Skip to content

Commit 44650de

Browse files
committed
Align ordnance UI with shared rules
1 parent 24de198 commit 44650de

File tree

15 files changed

+389
-53
lines changed

15 files changed

+389
-53
lines changed

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ client/ → State machine + Canvas renderer + DOM UI
2323
- **Transport abstraction.** `GameTransport` decouples the client from WebSocket vs local (AI) play. The client doesn't know or care where state comes from.
2424
- **Functional style throughout.** Pure derivation functions (`deriveHudViewModel`, `deriveKeyboardAction`, `deriveBurnChangePlan`), mandatory injectable RNG, `cond()` for branching.
2525
- **Scenario-driven.** `ScenarioRules` controls behaviour: ordnance types, base sharing, combat enabled, checkpoints, escape edges. New scenarios can vary gameplay without engine changes.
26+
- **Shared rule reuse across layers.** Client ordnance entry, HUD button visibility, and engine validation now all derive from the same shared ordnance-rule helpers, so restricted scenarios do not drift between UI and server authority.
2627
- **Hidden state filtering.** `filterStateForPlayer` hides fugitive identities in escape scenarios — the server never leaks information the client shouldn't have.
2728

2829
---
@@ -144,7 +145,7 @@ The frontend renders the pure hex-grid state into a smooth, continuous graphical
144145
- **`main.ts`**: The client-side coordinator. Manages WebSocket connections, local-AI execution, and phase transitions. Orchestrates the Renderer, Input, and UI through a centralized **`ClientContext`**. Commands are dispatched via `dispatchGameCommand()` in `game/command-router.ts`.
145146
- **`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.
146147
- **`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.
147-
- **`game/`**: Command routing, action handlers (astrogation/combat/ordnance), phase derivation, transport abstraction, connection management, input interpretation, view-model helpers, and presentation logic.
148+
- **`game/`**: Command routing, action handlers (astrogation/combat/ordnance), phase derivation, 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.
148149
- **`renderer/`**: Canvas drawing layers (scene, entities, vectors, effects, overlays), camera, minimap, and animation management.
149150
- **`ui/`**: Screen visibility, HUD view building, button bindings, game log, fleet building, ship list, formatters, and layout metrics.
150151
- **`ui/ui.ts`** / **`audio.ts`**: Handles the HTML overlay (menus, HUD) and Web Audio API interactions.

docs/CODING_STANDARDS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ Client game modules use two patterns depending on purity (see [dependency inject
245245

246246
When adding new side-effecting logic, prefer extending an existing `*Deps` interface over adding methods to `GameClient`. Keep pure derivation functions as direct-parameter exports — they don't need deps.
247247

248+
When the client needs to decide whether an action is legal or should be shown/enabled, prefer reusing shared rule helpers from `src/shared/engine/` over duplicating lighter-weight UI heuristics. The ordnance HUD and ordnance-phase auto-selection follow this pattern: the client derives button visibility/disabled state and default selection from the same validation helpers the engine uses.
249+
248250
### Transport adapter
249251

250252
Network vs. local game branching is handled by `GameTransport` (`src/client/game/transport.ts`), not by `if (isLocalGame)` checks in action handlers:

src/client/game/helpers.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,68 @@ describe('game client helpers', () => {
246246
canOverload: true,
247247
canEmplaceBase: true,
248248
fleetStatus: '⚔ 1v0 1M/1N',
249+
launchMineState: {
250+
visible: true,
251+
disabled: false,
252+
title: '',
253+
},
254+
launchTorpedoState: {
255+
visible: true,
256+
disabled: false,
257+
title: '',
258+
},
259+
launchNukeState: {
260+
visible: true,
261+
disabled: false,
262+
title: '',
263+
},
264+
});
265+
});
266+
267+
it('hides disallowed ordnance buttons and disables illegal launches', () => {
268+
const state = createState({
269+
phase: 'ordnance',
270+
scenarioRules: {
271+
allowedOrdnanceTypes: ['nuke'],
272+
},
273+
ships: [
274+
createShip({
275+
id: 'p0s0',
276+
type: 'corsair',
277+
cargoUsed: 0,
278+
}),
279+
createShip({
280+
id: 'p1s0',
281+
type: 'corsair',
282+
owner: 1,
283+
destroyed: true,
284+
}),
285+
],
286+
});
287+
288+
const planning = {
289+
selectedShipId: 'p0s0',
290+
burns: new Map(),
291+
overloads: new Map(),
292+
weakGravityChoices: new Map(),
293+
};
294+
295+
expect(deriveHudViewModel(state, 0, planning)).toMatchObject({
296+
launchMineState: {
297+
visible: false,
298+
disabled: true,
299+
title: '',
300+
},
301+
launchTorpedoState: {
302+
visible: false,
303+
disabled: true,
304+
title: '',
305+
},
306+
launchNukeState: {
307+
visible: true,
308+
disabled: true,
309+
title: 'Not enough cargo (need 20, have 10)',
310+
},
249311
});
250312
});
251313

