Skip to content

Commit 59bf7ab

Browse files
committed
Fix duplicate state broadcasts from server
1 parent 7efb484 commit 59bf7ab

File tree

4 files changed

+265
-12
lines changed

4 files changed

+265
-12
lines changed

docs/ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ The backend leverages Cloudflare's edge network.
104104

105105
- **`runGameStateAction(ws, action, onSuccess)`**: Generic handler that reduces boilerplate across all 12+ action handlers. Fetches current state from storage → runs engine function in try/catch → on validation error sends error message to the WebSocket → on exception logs with game code/phase/turn and sends error (state is preserved via clone-on-entry) → on success invokes `onSuccess` callback (typically save state + broadcast). `handleTurnTimeout` has equivalent try/catch protection for the alarm-driven code path.
106106

107+
- **Single state-bearing outbound message per action**: `publishStateChange()` persists state and events first, then emits exactly one state-bearing message (`movementResult`, `combatResult`, or `stateUpdate`). If the resulting state is terminal, the DO appends a separate `gameOver` notification after that state-bearing message. This keeps client phase transitions aligned with a one-action/one-update contract.
108+
107109
- **Filtered broadcasting**: `broadcastFiltered()` checks whether the current scenario has hidden information (fugitive identities in escape scenarios). If no hidden info, the same state goes to both players. If hidden info, `filterStateForPlayer(state, playerId)` is called separately per player — own ships are fully visible, unrevealed enemy ships show `type: 'unknown'`. When adding new hidden state, extend `filterStateForPlayer()` and the check in `broadcastFiltered()`.
108110

109111
- **Single-alarm scheduling**: One alarm per DO, rescheduled after each state change. Three independent deadlines are stored: `disconnectAt` (30s grace), `turnTimeoutAt` (2 min), `inactivityAt` (5 min). `getNextAlarmAt()` computes the nearest deadline. When the alarm fires, `resolveAlarmAction()` returns a discriminated action (`disconnectExpired`, `turnTimeout`, `inactivityTimeout`) and the handler dispatches accordingly.
@@ -186,7 +188,7 @@ All messages are discriminated unions validated at the protocol boundary. `GameS
186188
POST /create → Worker generates room code + tokens → DO /init
187189
WebSocket /ws/{code}?playerToken=X → DO accepts, tags socket with player ID
188190
Both players connected → createGame() → broadcast gameStart
189-
Game loop: C2S action → engine → broadcast S2C result → save state → restart timer
191+
Game loop: C2S action → engine → save state/events → restart timer → broadcast S2C result
190192
Disconnect → 30s grace period → reconnect with token or forfeit
191193
```
192194

docs/BACKLOG.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,67 @@ Remaining work only. Completed items are in git history.
44

55
---
66

7+
## Review Follow-Up Plan
8+
9+
Current direction is good. This plan is aimed at removing the
10+
main correctness and coordination risks found in the latest
11+
project review without rewriting working systems.
12+
13+
### Phase 0.5. Tighten protocol boundary consistency
14+
15+
- Trim chat text before validating it in `src/server/protocol.ts`.
16+
- Reject messages that become empty after trimming so non-UI
17+
clients cannot produce blank chat log entries.
18+
- Add protocol tests for whitespace-only chat and normal trimmed
19+
chat payloads.
20+
21+
### Phase 1. Consolidate protocol validation
22+
23+
- Move C2S/S2C runtime schema ownership next to the shared
24+
protocol message definitions instead of maintaining a large
25+
hand-written parser island in `src/server/protocol.ts`.
26+
- Keep room-code, token, and seat-assignment helpers separate if
27+
they stay generic; focus schema consolidation on message
28+
payloads first.
29+
- Use the shared schema layer for both runtime validation and
30+
compile-time TypeScript inference where practical.
31+
- Target outcome: one source of truth for protocol shape, smaller
32+
validator surface area, and cheaper message evolution.
33+
34+
### Phase 2. Finish consolidating client state ownership
35+
36+
- Extract `ClientContext` and `PlanningState` mutation behind a
37+
small store or reducer layer instead of mutating the shared
38+
object directly from `main.ts` and helper deps.
39+
- Move the imperative `setState()` side-effect block toward
40+
explicit transition handlers so phase-entry behavior is easier
41+
to test in isolation.
42+
- Keep `GameClient` as the bootstrap/wiring shell for renderer,
43+
connection, and UI composition.
44+
- Target outcome: `main.ts` becomes orchestration glue instead of
45+
the default home for future gameplay coupling.
46+
47+
### Phase 3. Enforce shared type boundaries
48+
49+
- Replace broad barrel imports from `shared/types` with direct
50+
imports from `shared/types/domain`, `shared/types/protocol`,
51+
and `shared/types/scenario`.
52+
- Update docs and import conventions so new modules follow the
53+
bounded split by default.
54+
- If needed, add a lint rule or lightweight repo check once the
55+
bulk migration is complete.
56+
- Target outcome: the type split becomes architectural reality,
57+
not just a file-layout improvement.
58+
59+
### Suggested order
60+
61+
1. Phase 0.5: chat boundary tightening.
62+
2. Phase 1: protocol schema consolidation.
63+
3. Phase 2: client store/transition consolidation.
64+
4. Phase 3: direct shared-type imports and boundary enforcement.
65+
66+
---
67+
768
## Maintenance
869

