@@ -22,6 +22,7 @@ function makeState(id: string): GameState {
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +55,11 @@ describe('openDb', () => {
|
||||
expect(db.load('a')?.events).toHaveLength(1);
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
|
||||
|
||||
// the monotonic version is persisted with the state and round-trips
|
||||
s.version = 3;
|
||||
db.save(s);
|
||||
expect(db.load('a')?.version).toBe(3);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
|
||||
+63
-1
@@ -9,6 +9,7 @@ import {
|
||||
isMeaningful,
|
||||
parseTurnResult,
|
||||
queueRoundItem,
|
||||
removeRoundItem,
|
||||
runTurn,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
@@ -72,6 +73,7 @@ function baseState(): GameState {
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,8 +208,11 @@ describe('runTurn (batched round, deterministic stub)', () => {
|
||||
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 }]);
|
||||
// committing a turn is a new revision too
|
||||
expect(state.version).toBe(2);
|
||||
// the original object is not mutated (a fresh queue was consumed)
|
||||
expect(original.pending).toHaveLength(1);
|
||||
expect(original.version).toBe(1);
|
||||
});
|
||||
|
||||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||||
@@ -286,6 +291,61 @@ describe('queueRoundItem', () => {
|
||||
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
|
||||
// the original state is not mutated
|
||||
expect(before.pending).toHaveLength(0);
|
||||
expect(before.version).toBe(1);
|
||||
});
|
||||
|
||||
it('bumps the monotonic version on each queue mutation', () => {
|
||||
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
|
||||
expect(a.version).toBe(2);
|
||||
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
|
||||
expect(b.version).toBe(3);
|
||||
// and never backwards: two consecutive adds from the same base both
|
||||
// advance past it, so a late snapshot of the older state is always older.
|
||||
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
|
||||
expect(b2.version).toBe(2);
|
||||
expect(b.version).toBeGreaterThan(b2.version);
|
||||
});
|
||||
|
||||
it('treats a legacy state without a version field as version 0', () => {
|
||||
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
|
||||
delete legacy.version; // a row saved before the version field existed
|
||||
// simulate a legacy row coming back from SQLite: the JSON has no field.
|
||||
const after = queueRoundItem(legacy as unknown as GameState, {
|
||||
kind: 'action',
|
||||
text: 'first after upgrade',
|
||||
});
|
||||
expect(after.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRoundItem', () => {
|
||||
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'one' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'two' },
|
||||
{ kind: 'action', text: 'three' },
|
||||
];
|
||||
const next = removeRoundItem(state, 1);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.version).toBe(2);
|
||||
expect(next!.pending).toEqual([
|
||||
{ kind: 'action', text: 'one' },
|
||||
{ kind: 'action', text: 'three' },
|
||||
]);
|
||||
expect(state.pending).toHaveLength(3); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
|
||||
const state = baseState();
|
||||
state.pending = [{ kind: 'action', text: 'one' }];
|
||||
expect(removeRoundItem(state, -1)).toBeNull();
|
||||
expect(removeRoundItem(state, 1)).toBeNull(); // == length
|
||||
expect(removeRoundItem(state, 5)).toBeNull();
|
||||
expect(removeRoundItem(state, 0.5)).toBeNull();
|
||||
expect(removeRoundItem(state, Number.NaN)).toBeNull();
|
||||
expect(state.pending).toHaveLength(1); // still untouched
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,7 +406,7 @@ describe('applyTurnResult', () => {
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('clears the pending round on commit', () => {
|
||||
it('clears the pending round on commit and bumps the version', () => {
|
||||
const state = baseState();
|
||||
state.pending = [{ kind: 'action', text: 'x' }];
|
||||
const result: TurnResult = {
|
||||
@@ -358,7 +418,9 @@ describe('applyTurnResult', () => {
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
expect(next.version).toBe(2);
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+22
-2
@@ -284,6 +284,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
@@ -325,15 +326,34 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
|
||||
/**
|
||||
* Immutably queue one item onto a round: append it to `pending` and return a new
|
||||
* state. This never advances the turn or the date — that only happens when a
|
||||
* round is committed via runTurn. Exported so the append-without-advance behavior
|
||||
* is unit-testable, and reused by the server's /action and /diplomacy routes.
|
||||
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
|
||||
* snapshots can never clobber it client-side). Exported so the append-without-
|
||||
* advance behavior is unit-testable, and reused by the /action and /diplomacy
|
||||
* routes.
|
||||
*/
|
||||
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
next.pending.push(item);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove one item from the pending round by index and return a new
|
||||
* state (bumping `version`). Returns `null` when the index is missing or not a
|
||||
* valid, in-bounds integer position, so the route can answer 400 without
|
||||
* mutating anything. Reused by the server's /queue/remove route.
|
||||
*/
|
||||
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
|
||||
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
|
||||
return null;
|
||||
}
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
next.pending.splice(index, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
||||
* model ONCE over the whole pending batch, parse/validate, retry once with a
|
||||
|
||||
+24
-2
@@ -9,11 +9,16 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import type { AddActionRequest, AddDiplomacyRequest, CreateGameRequest } from '../shared/protocol';
|
||||
import type {
|
||||
AddActionRequest,
|
||||
AddDiplomacyRequest,
|
||||
CreateGameRequest,
|
||||
RemoveQueueItemRequest,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, queueRoundItem, runTurn } from './engine';
|
||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
@@ -114,6 +119,7 @@ app.post('/api/games', (req: Request, res) => {
|
||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1, // the first saved revision; every later mutation bumps it
|
||||
};
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
@@ -179,6 +185,22 @@ app.post('/api/games/:id/diplomacy', (req, res) => {
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// Remove one item from the pending round, by its index in `game.pending`. No
|
||||
// LLM call, no turn advance — same fast save → broadcast → respond order as the
|
||||
// add routes above. The mutated state bumps `version`, so clients (which apply
|
||||
// states "never go backwards") always reconcile to it.
|
||||
app.post('/api/games/:id/queue/remove', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
|
||||
const next = removeRoundItem(game, index);
|
||||
if (!next) return res.status(400).json({ error: 'invalid queue index' });
|
||||
db.save(next);
|
||||
broadcast(id, 'state', { state: next });
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||
|
||||
@@ -30,6 +30,7 @@ const state: GameState = {
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
|
||||
Reference in New Issue
Block a user