src/client/game/helpers.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { SHIP_STATS } from '../../shared/constants';
2+
import {
3+
getAllowedOrdnanceTypes,
4+
validateOrdnanceLaunch,
5+
} from '../../shared/engine/util';
26
import { hexVecLength } from '../../shared/hex';
3-
import type { AstrogationOrder, GameState, Ship } from '../../shared/types';
7+
import type {
8+
AstrogationOrder,
9+
GameState,
10+
Ordnance,
11+
Ship,
12+
} from '../../shared/types';
413
import { count } from '../../shared/util';
514
import type { PlanningState } from './planning';
615

@@ -34,6 +43,15 @@ export interface HudViewModel {
3443
multipleShipsAlive: boolean;
3544
speed: number;
3645
fuelToStop: number;
46+
launchMineState: OrdnanceActionState;
47+
launchTorpedoState: OrdnanceActionState;
48+
launchNukeState: OrdnanceActionState;
49+
}
50+
51+
export interface OrdnanceActionState {
52+
visible: boolean;
53+
disabled: boolean;
54+
title: string;
3755
}
3856

3957
type PlanningSnapshot = Pick<
@@ -182,6 +200,38 @@ export const deriveHudViewModel = (
182200
);
183201

184202
const stats = selectedShip ? SHIP_STATS[selectedShip.type] : null;
203+
const allowedOrdnanceTypes = getAllowedOrdnanceTypes(state);
204+
205+
const getOrdnanceActionState = (
206+
ordnanceType: Ordnance['type'],
207+
): OrdnanceActionState => {
208+
if (!allowedOrdnanceTypes.has(ordnanceType)) {
209+
return {
210+
visible: false,
211+
disabled: true,
212+
title: '',
213+
};
214+
}
215+
216+
if (!selectedShip) {
217+
return {
218+
visible: true,
219+
disabled: true,
220+
title: '',
221+
};
222+
}
223+
224+
const error = validateOrdnanceLaunch(state, selectedShip, ordnanceType);
225+
226+
return {
227+
visible: true,
228+
disabled: error !== null,
229+
title:
230+
error === 'Only warships and orbital bases can launch torpedoes'
231+
? 'Warships only'
232+
: (error ?? ''),
233+
};
234+
};
185235

186236
return {
187237
turn: state.turnNumber,
@@ -212,6 +262,9 @@ export const deriveHudViewModel = (
212262
multipleShipsAlive: myShips.filter((s) => !s.destroyed).length > 1,
213263
speed: selectedShip ? hexVecLength(selectedShip.velocity) : 0,
214264
fuelToStop: selectedShip ? hexVecLength(selectedShip.velocity) : 0,
265+
launchMineState: getOrdnanceActionState('mine'),
266+
launchTorpedoState: getOrdnanceActionState('torpedo'),
267+
launchNukeState: getOrdnanceActionState('nuke'),
215268
};
216269
};
217270

