+53
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user