The second time needed no spike

AI duel gets the same injectable-factory treatment the PvP store just proved out. With the pattern and the risk already settled, the ticket for this one only had to say what was smaller.

The question behind turning pvpGameState.svelte.ts into an injectable factory had always been about two stores, not one: duelGameState.svelte.ts, the AI-duel side of the same untestable-Svelte-store problem, was in scope from the start. The follow-on ticket filed at the time deliberately deferred it, reasoning that PvP was the harder case, SSE plus a localStorage session versus AI duel’s plain fetch-based turn polling, and the more informative one to spike first. Once that spike had already proven the runes tolerate the shape and the factory pattern had a locked design to copy, there was nothing left in the AI-duel version to investigate, only to build.

The ticket’s own prediction held exactly: converting duelGameState.svelte.ts was a smaller lift, because its whole transport surface was smaller to begin with.

export interface DuelGameApi {
	startDuelGame(language?: string): Promise<{ gameId: string; seed: number }>;
	fetchOpponentMove(
		gameId: string,
		state: DuelGameState,
		config: DuelGameConfig
	): Promise<DuelOpponentMoveResponse>;
	logPlayerMove(params: LogPlayerMoveParams): void;
	endDuelGame(params: EndDuelGameParams): void;
}

export interface DuelGameStoreDeps {
	api?: DuelGameApi;
	clock?: Clock;
}

Two optional fields against PvP’s four: no EventSource constructor to inject, since there is no SSE here, and no Storage to fake, since AI duel keeps no session token across reloads. fetchOpponentMove already routed through the already-injectable DuelTurnEngine, so the rest of the module’s per-turn logic moved into the factory as closures, unreshaped, the same way PvP’s outcome-application quartet had. createDuelGameStore() still sits at the bottom as the real production singleton, so +layout.svelte and the AI-duel page needed no changes at all.

The new tests prove the two things that actually mattered: a full player turn running end to end through a fake api and clock with zero real fetch calls reaching the network, and two factory instances built side by side staying genuinely independent, one appending a letter to its input without the other seeing it. Neither assertion had anywhere to live before this store had a seam to test through; the whole file’s coverage used to stop at whatever the end-to-end suite happened to exercise through a real page.