Skip to content

Commit 6f57fa8

Browse files
committed
Extract client game state store helper
1 parent 8040424 commit 6f57fa8

File tree

5 files changed

+138
-12
lines changed

5 files changed

+138
-12
lines changed

docs/ARCHITECTURE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ The frontend renders the pure hex-grid state into a smooth, continuous graphical
147147

148148
#### Key Design Patterns
149149

150-
- **`main.ts`**: The client-side coordinator. Manages WebSocket connections, local-AI execution, and top-level composition. It now delegates command dispatch to `game/command-router.ts`, client state-entry side effects to `game/state-transition.ts`, and session lifecycle flows to `game/session-controller.ts` instead of keeping those blocks inline.
150+
- **`main.ts`**: The client-side coordinator. Manages WebSocket connections, local-AI execution, and top-level composition. It now delegates command dispatch to `game/command-router.ts`, game-state application to `game/game-state-store.ts`, client state-entry side effects to `game/state-transition.ts`, and session lifecycle flows to `game/session-controller.ts` instead of keeping those blocks inline.
151151
- **`renderer/renderer.ts`**: A highly optimized Canvas 2D renderer. It separates logical hex coordinates from pixel coordinates. It features smooth camera interpolation, persistent trails, and movement/combat animations that occur *between* turn phases.
152152
- **`input.ts`**: Manages user interaction (panning, zooming, clicking). It translates raw browser events into `InputEvent` objects. Pure `interpretInput()` then maps these to `GameCommand[]`, ensuring the input layer never directly mutates the application state.
153-
- **`game/`**: Command routing, action handlers (astrogation/combat/ordnance), phase derivation, transition helpers, session helpers, transport abstraction, connection management, input interpretation, view-model helpers, and presentation logic. Ordnance-phase auto-selection and HUD legality are derived from shared engine rules instead of client-only cargo heuristics.
153+
- **`game/`**: Command routing, action handlers (astrogation/combat/ordnance), phase derivation, game-state helpers, transition helpers, session helpers, transport abstraction, connection management, input interpretation, view-model helpers, and presentation logic. Ordnance-phase auto-selection and HUD legality are derived from shared engine rules instead of client-only cargo heuristics.
154154
- **`renderer/`**: Canvas drawing layers (scene, entities, vectors, effects, overlays), camera, minimap, and animation management.
155155
- **`ui/`**: Screen visibility, HUD view building, button bindings, game log, fleet building, ship list, formatters, and layout metrics.
156156
- **`ui/ui.ts`** / **`audio.ts`**: Handles the HTML overlay (menus, HUD) and Web Audio API interactions.
@@ -205,6 +205,7 @@ main.ts (GameClient)
205205
├→ input.ts (parse mouse/keyboard → InputEvent)
206206
├→ ui/ui.ts (manage screens, accept UIEvent)
207207
├→ game/command-router.ts (GameCommand → state mutation or network)
208+
├→ game/game-state-store.ts (apply authoritative game state + renderer sync)
208209
├→ game/session-controller.ts (create/join/local-start/exit session lifecycle)
209210
├→ game/state-transition.ts (client-state entry effects and screen changes)
210211
├→ game/network.ts, game/messages.ts (handle S2C)