970
### Phase 5. Stronger entity state models

src/server/game-do/game-do.test.ts

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import {
3+
handleServerMessage,
4+
type MessageHandlerDeps,
5+
} from '../../client/game/message-handler';
26
import { must } from '../../shared/assert';
3-
import type { GameState } from '../../shared/types';
7+
import type { MovementResult } from '../../shared/engine/game-engine';
8+
import type { GameState, S2C } from '../../shared/types';
49
import type { Env } from './game-do';
510

611
vi.mock('cloudflare:workers', () => ({
@@ -21,6 +26,7 @@ import {
2126
SCENARIOS,
2227
} from '../../shared/map-data';
2328
import { GameDO } from './game-do';
29+
import { toMovementResultMessage, toStateUpdateMessage } from './messages';
2430

2531
class MockStorage {
2632
private data = new Map<string, unknown>();
@@ -73,6 +79,68 @@ function createCtx(): MockDurableObjectState {
7379
},
7480
};
7581
}
82+
83+
const createSocket = () => ({
84+
sent: [] as string[],
85+
send(payload: string) {
86+
this.sent.push(payload);
87+
},
88+
});
89+
90+
const createMessageHandlerDeps = (
91+
state: GameState | null = null,
92+
): MessageHandlerDeps & { transitionCount: number } => {
93+
let transitionCount = 0;
94+
const deps: MessageHandlerDeps & { transitionCount: number } = {
95+
ctx: {
96+
state: 'playing_astrogation',
97+
playerId: 0,
98+
gameCode: null,
99+
reconnectAttempts: 0,
100+
latencyMs: 0,
101+
gameState: state,
102+
},
103+
transitionCount,
104+
setState(nextState) {
105+
deps.ctx.state = nextState;
106+
},
107+
applyGameState(nextState) {
108+
deps.ctx.gameState = nextState;
109+
},
110+
transitionToPhase() {
111+
transitionCount += 1;
112+
deps.transitionCount = transitionCount;
113+
deps.ctx.state = 'playing_ordnance';
114+
},
115+
presentMovementResult() {},
116+
presentCombatResults() {},
117+
showGameOverOutcome() {},
118+
storePlayerToken() {},
119+
resetTurnTelemetry() {},
120+
onAnimationComplete() {},
121+
logScenarioBriefing() {},
122+
deserializeState(raw) {
123+
return raw;
124+
},
125+
renderer: {
126+
setPlayerId() {},
127+
clearTrails() {},
128+
},
129+
ui: {
130+
showToast() {},
131+
logText() {},
132+
setChatEnabled() {},
133+
hideReconnecting() {},
134+
setPlayerId() {},
135+
clearLog() {},
136+
showRematchPending() {},
137+
showGameOver() {},
138+
updateLatency() {},
139+
},
140+
};
141+
return deps;
142+
};
143+
76144
describe('GameDO', () => {
77145
beforeEach(() => {
78146
vi.restoreAllMocks();
@@ -253,4 +321,130 @@ describe('GameDO', () => {
253321
expect(trace.indexOf('put:gameState')).toBeLessThan(trace.indexOf('send'));
254322
expect(await ctx.storage.get('gameState')).toEqual(state);
255323
});
324+
325+
it('emits one state-bearing message for state-update actions', async () => {
326+
const ctx = createCtx();
327+
const ws = createSocket();
328+
ctx.acceptWebSocket(ws, ['player:0']);
329+
const game = createGameDO(ctx);
330+
const state = createGame(
331+
SCENARIOS.duel,
332+
buildSolarSystemMap(),
333+
'STAT1',
334+
findBaseHex,
335+
);
336+
337+
await (
338+
game as unknown as {
339+
publishStateChange: (
340+
state: GameState,
341+
primaryMessage?: unknown,
342+
options?: { restartTurnTimer?: boolean; events?: unknown[] },
343+
) => Promise<void>;
344+
}
345+
).publishStateChange(state, toStateUpdateMessage(state), {
346+
restartTurnTimer: false,
347+
});
348+
349+
const messages = ws.sent.map((payload) => JSON.parse(payload) as S2C);
350+
351+
expect(messages).toHaveLength(1);
352+
expect(messages[0]).toEqual({
353+
type: 'stateUpdate',
354+
state,
355+
});
356+
357+
const deps = createMessageHandlerDeps();
358+
for (const message of messages) {
359+
handleServerMessage(deps, message);
360+
}
361+
expect(deps.transitionCount).toBe(1);
362+
});
363+
364+
it('keeps movement results as the only state-bearing message', async () => {
365+
const ctx = createCtx();
366+
const ws = createSocket();
367+
ctx.acceptWebSocket(ws, ['player:0']);
368+
const game = createGameDO(ctx);
369+
const state = createGame(
370+
SCENARIOS.duel,
371+
buildSolarSystemMap(),
372+
'MOVE1',
373+
findBaseHex,
374+
);
375+
const movementResult: MovementResult = {
376+
state,
377+
movements: [],
378+
ordnanceMovements: [],
379+
events: [],
380+
};
381+
382+
await (
383+
game as unknown as {
384+
publishStateChange: (
385+
state: GameState,
386+
primaryMessage?: unknown,
387+
options?: { restartTurnTimer?: boolean; events?: unknown[] },
388+
) => Promise<void>;
389+
}
390+
).publishStateChange(state, toMovementResultMessage(movementResult), {
391+
restartTurnTimer: false,
392+
});
393+
394+
const messages = ws.sent.map((payload) => JSON.parse(payload) as S2C);
395+
396+
expect(messages).toHaveLength(1);
397+
expect(messages[0]).toEqual({
398+
type: 'movementResult',
399+
movements: [],
400+
ordnanceMovements: [],
401+
events: [],
402+
state,
403+
});
404+
});
405+
406+
it('appends game-over after a single state-bearing terminal update', async () => {
407+
const ctx = createCtx();
408+
const ws = createSocket();
409+
ctx.acceptWebSocket(ws, ['player:0']);
410+
const game = createGameDO(ctx);
411+
const base = createGame(
412+
SCENARIOS.duel,
413+
buildSolarSystemMap(),
414+
'OVER1',
415+
findBaseHex,
416+
);
417+
const state: GameState = {
418+
...base,
419+
phase: 'gameOver',
420+
winner: 0,
421+
winReason: 'Fleet eliminated!',
422+
};
423+
424+
await (
425+
game as unknown as {
426+
publishStateChange: (
427+
state: GameState,
428+
primaryMessage?: unknown,
429+
options?: { restartTurnTimer?: boolean; events?: unknown[] },
430+
) => Promise<void>;
431+
}
432+
).publishStateChange(state, toStateUpdateMessage(state), {
433+
restartTurnTimer: false,
434+
});
435+
436+
const messages = ws.sent.map((payload) => JSON.parse(payload) as S2C);
437+
438+
expect(messages).toEqual([
439+
{
440+
type: 'stateUpdate',
441+
state,
442+
},
443+
{
444+
type: 'gameOver',
445+
winner: 0,
446+
reason: 'Fleet eliminated!',
447+
},
448+
]);
449+
});
256450
});

src/server/game-do/game-do.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -484,10 +484,7 @@ export class GameDO extends DurableObject<Env> {
484484
if (restartTurnTimer) {
485485
await this.startTurnTimer(state);
486486
}
487-
if (primaryMessage) {
488-
this.broadcastFiltered(primaryMessage);
489-
}
490-
this.broadcastEndOrUpdate(state);
487+
this.broadcastStateChange(state, primaryMessage);
491488
}
492489
private async runGameStateAction<
493490
Success extends {
@@ -803,18 +800,17 @@ export class GameDO extends DurableObject<Env> {
803800
this.broadcast({ type: 'rematchPending' });
804801
}
805802
}
806-
private broadcastEndOrUpdate(state: GameState) {
803+
private broadcastStateChange(
804+
state: GameState,
805+
primaryMessage?: StatefulServerMessage,
806+
) {
807+
this.broadcastFiltered(primaryMessage ?? toStateUpdateMessage(state));
807808
if (state.phase === 'gameOver') {
808809
this.broadcast({
809810
type: 'gameOver',
810811
winner: must(state.winner),
811812
reason: must(state.winReason),
812813
});
813-
} else {
814-
this.broadcastFiltered({
815-
type: 'stateUpdate',
816-
state,
817-
});
818814
}
819815
}
820816
// --- Messaging ---

0 commit comments

Comments
 (0)