batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s
CI / build + unit + e2e (pull_request) Successful in 1m51s
This commit is contained in:
+130
-7
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import type { TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import {
|
||||
applyTurnResult,
|
||||
buildUserPrompt,
|
||||
isMeaningful,
|
||||
parseTurnResult,
|
||||
queueRoundItem,
|
||||
runTurn,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
@@ -68,6 +71,7 @@ function baseState(): GameState {
|
||||
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,17 +89,18 @@ const validResult = JSON.stringify({
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date, pending cleared', async () => {
|
||||
const original = baseState();
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state: original,
|
||||
action: 'We rearm the coast',
|
||||
items: [{ kind: 'action', text: 'We rearm the coast' }],
|
||||
});
|
||||
|
||||
expect(state.turn).toBe(2); // the turn counter advanced
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]); // committing a round always clears it
|
||||
expect(state.nations.uk.stats.military).toBe(85);
|
||||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||||
// relations are symmetric
|
||||
@@ -131,7 +136,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm([clampResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.nations.uk.stats.military).toBe(100);
|
||||
expect(state.nations.uk.stats.stability).toBe(0);
|
||||
@@ -142,7 +147,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm(['total garbage', 'still not json']),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events).toHaveLength(1);
|
||||
@@ -162,7 +167,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm([emptyResult, emptyResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
@@ -174,13 +179,116 @@ describe('runTurn', () => {
|
||||
const { state: next } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state, // no action / no chat = jump forward
|
||||
state, // no items = an empty round / jump forward
|
||||
items: [],
|
||||
});
|
||||
expect(next.turn).toBe(4);
|
||||
expect(next.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTurn (batched round, deterministic stub)', () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
|
||||
const { state, result } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]);
|
||||
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].type).toBe('military');
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
expect(state.events[0].outcome).toBe('success');
|
||||
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
|
||||
// the original object is not mutated (a fresh queue was consumed)
|
||||
expect(original.pending).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: baseState(), // pending: []
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]);
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].type).toBe('political');
|
||||
expect(state.events[0].title).toBe('The world moves on');
|
||||
expect(state.events[0].outcome).toBe('neutral');
|
||||
});
|
||||
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
|
||||
{ kind: 'action', text: 'Expand the shipyards' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'Propose a mutual defense pact' },
|
||||
];
|
||||
const { state: next } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state,
|
||||
items: state.pending,
|
||||
});
|
||||
expect(next.turn).toBe(2); // a whole batch = one turn
|
||||
expect(next.pending).toEqual([]);
|
||||
expect(next.events).toHaveLength(3);
|
||||
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
|
||||
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
|
||||
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt (batched round)', () => {
|
||||
it('includes every queued item (action + diplomacy) in the emitted payload', () => {
|
||||
const items: RoundItem[] = [
|
||||
{ kind: 'action', text: 'Fortify Dover' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'Propose a pact' },
|
||||
];
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), items)) as {
|
||||
pending: RoundItem[];
|
||||
playerActions: unknown[];
|
||||
roundInstruction: string;
|
||||
};
|
||||
expect(parsed.pending).toEqual(items);
|
||||
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
|
||||
expect(parsed.roundInstruction).toContain('Fortify Dover');
|
||||
expect(parsed.roundInstruction).toContain('Propose a pact');
|
||||
});
|
||||
|
||||
it('frames an empty round as "the world advances on its own momentum"', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
pending: RoundItem[];
|
||||
roundInstruction: string;
|
||||
};
|
||||
expect(parsed.pending).toEqual([]);
|
||||
expect(parsed.roundInstruction).toMatch(/no action/i);
|
||||
expect(parsed.roundInstruction).toMatch(/own momentum/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueRoundItem', () => {
|
||||
it('appends an item to pending without advancing the turn or the date', () => {
|
||||
const before = baseState();
|
||||
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
|
||||
expect(after.turn).toBe(before.turn);
|
||||
expect(after.date).toBe(before.date);
|
||||
expect(after.pending).toHaveLength(1);
|
||||
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
|
||||
// the original state is not mutated
|
||||
expect(before.pending).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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```');
|
||||
@@ -237,6 +345,21 @@ describe('applyTurnResult', () => {
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('clears the pending round on commit', () => {
|
||||
const state = baseState();
|
||||
state.pending = [{ kind: 'action', text: 'x' }];
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
|
||||
Reference in New Issue
Block a user