0.4.0 — fix queue-add race + per-item remove button (#10, #11)

This commit is contained in:
2026-10-11 16:09:29 +00:00
parent 3287bcaca4
commit ef27b59762
14 changed files with 448 additions and 29 deletions
+53 -13
View File
@@ -153,6 +153,26 @@ export default function App() {
// never acts on a stale closure. Written each render (same pattern as sse.ts).
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
// The single entry point for adopting a full game state — SSE `state`/`turn`
// events AND the REST responses (add/remove/jump) AND game loads all flow
// through here. Two rules:
// • a *different* game id (new game, deep link, resume) is always adopted;
// • the *same* game is adopted only when its `version` is not older than
// the current state's ("never go backwards").
// The comparison happens inside the functional updater, so it always reads
// the latest committed state — never a stale closure. This is what makes a
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
// before an add was saved, or an EventSource auto-reconnect) harmless: it
// carries a lower version than the fresher state the REST response already
// applied, so it is dropped instead of clobbering the queued item (#10).
// Legacy games saved before `version` existed are treated as version 0.
const applyState = useCallback((s: GameState) => {
setGame((cur) => {
if (!cur || cur.id !== s.id) return s;
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
});
}, []);
// On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game.
useEffect(() => {
@@ -192,7 +212,7 @@ export default function App() {
if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id);
setGame(game);
applyState(game);
setScreen({ name: 'game' });
} catch {
if (!alive) return;
@@ -250,10 +270,9 @@ export default function App() {
}, [game]);
// Resync from the persisted server state on connect and after every turn.
// Handlers only call the stable setGame, so no stale-closure concerns.
useSse(game?.id ?? null, {
onState: (s) => setGame(s),
onTurn: (s) => setGame(s),
onState: applyState,
onTurn: applyState,
});
const play = useCallback(async (presetId: string) => {
@@ -282,7 +301,7 @@ export default function App() {
// Remember this as the active game so a refresh resumes it.
persistActiveGameId(game.id);
setActiveGameId(game.id);
setGame(game);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
@@ -295,18 +314,20 @@ export default function App() {
);
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
// list updates from the response and the SSE `state` event.
// list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
// stale snapshot (#10).
const runAddAction = useCallback(
async (text: string) => {
if (!game) return;
try {
const { game: g } = await api.addAction(game.id, text);
setGame(g);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game],
[game, applyState],
);
// Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
@@ -315,12 +336,27 @@ export default function App() {
if (!game) return;
try {
const { game: g } = await api.addDiplomacy(game.id, to, message);
setGame(g);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game],
[game, applyState],
);
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
// updates from the response and the SSE `state` event (version-gated).
const runRemoveQueueItem = useCallback(
async (index: number) => {
if (!game) return;
try {
const { game: g } = await api.removeQueueItem(game.id, index);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Jump forward: the single LLM call that commits the pending round. Only this
@@ -330,13 +366,13 @@ export default function App() {
setBusy(true);
try {
const { state } = await api.jump(game.id);
setGame(state);
applyState(state);
} catch (e) {
toastError(e);
} finally {
setBusy(false);
}
}, [game]);
}, [game, applyState]);
const askAdvisor = useCallback(
async (prompt?: string) => {
@@ -362,7 +398,7 @@ export default function App() {
// Make this the active game so a bare `/` reload keeps resuming it.
persistActiveGameId(id);
setActiveGameId(id);
setGame(game);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
@@ -401,6 +437,7 @@ export default function App() {
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor}
onJump={runJump}
onHome={goHome}
@@ -440,6 +477,7 @@ interface GameScreenProps {
advisors: string[];
onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void;
onJump: () => void;
onHome: () => void;
@@ -463,6 +501,7 @@ function GameScreen({
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
onHome,
@@ -663,6 +702,7 @@ function GameScreen({
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor}
onJump={onJump}
inWindow