Map (WorldMap): - Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that host) so the choropleth nation colors pop - Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter thicker amber border for the player nation so "this is me" is always obvious - Hover brightens a region and shows a color-chipped owner + key stats popup; click opens a full dossier popup and focuses the left panel on that nation - Transient, self-cleaning flash (pulsing amber outline) over regions whose ownership changed this turn - Bottom-left legend auto-generated from the region map; per-scenario initial camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis) Command-center UI: - New header bar: scenario title, prominent turn + date, the player nation (name + color chip), and a live Idle / "Simulating…" status indicator - 3-column operations-console grid: nation dossier (left) / dominant map (center) / command deck + event feed (right); stacks map-first on mobile - EventFeed: one card per event with a category icon, a colored outcome badge (success/failure/partial/neutral), a nation-colored accent edge, and the turn + date; new events slide in and flash at the top - NationPanel: value-colored animated stat bars (red → green), territory as color-coded region chips, and diplomacy as a list with a colored indicator per relation (ally/rival/war/neutral) - ActionPanel: a "Your move" composer with one prominent Send button and a spinner + "turns can take a few seconds" hint while a turn resolves - sonner error toasts: a 502 / model-down surfaces as "AI temporarily unavailable — try again" Live LLM rewire (server, previously uncommitted): - Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its chain-of-thought before emitting content; read only `content`, ignore `reasoning`. docker-compose + AGENTS.md updated to match. Tests: the deterministic Playwright e2e gains assertions for the dossier stat bars, the map legend (all nations), and the event-card type icon + outcome badge; it stays fully deterministic under LLM_STUB=1.
249 lines
8.8 KiB
TypeScript
249 lines
8.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { GameState, Preset } from '../shared/types';
|
|
import type { TurnResult } from '../shared/protocol';
|
|
import type { LlmClient } from './llm';
|
|
import {
|
|
applyTurnResult,
|
|
isMeaningful,
|
|
parseTurnResult,
|
|
runTurn,
|
|
validateTurnResult,
|
|
} from './engine';
|
|
|
|
/** A fake LlmClient that returns a fixed list of raw responses in order. */
|
|
function fakeLlm(responses: string[]): LlmClient {
|
|
let i = 0;
|
|
return {
|
|
chat: async () => responses[Math.min(i++, responses.length - 1)],
|
|
listModels: async () => [],
|
|
};
|
|
}
|
|
|
|
const preset: Preset = {
|
|
id: 'test',
|
|
title: 'Test',
|
|
startDate: '1939-09-01',
|
|
summary: '',
|
|
worldBackground: 'A test world.',
|
|
simulationRules: ['Prefer gradual change'],
|
|
nations: [
|
|
{
|
|
id: 'uk',
|
|
name: 'UK',
|
|
type: 'major',
|
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
|
territory: ['GBR'],
|
|
relations: { us: 'neutral', de: 'hostile' },
|
|
},
|
|
{
|
|
id: 'us',
|
|
name: 'US',
|
|
type: 'major',
|
|
stats: { military: 60, economy: 90, stability: 80, population: 80 },
|
|
territory: ['USA'],
|
|
relations: { uk: 'neutral' },
|
|
},
|
|
{
|
|
id: 'de',
|
|
name: 'DE',
|
|
type: 'major',
|
|
stats: { military: 95, economy: 70, stability: 75, population: 90 },
|
|
territory: ['DEU'],
|
|
relations: {},
|
|
},
|
|
],
|
|
};
|
|
|
|
function baseState(): GameState {
|
|
return {
|
|
id: 'g1',
|
|
presetId: 'test',
|
|
presetTitle: 'Test',
|
|
playerNationId: 'uk',
|
|
turn: 1,
|
|
date: '1939-09-01',
|
|
nations: {
|
|
uk: { ...preset.nations[0], stats: { ...preset.nations[0].stats }, territory: [...preset.nations[0].territory], relations: { ...preset.nations[0].relations } },
|
|
us: { ...preset.nations[1], stats: { ...preset.nations[1].stats }, territory: [...preset.nations[1].territory], relations: { ...preset.nations[1].relations } },
|
|
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
|
},
|
|
events: [],
|
|
};
|
|
}
|
|
|
|
const validResult = JSON.stringify({
|
|
date: '1939-09-03',
|
|
events: [
|
|
{ nation: 'uk', type: 'military', title: 'T-26s cross the border', text: 'The border is breached.', outcome: 'success' },
|
|
],
|
|
stateChanges: [
|
|
{ nation: 'uk', stat: 'military', op: 'set', value: 85 },
|
|
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
|
|
],
|
|
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
|
|
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
|
|
});
|
|
|
|
describe('runTurn', () => {
|
|
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
|
|
const original = baseState();
|
|
const { state, result } = await runTurn({
|
|
llm: fakeLlm([validResult]),
|
|
preset,
|
|
state: original,
|
|
action: 'We rearm the coast',
|
|
});
|
|
|
|
expect(state.turn).toBe(2); // the turn counter advanced
|
|
expect(state.date).toBe('1939-09-03');
|
|
expect(state.nations.uk.stats.military).toBe(85);
|
|
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
|
// relations are symmetric
|
|
expect(state.nations.uk.relations.us).toBe('ally');
|
|
expect(state.nations.us.relations.uk).toBe('ally');
|
|
// territory moved to DE
|
|
expect(state.nations.de.territory).toContain('POL');
|
|
// one event appended, tagged with the turn that was resolved (1)
|
|
expect(state.events).toHaveLength(1);
|
|
expect(state.events[0].turn).toBe(1);
|
|
expect(state.events[0].title).toBe('T-26s cross the border');
|
|
expect(state.events[0].nation).toBe('uk');
|
|
// the return value carries the validated result too
|
|
expect(result.events[0].title).toBe('T-26s cross the border');
|
|
// and the original state object is NOT mutated
|
|
expect(original.turn).toBe(1);
|
|
expect(original.nations.uk.stats.military).toBe(80);
|
|
expect(original.nations.de.territory).not.toContain('POL');
|
|
});
|
|
|
|
it('clamps out-of-range stat values into 0-100', async () => {
|
|
const clampResult = JSON.stringify({
|
|
date: '1939-09-03',
|
|
events: [{ nation: 'uk', type: 'economic', title: 'cap', text: 'x', outcome: 'neutral' }],
|
|
stateChanges: [
|
|
{ nation: 'uk', stat: 'military', op: 'set', value: 250 },
|
|
{ nation: 'uk', stat: 'stability', op: 'set', value: -20 },
|
|
],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
});
|
|
const { state } = await runTurn({
|
|
llm: fakeLlm([clampResult]),
|
|
preset,
|
|
state: baseState(),
|
|
action: 'x',
|
|
});
|
|
expect(state.nations.uk.stats.military).toBe(100);
|
|
expect(state.nations.uk.stats.stability).toBe(0);
|
|
});
|
|
|
|
it('survives an invalid-JSON response: no throw, advances turn, emits a generic event', async () => {
|
|
const { state, result } = await runTurn({
|
|
llm: fakeLlm(['total garbage', 'still not json']),
|
|
preset,
|
|
state: baseState(),
|
|
action: 'x',
|
|
});
|
|
expect(state.turn).toBe(2);
|
|
expect(state.events).toHaveLength(1);
|
|
expect(state.events[0].title).toBe('The turn was unclear');
|
|
expect(result.events[0].title).toBe('The turn was unclear');
|
|
});
|
|
|
|
it('treats a valid-but-empty result as unclear (not meaningful)', async () => {
|
|
const emptyResult = JSON.stringify({
|
|
date: '1939-09-03',
|
|
events: [],
|
|
stateChanges: [],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
});
|
|
const { state } = await runTurn({
|
|
llm: fakeLlm([emptyResult, emptyResult]),
|
|
preset,
|
|
state: baseState(),
|
|
action: 'x',
|
|
});
|
|
expect(state.turn).toBe(2);
|
|
expect(state.events[0].title).toBe('The turn was unclear');
|
|
});
|
|
|
|
it('"jump forward" (no action) still advances the turn and emits an event', async () => {
|
|
const state = baseState();
|
|
state.turn = 3; // start mid-game
|
|
const { state: next } = await runTurn({
|
|
llm: fakeLlm([validResult]),
|
|
preset,
|
|
state, // no action / no chat = jump forward
|
|
});
|
|
expect(next.turn).toBe(4);
|
|
expect(next.events).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('parseTurnResult', () => {
|
|
it('parses a fenced JSON block', () => {
|
|
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
|
expect(r?.events[0].title).toBe('t');
|
|
});
|
|
|
|
it('parses JSON prefixed by leading whitespace/newlines (reasoning model emits "\\n\\n{...}")', () => {
|
|
const r = parseTurnResult('\n\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n');
|
|
expect(r?.events[0].title).toBe('t');
|
|
expect(r?.date).toBe('1939-09-03');
|
|
});
|
|
|
|
it('extracts the object from surrounding prose', () => {
|
|
const r = parseTurnResult('Sure! Here is the result: {"date":"1939-09-03","events":[]} hope that helps');
|
|
expect(r).not.toBeNull();
|
|
});
|
|
|
|
it('returns null for non-JSON output', () => {
|
|
expect(parseTurnResult('no json here at all')).toBeNull();
|
|
});
|
|
|
|
it('validateTurnResult ignores unknown/invalid entries and clamps', () => {
|
|
const r = validateTurnResult({
|
|
date: '1939-09-03',
|
|
events: [
|
|
{ nation: 'uk', type: 'bogus', title: 't', text: 'b', outcome: 'weird' },
|
|
{ nation: 'uk', title: 'missing text' },
|
|
],
|
|
stateChanges: [
|
|
{ nation: 'uk', stat: 'notastat', op: 'set', value: 5 },
|
|
{ nation: 'uk', stat: 'military', op: 'set', value: 150 },
|
|
],
|
|
});
|
|
expect(r).not.toBeNull();
|
|
expect(r!.events).toHaveLength(1); // the invalid one was dropped
|
|
expect(r!.events[0].type).toBe('political'); // bogus type -> default
|
|
expect(r!.events[0].outcome).toBe('neutral'); // bogus outcome -> default
|
|
expect(r!.stateChanges).toHaveLength(1);
|
|
expect(r!.stateChanges[0].value).toBe(100); // clamped
|
|
});
|
|
});
|
|
|
|
describe('applyTurnResult', () => {
|
|
it('does not mutate its input', () => {
|
|
const state = baseState();
|
|
const snapshot = JSON.stringify(state);
|
|
const result: TurnResult = {
|
|
date: '1939-09-03',
|
|
events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'success' }],
|
|
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
|
|
relations: [],
|
|
territoryChanges: [],
|
|
};
|
|
applyTurnResult(state, result);
|
|
expect(JSON.stringify(state)).toBe(snapshot);
|
|
});
|
|
});
|
|
|
|
describe('isMeaningful', () => {
|
|
it('is true with an event, false when everything is empty', () => {
|
|
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true);
|
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true);
|
|
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false);
|
|
});
|
|
});
|