Skip to content

Commit c16c5b9

Browse files
committed
Extract client burn and landing helpers
1 parent cfe4b7f commit c16c5b9

File tree

5 files changed

+347
-30
lines changed

5 files changed

+347
-30
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { GameState, PlayerState, Ship } from '../shared/types';
4+
import { deriveBurnChangePlan } from './game-client-burn';
5+
6+
function createShip(overrides: Partial<Ship> = {}): Ship {
7+
return {
8+
id: 'ship-0',
9+
type: 'packet',
10+
owner: 0,
11+
position: { q: 0, r: 0 },
12+
velocity: { dq: 0, dr: 0 },
13+
fuel: 5,
14+
cargoUsed: 0,
15+
nukesLaunchedSinceResupply: 0,
16+
resuppliedThisTurn: false,
17+
landed: false,
18+
destroyed: false,
19+
detected: true,
20+
damage: { disabledTurns: 0 },
21+
...overrides,
22+
};
23+
}
24+
25+
function createPlayers(): [PlayerState, PlayerState] {
26+
return [
27+
{ connected: true, ready: true, targetBody: 'Mars', homeBody: 'Terra', bases: [], escapeWins: false },
28+
{ connected: true, ready: true, targetBody: 'Terra', homeBody: 'Mars', bases: [], escapeWins: false },
29+
];
30+
}
31+
32+
function createState(ship: Ship): GameState {
33+
return {
34+
gameId: 'BURN',
35+
scenario: 'test',
36+
scenarioRules: {},
37+
escapeMoralVictoryAchieved: false,
38+
turnNumber: 1,
39+
phase: 'astrogation',
40+
activePlayer: 0,
41+
ships: [ship],
42+
ordnance: [],
43+
pendingAstrogationOrders: null,
44+
pendingAsteroidHazards: [],
45+
destroyedAsteroids: [],
46+
destroyedBases: [],
47+
players: createPlayers(),
48+
winner: null,
49+
winReason: null,
50+
};
51+
}
52+
53+
describe('game-client-burn', () => {
54+
it('requires a selected ship before changing burns', () => {
55+
expect(deriveBurnChangePlan(createState(createShip()), null, 2, null)).toEqual({
56+
kind: 'error',
57+
message: 'Select a ship first',
58+
level: 'info',
59+
});
60+
});
61+
62+
it('ignores missing or destroyed ships', () => {
63+
expect(deriveBurnChangePlan(createState(createShip()), 'missing', 2, null)).toEqual({
64+
kind: 'noop',
65+
});
66+
expect(deriveBurnChangePlan(createState(createShip({ destroyed: true })), 'ship-0', 2, null)).toEqual({
67+
kind: 'noop',
68+
});
69+
});
70+
71+
it('rejects disabled and fuel-starved ships', () => {
72+
expect(deriveBurnChangePlan(createState(createShip({
73+
damage: { disabledTurns: 2 },
74+
})), 'ship-0', 2, null)).toEqual({
75+
kind: 'error',
76+
message: 'Ship disabled for 2 more turn(s)',
77+
level: 'error',
78+
});
79+
80+
expect(deriveBurnChangePlan(createState(createShip({ fuel: 0 })), 'ship-0', 2, null)).toEqual({
81+
kind: 'error',
82+
message: 'No fuel remaining',
83+
level: 'error',
84+
});
85+
});
86+
87+
it('toggles burns and clears overloads when choosing a new direction', () => {
88+
expect(deriveBurnChangePlan(createState(createShip()), 'ship-0', 2, null)).toEqual({
89+
kind: 'update',
90+
shipId: 'ship-0',
91+
nextBurn: 2,
92+
clearOverload: true,
93+
});
94+
95+
expect(deriveBurnChangePlan(createState(createShip()), 'ship-0', 2, 2)).toEqual({
96+
kind: 'update',
97+
shipId: 'ship-0',
98+
nextBurn: null,
99+
clearOverload: false,
100+
});
101+
});
102+
});