docs/BACKLOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ project review without rewriting working systems.
2222
create/join/local-start/exit session flows, by moving the rest
2323
of session-level `ClientContext` mutation behind explicit
2424
helpers instead of leaving it in `main.ts`.
25+
- Keep building on `game/game-state-store.ts`, which now owns
26+
authoritative client-side `GameState` replacement, renderer
27+
sync, and selection cleanup, so state application stops being
28+
open-coded in `main.ts`.
2529
- Keep `GameClient` as the bootstrap/wiring shell for renderer,
2630
connection, and UI composition.
2731
- Target outcome: `main.ts` becomes orchestration glue instead of
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createGame } from '../../shared/engine/game-engine';
4+
import {
5+
buildSolarSystemMap,
6+
findBaseHex,
7+
SCENARIOS,
8+
} from '../../shared/map-data';
9+
import type { GameState } from '../../shared/types';
10+
import {
11+
type ApplyClientGameStateDeps,
12+
applyClientGameState,
13+
} from './game-state-store';
14+
15+
const createState = (overrides: Partial<GameState> = {}): GameState => ({
16+
...createGame(SCENARIOS.duel, buildSolarSystemMap(), 'STORE1', findBaseHex),
17+
...overrides,
18+
});
19+
20+
const createDeps = (
21+
selectedShipId: string | null = null,
22+
): ApplyClientGameStateDeps & {
23+
rendererCalls: GameState[];
24+
} => {
25+
const rendererCalls: GameState[] = [];
26+
27+
return {
28+
ctx: {
29+
gameState: null,
30+
planningState: {
31+
selectedShipId,
32+
},
33+
},
34+
renderer: {
35+
setGameState: (state) => {
36+
rendererCalls.push(state);
37+
},
38+
},
39+
rendererCalls,
40+
};
41+
};
42+
43+
describe('applyClientGameState', () => {
44+
it('stores the game state and updates the renderer', () => {
45+
const state = createState();
46+
const deps = createDeps();
47+
48+
applyClientGameState(deps, state);
49+
50+
expect(deps.ctx.gameState).toBe(state);
51+
expect(deps.rendererCalls).toEqual([state]);
52+
});
53+
54+
it('keeps the selected ship when it still exists and is alive', () => {
55+
const state = createState();
56+
const selectedShipId = state.ships[0]?.id ?? null;
57+
const deps = createDeps(selectedShipId);
58+
59+
applyClientGameState(deps, state);
60+
61+
expect(deps.ctx.planningState.selectedShipId).toBe(selectedShipId);
62+
});
63+
64+
it('clears the selected ship when it no longer exists', () => {
65+
const state = createState();
66+
const deps = createDeps('missing-ship');
67+
68+
applyClientGameState(deps, state);
69+
70+
expect(deps.ctx.planningState.selectedShipId).toBeNull();
71+
});
72+
73+
it('clears the selected ship when it was destroyed', () => {
74+
const state = createState({
75+
ships: createState().ships.map((ship, index) =>
76+
index === 0 ? { ...ship, destroyed: true } : ship,
77+
),
78+
});
79+
const destroyedShipId = state.ships[0]?.id ?? null;
80+
const deps = createDeps(destroyedShipId);
81+
82+
applyClientGameState(deps, state);
83+
84+
expect(deps.ctx.planningState.selectedShipId).toBeNull();
85+
});
86+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { GameState } from '../../shared/types';
2+
3+
interface PlanningStateLike {
4+
selectedShipId: string | null;
5+
}
6+
7+
interface GameStateStoreContext {
8+
gameState: GameState | null;
9+
planningState: PlanningStateLike;
10+
}
11+
12+
interface GameStateStoreRenderer {
13+
setGameState: (state: GameState) => void;
14+
}
15+
16+
export interface ApplyClientGameStateDeps {
17+
ctx: GameStateStoreContext;
18+
renderer: GameStateStoreRenderer;
19+
}
20+
21+
export const applyClientGameState = (
22+
deps: ApplyClientGameStateDeps,
23+
state: GameState,
24+
): void => {
25+
deps.ctx.gameState = state;
26+
deps.renderer.setGameState(state);
27+
28+
const selectedId = deps.ctx.planningState.selectedShipId;
29+
if (!selectedId) {
30+
return;
31+
}
32+
33+
const selectedShip = state.ships.find((ship) => ship.id === selectedId);
34+
if (!selectedShip || selectedShip.destroyed) {
35+
deps.ctx.planningState.selectedShipId = null;
36+
}
37+
};

src/client/main.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
createConnectionManager,
5555
} from './game/connection';
5656
import { resolveLocalFleetReady } from './game/fleet';
57+
import { applyClientGameState } from './game/game-state-store';
5758
import { deriveHudViewModel } from './game/helpers';
5859
import { getTooltipShip } from './game/hover';
5960
import { type InputEvent, interpretInput } from './game/input-events';
@@ -535,16 +536,13 @@ class GameClient {
535536
this.connection.send(msg);
536537
}
537538
private applyGameState(state: GameState) {
538-
this.ctx.gameState = state;
539-
this.renderer.setGameState(state);
540-
// Clear selection if the selected ship was destroyed
541-
const selectedId = this.ctx.planningState.selectedShipId;
542-
if (selectedId) {
543-
const ship = state.ships.find((s) => s.id === selectedId);
544-
if (!ship || ship.destroyed) {
545-
this.ctx.planningState.selectedShipId = null;
546-
}
547-
}
539+
applyClientGameState(
540+
{
541+
ctx: this.ctx,
542+
renderer: this.renderer,
543+
},
544+
state,
545+
);
548546
}
549547
private presentMovementResult(
550548
state: GameState,

0 commit comments

Comments
 (0)