Skip to content

Commit 59b428f

Browse files
committed
Centralize ordnance launch rules in shared helpers
Phase 4 of the maintenance plan. Extracts duplicated ordnance launch eligibility logic into two shared helpers in engine/util.ts: - validateShipOrdnanceLaunch: per-ship per-type error messages - canLaunchOrdnance: quick boolean for ship filtering Both client ordnance.ts and engine game-engine.ts now use these instead of encoding the same rules independently. Removes the duplicate canShipLaunchAnyOrdnance from client code. Fixes two client-side bugs: - Orbital bases at D1 damage were incorrectly blocked from launching - Torpedo error message now correctly mentions orbital bases
1 parent 5bc55f3 commit 59b428f

File tree

6 files changed

+258
-175
lines changed

6 files changed

+258
-175
lines changed

docs/BACKLOG.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,15 @@ Split `src/shared/types.ts` into `src/shared/types/` directory:
7070
ScenarioPlayer). Barrel `index.ts` preserves all existing
7171
import paths.
7272

73-
### Phase 4. Rules consolidation
73+
### ~~Phase 4. Rules consolidation~~ *(done)*
7474

75-
- Centralize scenario capability checks and ordnance launch
76-
legality so client helpers and engine validation do not
77-
encode the same rules in different places.
78-
- Continue preferring extracted pure helpers over larger
79-
architectural moves.
75+
Centralized ordnance launch rules into shared helpers in
76+
`engine/util.ts`: `validateShipOrdnanceLaunch` (per-ship
77+
per-type eligibility) and `canLaunchOrdnance` (quick boolean
78+
filter). Client `ordnance.ts` and engine `game-engine.ts`
79+
both use the shared helpers. Fixes: client now correctly
80+
allows orbital bases to launch at D1 damage and includes
81+
orbital base exception in torpedo rules.
8082

8183
### Phase 5. Stronger entity state models
8284

@@ -92,7 +94,7 @@ import paths.
9294
2. ~~`main.ts` coordinator extraction.~~
9395
3. ~~`ui.ts` view extraction.~~
9496
4. ~~Shared type/module split.~~
95-
5. Rules consolidation.
97+
5. ~~Rules consolidation.~~
9698
6. Optional stronger state-model refactors.
9799

98100
---

src/client/game/ordnance.test.ts

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
22