src/client/game-client-burn.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { GameState } from '../shared/types';
2+
3+
export type BurnChangePlan =
4+
| { kind: 'noop' }
5+
| { kind: 'error'; message: string; level: 'info' | 'error' }
6+
| {
7+
kind: 'update';
8+
shipId: string;
9+
nextBurn: number | null;
10+
clearOverload: boolean;
11+
};
12+
13+
export function deriveBurnChangePlan(
14+
state: GameState | null,
15+
selectedShipId: string | null,
16+
direction: number,
17+
currentBurn: number | null,
18+
): BurnChangePlan {
19+
if (!state) {
20+
return { kind: 'noop' };
21+
}
22+
if (!selectedShipId) {
23+
return {
24+
kind: 'error',
25+
message: 'Select a ship first',
26+
level: 'info',
27+
};
28+
}
29+
30+
const ship = state.ships.find((candidate) => candidate.id === selectedShipId);
31+
if (!ship || ship.destroyed) {
32+
return { kind: 'noop' };
33+
}
34+
if (ship.damage.disabledTurns > 0) {
35+
return {
36+
kind: 'error',
37+
message: `Ship disabled for ${ship.damage.disabledTurns} more turn(s)`,
38+
level: 'error',
39+
};
40+
}
41+
if (ship.fuel <= 0) {
42+
return {
43+
kind: 'error',
44+
message: 'No fuel remaining',
45+
level: 'error',
46+
};
47+
}
48+
49+
return {
50+
kind: 'update',
51+
shipId: selectedShipId,
52+
nextBurn: currentBurn === direction ? null : direction,
53+
clearOverload: currentBurn !== direction,
54+
};
55+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { GameState, PlayerState, Ship, ShipMovement } from '../shared/types';
4+
import { deriveLandingLogEntries } from './game-client-landings';
5+
6+
function createShip(overrides: Partial<Ship> = {}): Ship {
7+
return {
8+
id: 'ship-0',
9+
type: 'packet',
10+
owner: 0,
11+
position: { q: 0, r: 0 },
12+
velocity: { dq: 0, dr: 0 },
13+
fuel: 5,
14+
cargoUsed: 0,
15+
nukesLaunchedSinceResupply: 0,
16+
resuppliedThisTurn: false,
17+
landed: false,
18+
destroyed: false,
19+
detected: true,
20+
damage: { disabledTurns: 0 },
21+
...overrides,
22+
};
23+
}
24+
25+
function createPlayers(): [PlayerState, PlayerState] {
26+
return [
27+
{ connected: true, ready: true, targetBody: 'Mars', homeBody: 'Terra', bases: ['1,0'], escapeWins: false },
28+
{ connected: true, ready: true, targetBody: 'Terra', homeBody: 'Mars', bases: [], escapeWins: false },
29+
];
30+
}
31+
32+
function createState(overrides: Partial<GameState> = {}): GameState {
33+
return {
34+
gameId: 'LAND',
35+
scenario: 'test',
36+
scenarioRules: {},
37+
escapeMoralVictoryAchieved: false,
38+
turnNumber: 1,
39+
phase: 'movement',
40+
activePlayer: 0,
41+
ships: [
42+
createShip(),
43+
createShip({ id: 'enemy', owner: 1, type: 'corsair' }),
44+
],
45+
ordnance: [],
46+
pendingAstrogationOrders: null,
47+
pendingAsteroidHazards: [],
48+
destroyedAsteroids: [],
49+
destroyedBases: [],
50+
players: createPlayers(),
51+
winner: null,
52+
winReason: null,
53+
...overrides,
54+
};
55+
}
56+
57+
describe('game-client-landings', () => {
58+
it('builds landing log entries and resupply text from completed landings', () => {
59+
const movements: ShipMovement[] = [
60+
{
61+
shipId: 'ship-0',
62+
from: { q: 0, r: 0 },
63+
to: { q: 1, r: 0 },
64+
path: [],
65+
newVelocity: { dq: 0, dr: 0 },
66+
fuelSpent: 1,
67+
gravityEffects: [],
68+
crashed: false,
69+
landedAt: 'Mars',
70+
},
71+
{
72+
shipId: 'enemy',
73+
from: { q: 2, r: 0 },
74+
to: { q: 2, r: 1 },
75+
path: [],
76+
newVelocity: { dq: 0, dr: 0 },
77+
fuelSpent: 1,
78+
gravityEffects: [],
79+
crashed: false,
80+
landedAt: 'Venus',
81+
},
82+
];
83+
84+
expect(deriveLandingLogEntries(createState(), movements)).toEqual([
85+
{
86+
destination: { q: 1, r: 0 },
87+
shipName: 'Packet',
88+
bodyName: 'Mars',
89+
resupplyText: ' Packet resupplied: fuel + cargo restored',
90+
},
91+
{
92+
destination: { q: 2, r: 1 },
93+
shipName: 'Corsair',
94+
bodyName: 'Venus',
95+
resupplyText: null,
96+
},
97+
]);
98+
});
99+
100+
it('ignores missing state, non-landings, and missing ships', () => {
101+
const movements: ShipMovement[] = [
102+
{
103+
shipId: 'missing',
104+
from: { q: 0, r: 0 },
105+
to: { q: 1, r: 0 },
106+
path: [],
107+
newVelocity: { dq: 0, dr: 0 },
108+
fuelSpent: 1,
109+
gravityEffects: [],
110+
crashed: false,
111+
landedAt: 'Mars',
112+
},
113+
{
114+
shipId: 'ship-0',
115+
from: { q: 0, r: 0 },
116+
to: { q: 1, r: 0 },
117+
path: [],
118+
newVelocity: { dq: 0, dr: 0 },
119+
fuelSpent: 1,
120+
gravityEffects: [],
121+
crashed: false,
122+
landedAt: null,
123+
},
124+
];
125+
126+
expect(deriveLandingLogEntries(null, movements)).toEqual([]);
127+
expect(deriveLandingLogEntries(createState(), movements)).toEqual([]);
128+
});
129+
});

src/client/game-client-landings.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { hexKey, type HexCoord } from '../shared/hex';
2+
import { SHIP_STATS } from '../shared/constants';
3+
import type { GameState, ShipMovement } from '../shared/types';
4+
5+
export interface LandingLogEntry {
6+
destination: HexCoord;
7+
shipName: string;
8+
bodyName: string;
9+
resupplyText: string | null;
10+
}
11+
12+
export function deriveLandingLogEntries(
13+
state: GameState | null,
14+
movements: ShipMovement[],
15+
): LandingLogEntry[] {
16+
if (!state) {
17+
return [];
18+
}
19+
20+
const entries: LandingLogEntry[] = [];
21+
for (const movement of movements) {
22+
if (!movement.landedAt) {
23+
continue;
24+
}
25+
const ship = state.ships.find((candidate) => candidate.id === movement.shipId);
26+
if (!ship) {
27+
continue;
28+
}
29+
const shipName = SHIP_STATS[ship.type]?.name ?? ship.type;
30+
const player = state.players[ship.owner];
31+
entries.push({
32+
destination: movement.to,
33+
shipName,
34+
bodyName: movement.landedAt,
35+
resupplyText: player && player.bases.includes(hexKey(movement.to))
36+
? ` ${shipName} resupplied: fuel + cargo restored`
37+
: null,
38+
});
39+
}
40+
return entries;
41+
}

0 commit comments

Comments
 (0)