src/client/game/ordnance.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
33
import type { GameState, Ship } from '../../shared/types';
44
import {
55
getFirstLaunchableShipId,
6+
getUnambiguousLaunchableShipId,
67
resolveBaseEmplacementPlan,
78
resolveOrdnanceLaunchPlan,
89
} from './ordnance';
@@ -26,8 +27,11 @@ function createShip(overrides: Partial<Ship> = {}): Ship {
2627
};
2728
}
2829

29-
function createState(ships: Ship[]): Pick<GameState, 'ships'> {
30-
return { ships };
30+
function createState(
31+
ships: Ship[],
32+
scenarioRules: GameState['scenarioRules'] = {},
33+
): Pick<GameState, 'ships' | 'scenarioRules'> {
34+
return { ships, scenarioRules };
3135
}
3236

3337
function createPlanning(
@@ -60,6 +64,30 @@ describe('game-client-ordnance', () => {
6064
expect(getFirstLaunchableShipId(state, 1)).toBe('enemy');
6165
});
6266

67+
it('skips ships that cannot launch any allowed ordnance this turn', () => {
68+
const state = createState(
69+
[
70+
createShip({
71+
id: 'corsair',
72+
type: 'corsair',
73+
}),
74+
createShip({
75+
id: 'packet',
76+
type: 'packet',
77+
}),
78+
createShip({
79+
id: 'resupplied',
80+
type: 'packet',
81+
resuppliedThisTurn: true,
82+
}),
83+
],
84+
{ allowedOrdnanceTypes: ['nuke'] },
85+
);
86+
87+
expect(getFirstLaunchableShipId(state, 0)).toBe('packet');
88+
expect(getUnambiguousLaunchableShipId(state, 0)).toBe('packet');
89+
});
90+
6391
it('builds a launch plan for torpedoes', () => {
6492
const state = createState([createShip({ type: 'frigate' })]);
6593
const planning = createPlanning({
@@ -106,6 +134,38 @@ describe('game-client-ordnance', () => {
106134
level: 'error',
107135
});
108136

137+
expect(
138+
resolveOrdnanceLaunchPlan(
139+
createState([createShip({ type: 'packet' })], {
140+
allowedOrdnanceTypes: ['nuke'],
141+
}),
142+
createPlanning(),
143+
'mine',
144+
),
145+
).toEqual({
146+
ok: false,
147+
message: 'This scenario does not allow mine launches',
148+
level: 'error',
149+
});
150+
151+
expect(
152+
resolveOrdnanceLaunchPlan(
153+
createState([
154+
createShip({
155+
type: 'packet',
156+
resuppliedThisTurn: true,
157+
}),
158+
]),
159+
createPlanning(),
160+
'nuke',
161+
),
162+
).toEqual({
163+
ok: false,
164+
message:
165+
'Ships cannot launch ordnance during a turn in which they resupply',
166+
level: 'error',
167+
});
168+
109169
expect(
110170
resolveOrdnanceLaunchPlan(
111171
createState([

src/client/game/ordnance.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { SHIP_STATS } from '../../shared/constants';
22
import {
3-
canLaunchOrdnance,
4-
validateShipOrdnanceLaunch,
3+
getAllowedOrdnanceTypes,
4+
hasLaunchableOrdnanceCapacity,
5+
validateOrdnanceLaunch,
56
} from '../../shared/engine/util';
67
import type {
78
GameState,
@@ -11,7 +12,7 @@ import type {
1112
} from '../../shared/types';
1213
import type { PlanningState } from './planning';
1314

14-
type OrdnanceState = Pick<GameState, 'ships'>;
15+
type OrdnanceState = Pick<GameState, 'ships' | 'scenarioRules'>;
1516

1617
type OrdnancePlanning = Pick<
1718
PlanningState,
@@ -58,9 +59,14 @@ export const getFirstLaunchableShipId = (
5859
state: OrdnanceState,
5960
playerId: number,
6061
): string | null => {
62+
const allowedTypes = getAllowedOrdnanceTypes(state);
63+
6164
return (
6265
state.ships.find(
63-
(ship) => ship.owner === playerId && canLaunchOrdnance(ship),
66+
(ship) =>
67+
ship.owner === playerId &&
68+
!ship.resuppliedThisTurn &&
69+
hasLaunchableOrdnanceCapacity(ship, allowedTypes),
6470
)?.id ?? null
6571
);
6672
};
@@ -69,8 +75,12 @@ export const getUnambiguousLaunchableShipId = (
6975
state: OrdnanceState,
7076
playerId: number,
7177
): string | null => {
78+
const allowedTypes = getAllowedOrdnanceTypes(state);
7279
const launchable = state.ships.filter(
73-
(ship) => ship.owner === playerId && canLaunchOrdnance(ship),
80+
(ship) =>
81+
ship.owner === playerId &&
82+
!ship.resuppliedThisTurn &&
83+
hasLaunchableOrdnanceCapacity(ship, allowedTypes),
7484
);
7585

7686
return launchable.length === 1 ? launchable[0].id : null;
@@ -95,7 +105,7 @@ export const resolveOrdnanceLaunchPlan = (
95105
return { ok: false, message: null, level: 'error' };
96106
}
97107

98-
const error = validateShipOrdnanceLaunch(ship, ordnanceType);
108+
const error = validateOrdnanceLaunch(state, ship, ordnanceType);
99109

100110
if (error) {
101111
return { ok: false, message: error, level: 'error' };

src/client/game/phase-entry.test.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ function createShip(overrides: Partial<Ship> = {}): Ship {
2121
};
2222
}
2323

24-
function createState(ships: Ship[]): GameState {
24+
function createState(
25+
ships: Ship[],
26+
overrides: Partial<GameState> = {},
27+
): GameState {
2528
return {
2629
gameId: 'LOCAL',
2730
scenario: 'Bi-Planetary',
@@ -56,6 +59,7 @@ function createState(ships: Ship[]): GameState {
5659
],
5760
winner: null,
5861
winReason: null,
62+
...overrides,
5963
};
6064
}
6165

@@ -81,17 +85,41 @@ describe('game-client-phase-entry', () => {
8185
it('derives ordnance selection from the first launchable ship', () => {
8286
const plan = deriveClientStateEntryPlan(
8387
'playing_ordnance',
84-
createState([
85-
createShip({ id: 'empty', cargoUsed: 50 }),
86-
createShip({ id: 'launchable' }),
87-
]),
88+
createState(
89+
[
90+
createShip({
91+
id: 'restricted',
92+
type: 'corsair',
93+
}),
94+
createShip({ id: 'launchable', type: 'packet' }),
95+
],
96+
{ scenarioRules: { allowedOrdnanceTypes: ['nuke'] } },
97+
),
8898
0,
8999
);
90100

91101
expect(plan.selectedShipId).toBe('launchable');
92102
expect(plan.tutorialPhase).toBe('ordnance');
93103
});
94104

105+
it('returns null ordnance selection when no ship can launch the allowed types', () => {
106+
const plan = deriveClientStateEntryPlan(
107+
'playing_ordnance',
108+
createState(
109+
[
110+
createShip({
111+
id: 'restricted',
112+
type: 'corsair',
113+
}),
114+
],
115+
{ scenarioRules: { allowedOrdnanceTypes: ['nuke'] } },
116+
),
117+
0,
118+
);
119+
120+
expect(plan.selectedShipId).toBeNull();
121+
});
122+
95123
it('returns null selectedShipId when multiple alive ships exist', () => {
96124
const plan = deriveClientStateEntryPlan(
97125
'playing_astrogation',

0 commit comments

Comments
 (0)