33
import type { GameState, Ship } from '../../shared/types';
44
import {
5-
canShipLaunchAnyOrdnance,
65
getFirstLaunchableShipId,
76
resolveBaseEmplacementPlan,
87
resolveOrdnanceLaunchPlan,
@@ -45,16 +44,6 @@ function createPlanning(
4544
}
4645

4746
describe('game-client-ordnance', () => {
48-
it('checks whether a ship can launch any ordnance', () => {
49-
expect(
50-
canShipLaunchAnyOrdnance(createShip({ type: 'packet', cargoUsed: 0 })),
51-
).toBe(true);
52-
53-
expect(
54-
canShipLaunchAnyOrdnance(createShip({ type: 'packet', cargoUsed: 50 })),
55-
).toBe(false);
56-
});
57-
5847
it('finds the first launchable ship for the active player', () => {
5948
const state = createState([
6049
createShip({ id: 'blocked', cargoUsed: 50 }),
@@ -141,7 +130,7 @@ describe('game-client-ordnance', () => {
141130
),
142131
).toEqual({
143132
ok: false,
144-
message: 'Only warships can launch torpedoes',
133+
message: 'Only warships and orbital bases can launch torpedoes',
145134
level: 'error',
146135
});
147136

src/client/game/ordnance.ts

Lines changed: 12 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { ORDNANCE_MASS, SHIP_STATS } from '../../shared/constants';
1+
import { SHIP_STATS } from '../../shared/constants';
2+
import {
3+
canLaunchOrdnance,
4+
validateShipOrdnanceLaunch,
5+
} from '../../shared/engine/util';
26
import type {
37
GameState,
48
OrbitalBaseEmplacement,
@@ -50,30 +54,13 @@ const getSelectedShip = (
5054
return state.ships.find((ship) => ship.id === selectedShipId) ?? null;
5155
};
5256

53-
export const canShipLaunchAnyOrdnance = (
54-
ship: Pick<Ship, 'type' | 'cargoUsed'>,
55-
): boolean => {
56-
const stats = SHIP_STATS[ship.type];
57-
58-
if (!stats) {
59-
return false;
60-
}
61-
62-
return stats.cargo - ship.cargoUsed >= ORDNANCE_MASS.mine;
63-
};
64-
6557
export const getFirstLaunchableShipId = (
6658
state: OrdnanceState,
6759
playerId: number,
6860
): string | null => {
6961
return (
7062
state.ships.find(
71-
(ship) =>
72-
ship.owner === playerId &&
73-
!ship.destroyed &&
74-
!ship.landed &&
75-
ship.damage.disabledTurns === 0 &&
76-
canShipLaunchAnyOrdnance(ship),
63+
(ship) => ship.owner === playerId && canLaunchOrdnance(ship),
7764
)?.id ?? null
7865
);
7966
};
@@ -83,12 +70,7 @@ export const getUnambiguousLaunchableShipId = (
8370
playerId: number,
8471
): string | null => {
8572
const launchable = state.ships.filter(
86-
(ship) =>
87-
ship.owner === playerId &&
88-
!ship.destroyed &&
89-
!ship.landed &&
90-
ship.damage.disabledTurns === 0 &&
91-
canShipLaunchAnyOrdnance(ship),
73+
(ship) => ship.owner === playerId && canLaunchOrdnance(ship),
9274
);
9375

9476
return launchable.length === 1 ? launchable[0].id : null;
@@ -99,8 +81,6 @@ export const resolveOrdnanceLaunchPlan = (
9981
planning: OrdnancePlanning,
10082
ordnanceType: 'mine' | 'torpedo' | 'nuke',
10183
): OrdnanceLaunchPlan => {
102-
const ship = getSelectedShip(state, planning.selectedShipId);
103-
10484
if (!planning.selectedShipId) {
10585
return {
10686
ok: false,
@@ -109,70 +89,16 @@ export const resolveOrdnanceLaunchPlan = (
10989
};
11090
}
11191

112-
if (!ship) {
113-
return { ok: false, message: null, level: 'error' };
114-
}
115-
116-
const stats = SHIP_STATS[ship.type];
92+
const ship = getSelectedShip(state, planning.selectedShipId);
11793

118-
if (!stats) {
94+
if (!ship) {
11995
return { ok: false, message: null, level: 'error' };
12096
}
12197

122-
const cargoFree = stats.cargo - ship.cargoUsed;
123-
124-
if (ship.destroyed) {
125-
return {
126-
ok: false,
127-
message: 'Ship is destroyed',
128-
level: 'error',
129-
};
130-
}
131-
132-
if (ship.landed) {
133-
return {
134-
ok: false,
135-
message: 'Cannot launch ordnance while landed',
136-
level: 'error',
137-
};
138-
}
139-
140-
if (ship.damage.disabledTurns > 0) {
141-
return {
142-
ok: false,
143-
message: 'Ship is disabled',
144-
level: 'error',
145-
};
146-
}
147-
148-
if (ordnanceType === 'torpedo' && !stats.canOverload) {
149-
return {
150-
ok: false,
151-
message: 'Only warships can launch torpedoes',
152-
level: 'error',
153-
};
154-
}
155-
156-
if (
157-
ordnanceType === 'nuke' &&
158-
!stats.canOverload &&
159-
(ship.nukesLaunchedSinceResupply ?? 0) >= 1
160-
) {
161-
return {
162-
ok: false,
163-
message: 'Non-warships may carry only one nuke between resupplies',
164-
level: 'error',
165-
};
166-
}
167-
168-
const neededCargo = ORDNANCE_MASS[ordnanceType] ?? 0;
98+
const error = validateShipOrdnanceLaunch(ship, ordnanceType);
16999

170-
if (cargoFree < neededCargo) {
171-
return {
172-
ok: false,
173-
message: `Not enough cargo (need ${neededCargo}, have ${cargoFree})`,
174-
level: 'error',
175-
};
100+
if (error) {
101+
return { ok: false, message: error, level: 'error' };
176102
}
177103

178104
return {

src/shared/engine/game-engine.ts

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
parseBaseKey,
2929
usesEscapeInspectionRules,
3030
validatePhaseAction,
31+
validateShipOrdnanceLaunch,
3132
} from './util';
3233
import {
3334
advanceTurn,
@@ -689,66 +690,25 @@ export const processOrdnance = (
689690
};
690691
}
691692
const ship = state.ships.find((s) => s.id === launch.shipId);
692-
if (!ship || ship.owner !== playerId || ship.destroyed || ship.landed) {
693+
if (!ship || ship.owner !== playerId) {
693694
return {
694695
error: 'Invalid ship for ordnance launch',
695696
};
696697
}
697-
if (ship.damage.disabledTurns > 0) {
698-
// Orbital bases may launch ordnance at D1
699-
// damage (rulebook p.6)
700-
if (ship.type !== 'orbitalBase' || ship.damage.disabledTurns > 1) {
701-
return {
702-
error: 'Disabled ships cannot launch ordnance',
703-
};
704-
}
705-
}
706-
if (ship.captured) {
707-
return {
708-
error: 'Captured ships cannot launch ordnance',
709-
};
710-
}
711698
if (ship.resuppliedThisTurn) {
712699
return {
713700
error:
714701
'Ships cannot launch ordnance during a turn in which they resupply',
715702
};
716703
}
717-
const mass = ORDNANCE_MASS[launch.ordnanceType];
718-
if (!mass) return { error: 'Invalid ordnance type' };
719704
if (!allowedOrdnanceTypes.has(launch.ordnanceType)) {
720705
return {
721706
error: `This scenario does not allow ${launch.ordnanceType} launches`,
722707
};
723708
}
724-
const stats = SHIP_STATS[ship.type];
725-
if (!stats) return { error: 'Unknown ship type' };
726-
if (ship.cargoUsed + mass > stats.cargo) {
727-
return { error: 'Insufficient cargo capacity' };
728-
}
729-
if (ship.type === 'orbitalBase' && launch.ordnanceType !== 'torpedo') {
730-
return {
731-
error: 'Orbital bases can only launch torpedoes',
732-
};
733-
}
734-
if (
735-
launch.ordnanceType === 'torpedo' &&
736-
!stats.canOverload &&
737-
ship.type !== 'orbitalBase'
738-
) {
739-
return {
740-
error: 'Only warships and orbital bases can launch torpedoes',
741-
};
742-
}
743-
if (
744-
launch.ordnanceType === 'nuke' &&
745-
!stats.canOverload &&
746-
(ship.nukesLaunchedSinceResupply ?? 0) >= 1
747-
) {
748-
return {
749-
error: 'Non-warships may carry only one nuke between resupplies',
750-
};
751-
}
709+
const shipError = validateShipOrdnanceLaunch(ship, launch.ordnanceType);
710+
if (shipError) return { error: shipError };
711+
const mass = ORDNANCE_MASS[launch.ordnanceType];
752712
if (launch.ordnanceType === 'torpedo' && launch.torpedoAccel != null) {
753713
if (launch.torpedoAccel < 0 || launch.torpedoAccel > 5) {
754714
return {

0 commit comments

Comments
 (0)