Skip to content

Commit d04da6b

Browse files
committed
Adds boilers
1 parent d34e903 commit d04da6b

19 files changed

Lines changed: 520 additions & 94 deletions

File tree

docs/PIPES.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Developer spec for the **fluid logistics** slice: ground water, boilers, mana springs, steam turrets, and typed pipe networks. Complements [`INFRASTRUCTURE.md`](INFRASTRUCTURE.md) (layers, soldiers, stairs).
44

5-
**Status:** Planned — not yet implemented (pipe infra exists as untyped placeholder).
5+
**Status:** P0–P3 in progress (water/steam preview, merge reject, boilers). Steam turrets / mana springs / magic turret mana are P4+.
66

77
---
88

@@ -27,7 +27,7 @@ flowchart TB
2727
subgraph mana [Mana economy]
2828
Pool[Shared mana pool max 20]
2929
MS -->|water + spring| Pool
30-
Boiler[Boiler 2x1] -->|mana/sec| Pool
30+
Boiler[Boiler 1x2] -->|mana/sec| Pool
3131
MT[Magic turret 1 mana/shot]
3232
Pool --> MT
3333
end
@@ -108,7 +108,7 @@ If placing a pipe would **4-connect** two components whose resolved fluids diffe
108108

109109
| Property | Value |
110110
|----------|--------|
111-
| Size | **2×1** |
111+
| Size | **1×2** |
112112
| Water | Adjacent cell connected to **ground-water** network |
113113
| Steam | Adjacent cell connected to **steam-turret** network |
114114
| Mana | **~0.25/sec** while producing (tunable); **stops** at 0 mana |
@@ -258,7 +258,7 @@ interface SteamTurretState {
258258
| **P0** | This doc + `Player.mana` + constants stubs |
259259
| **P1** | `InfraCell.fluid`, seed flood-fill, preview colors, merge reject |
260260
| **P2** | Ground-water detection; connectivity warnings |
261-
| **P3** | Boiler 2×1 + `boilerExpansion` + mana drain + steam availability |
261+
| **P3** | Boiler 1×2 + `boilerExpansion` + mana drain + steam availability |
262262
| **P4** | Steam turret + charge + side blast + exterior targeting |
263263
| **P5** | Mana spring 2×2 + water gate + inspect warning |
264264
| **P6** | Magic turret 1 mana/shot |

src/config/constants.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,17 @@ export const WIZARD_DEFAULTS = {
5353
attackCooldown: 0.6,
5454
} as const;
5555

56-
export const MAX_MANA = 10;
56+
/** Shared mana pool cap (spells, boilers, future springs/turrets). */
57+
export const MAX_MANA = 20;
58+
59+
// Pipes / boilers / steam (PIPES.md) — stubs until later phases consume them.
60+
export const BOILER_MANA_PER_SEC = 0.25;
61+
export const MANA_SPRING_PER_SEC = 0.5;
62+
export const STEAM_TURRET_CHARGE_SEC = 3;
63+
export const STEAM_TURRET_DAMAGE = 10;
64+
export const MAGIC_TURRET_MANA_COST = 1;
65+
/** Throughput units by boilerExpansion level (0 = base, 1–2 = upgrades). */
66+
export const BOILER_THROUGHPUT = [3, 4, 5] as const;
5767

5868
export const symbols = {
5969
wizard: '@',
@@ -88,6 +98,7 @@ export const colors = {
8898
infraPipe: '#4299e1',
8999
/** Pipe not yet connected to a water (or steam) seed. */
90100
infraPipeDry: '#718096',
101+
infraPipeSteam: '#ed8936',
91102
soldier: '#68d391',
92103
connectivityWarn: '#ed8936',
93104
} as const;

src/model/blueprints.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ export const BLUEPRINTS: Blueprint[] = [
7575
passable: true,
7676
description: 'Station soldiers here during attack. Allocate headcount from barracks in build phase.',
7777
},
78+
{
79+
id: 'boilerRoom',
80+
name: 'Boiler',
81+
glyph: 'H',
82+
color: '#c05621',
83+
size: { w: 1, h: 2 },
84+
cost: 16,
85+
baseHp: 22,
86+
category: 'structure',
87+
passable: false,
88+
description:
89+
'1×2 steam plant. Needs a ground-water pipe in and a steam pipe out. Drains mana while producing steam.',
90+
},
7891
];
7992

8093
export const STARTING_BLUEPRINT_IDS = BLUEPRINTS.map((b) => b.id);

src/model/boilers.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { BOILER_MANA_PER_SEC } from '@/config/constants';
3+
import { getBlueprint } from './blueprints';
4+
import { placeInfra } from './infra';
5+
import { resetBoilerRuntime, tickBoilers } from './boilers';
6+
import { selectPipeConnectivityReport } from './pipes';
7+
import { createInitialState } from './game';
8+
import { createRoom, createTower, placeRoom } from './tower';
9+
10+
function boilerWithPorts() {
11+
const state = createInitialState('boiler0');
12+
state.tower = createTower();
13+
// Water column at col 4; boiler 1×2 at (5,0) covering (5,0)–(5,1); steam outlet at (6,1).
14+
state.tower = placeRoom(state.tower, createRoom('g4', getBlueprint('stem')!, { col: 4, row: 0 }));
15+
state.tower = placeRoom(state.tower, createRoom('w1', getBlueprint('stem')!, { col: 4, row: 1 }));
16+
state.tower = placeRoom(state.tower, createRoom('boiler', getBlueprint('boilerRoom')!, { col: 5, row: 0 }));
17+
state.tower = placeRoom(state.tower, createRoom('s0', getBlueprint('stem')!, { col: 6, row: 0 }));
18+
state.tower = placeRoom(state.tower, createRoom('s1', getBlueprint('stem')!, { col: 6, row: 1 }));
19+
state.tower = placeInfra(state.tower, { col: 4, row: 0 }, 'pipe');
20+
state.tower = placeInfra(state.tower, { col: 4, row: 1 }, 'pipe');
21+
state.tower = placeInfra(state.tower, { col: 6, row: 1 }, 'pipe');
22+
state.phase = 'attack';
23+
resetBoilerRuntime(state);
24+
return state;
25+
}
26+
27+
describe('tickBoilers', () => {
28+
it('drains mana and marks steam available when both ports are connected', () => {
29+
const state = boilerWithPorts();
30+
const before = state.player.mana;
31+
tickBoilers(state, 1);
32+
expect(state.player.mana).toBeCloseTo(before - BOILER_MANA_PER_SEC, 5);
33+
expect(state.boilerRuntime.boiler?.producing).toBe(true);
34+
expect(state.boilerRuntime.boiler?.steamAvailable).toBe(true);
35+
});
36+
37+
it('stops producing when mana is empty', () => {
38+
const state = boilerWithPorts();
39+
state.player.mana = 0;
40+
tickBoilers(state, 1);
41+
expect(state.boilerRuntime.boiler?.producing).toBe(false);
42+
expect(state.boilerRuntime.boiler?.steamAvailable).toBe(false);
43+
});
44+
45+
it('does not produce without a steam outlet', () => {
46+
const state = createInitialState('boiler-dry');
47+
state.tower = createTower();
48+
state.tower = placeRoom(state.tower, createRoom('g4', getBlueprint('stem')!, { col: 4, row: 0 }));
49+
state.tower = placeRoom(state.tower, createRoom('w1', getBlueprint('stem')!, { col: 4, row: 1 }));
50+
state.tower = placeRoom(state.tower, createRoom('boiler', getBlueprint('boilerRoom')!, { col: 5, row: 0 }));
51+
state.tower = placeInfra(state.tower, { col: 4, row: 0 }, 'pipe');
52+
state.tower = placeInfra(state.tower, { col: 4, row: 1 }, 'pipe');
53+
state.phase = 'attack';
54+
resetBoilerRuntime(state);
55+
const before = state.player.mana;
56+
tickBoilers(state, 1);
57+
expect(state.player.mana).toBe(before);
58+
expect(state.boilerRuntime.boiler?.producing).toBe(false);
59+
});
60+
});
61+
62+
describe('selectPipeConnectivityReport', () => {
63+
it('warns when a boiler lacks water', () => {
64+
const state = createInitialState('pipe-warn');
65+
state.tower = createTower();
66+
state.tower = placeRoom(state.tower, createRoom('g5', getBlueprint('stem')!, { col: 4, row: 0 }));
67+
state.tower = placeRoom(state.tower, createRoom('boiler', getBlueprint('boilerRoom')!, { col: 5, row: 0 }));
68+
const report = selectPipeConnectivityReport(state);
69+
expect(report.boilers.some((b) => b.roomId === 'boiler' && b.warning.includes('water'))).toBe(true);
70+
});
71+
});

src/model/boilers.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { BOILER_MANA_PER_SEC, BOILER_THROUGHPUT } from '@/config/constants';
2+
import { boilerHasSteamPort, boilerHasWaterPort, isBoilerRoom } from './pipes';
3+
import type { GameState, Room } from './types';
4+
5+
export function boilerThroughput(room: Room): number {
6+
const expansion = room.modifications.find((m) => m.id === 'boilerExpansion');
7+
const level = expansion?.level ?? 0;
8+
return BOILER_THROUGHPUT[Math.min(level, BOILER_THROUGHPUT.length - 1)] ?? BOILER_THROUGHPUT[0];
9+
}
10+
11+
export function resetBoilerRuntime(state: GameState): void {
12+
state.boilerRuntime = {};
13+
for (const room of state.tower.rooms) {
14+
if (!isBoilerRoom(room)) continue;
15+
state.boilerRuntime[room.id] = { producing: false, steamAvailable: false };
16+
}
17+
}
18+
19+
/** Drain mana and mark steam availability while water-connected and mana remains. */
20+
export function tickBoilers(state: GameState, dt: number): void {
21+
for (const room of state.tower.rooms) {
22+
if (!isBoilerRoom(room)) continue;
23+
24+
const waterOk = boilerHasWaterPort(state.tower, room.origin, room.size);
25+
const steamOk = boilerHasSteamPort(state.tower, room.origin, room.size);
26+
const producing = waterOk && steamOk && state.player.mana > 0;
27+
28+
if (producing) {
29+
state.player.mana = Math.max(0, state.player.mana - BOILER_MANA_PER_SEC * dt);
30+
}
31+
32+
const stillProducing = producing && state.player.mana > 0;
33+
state.boilerRuntime[room.id] = {
34+
producing: stillProducing,
35+
steamAvailable: stillProducing,
36+
};
37+
}
38+
}

src/model/game.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@/config/constants';
88
import { sameMacroCell, macroCellOfNode } from '@/calculations/subGrid';
99
import { resetSoldierCounter, stepSoldiers } from './soldiers';
10+
import { tickBoilers } from './boilers';
1011
import { STARTING_BLUEPRINT_IDS } from './blueprints';
1112
import { computeDamage, type Combatant } from '../calculations/combat';
1213
import { spawnNode } from '../calculations/exteriorGraph';
@@ -79,6 +80,7 @@ export function createInitialState(seed: string | number = 'wizard'): GameState
7980
blizzardZones: [],
8081
tornadoEnterDone: {},
8182
activeSpellSchool: 'fire',
83+
boilerRuntime: {},
8284
buildBaseline: null,
8385
};
8486
resetSoldierCounter();
@@ -273,6 +275,9 @@ export function step(state: GameState, dt: number): void {
273275
// Room behaviors (turret rooms, slot volleys) and modifications (spikes) act on enemies this tick.
274276
runRoomEffects(state, dt);
275277

278+
// Boilers drain mana and mark steam availability.
279+
tickBoilers(state, dt);
280+
276281
// Reap dead enemies and award currency.
277282
const survivors: Enemy[] = [];
278283
for (const enemy of state.enemies) {

src/model/infraPlacement.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getBlueprint } from './blueprints';
22
import { canPlaceInfra, getInfraAt, placeInfra } from './infra';
3+
import { isBoilerFootprintCell, wouldMixFluids } from './pipes';
34
import { canPlace, createRoom, isOccupied, placeRoom } from './tower';
45
import type { Blueprint, Cell, PlacementReason, Tower } from './types';
56

@@ -35,6 +36,14 @@ export function planInfraPlacement(
3536
return { ok: false, reason: base.reason, needsStem: false, isToggleOff: false };
3637
}
3738

39+
if (blueprint.infraKind === 'pipe' && isBoilerFootprintCell(tower, cell.col, cell.row)) {
40+
return { ok: false, reason: 'boiler_footprint', needsStem: false, isToggleOff: false };
41+
}
42+
43+
if (blueprint.infraKind === 'pipe' && wouldMixFluids(tower, cell)) {
44+
return { ok: false, reason: 'fluid_mix', needsStem: false, isToggleOff: false };
45+
}
46+
3847
if (isOccupied(tower, cell.col, cell.row)) {
3948
return { ok: true, reason: 'ok', needsStem: false, isToggleOff: false };
4049
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { BOILER_THROUGHPUT } from '@/config/constants';
2+
import { isBoilerRoom } from '@/model/pipes';
3+
import type { ModificationDef } from './types';
4+
5+
export const boilerExpansion: ModificationDef = {
6+
id: 'boilerExpansion',
7+
name: 'Boiler Expansion',
8+
glyph: '+',
9+
color: '#c05621',
10+
description: 'Increases steam throughput for connected turrets.',
11+
mechanicsAtLevel: (level) => `Throughput ${BOILER_THROUGHPUT[level] ?? BOILER_THROUGHPUT[0]} units`,
12+
maxLevel: 2,
13+
cost: (level) => (level === 1 ? 14 : level === 2 ? 18 : 0),
14+
canApply: (room) => isBoilerRoom(room),
15+
};

src/model/modifications/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Room, RoomStats, Tower } from '../types';
22
import type { ModificationDef } from './types';
33
import { barracksExpansion } from './barracksExpansion';
4+
import { boilerExpansion } from './boilerExpansion';
45
import { slotExpansion } from './slotExpansion';
56
import { spikes } from './spikes';
67

@@ -9,7 +10,12 @@ export type { ModificationDef, ModEffectContext } from './types';
910
const DEFAULT_REFUND_RATE = 0.5;
1011

1112
/** Every modification the game knows about. Add new types here. */
12-
export const MODIFICATIONS: ModificationDef[] = [spikes, barracksExpansion, slotExpansion];
13+
export const MODIFICATIONS: ModificationDef[] = [
14+
spikes,
15+
barracksExpansion,
16+
slotExpansion,
17+
boilerExpansion,
18+
];
1319

1420
export function getModification(id: string): ModificationDef | undefined {
1521
return MODIFICATIONS.find((m) => m.id === id);

src/model/phases.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { prepareWaveNames } from './game';
22
import { addMessage } from './messages';
3+
import { resetBoilerRuntime } from './boilers';
4+
import { lockPipeFluids } from './pipes';
35
import { clearSoldiersAfterWave, deploySoldiersForWave } from './soldiers';
46
import { reward } from '../calculations/economy';
57
import { runWaveClearedEffects } from './modifications/effects';
@@ -32,6 +34,8 @@ export function beginWave(state: GameState): void {
3234
state.spawnTimer = 0;
3335
state.waveTimer = 0;
3436
state.roomEffectTimers = {};
37+
state.tower = lockPipeFluids(state.tower);
38+
resetBoilerRuntime(state);
3539
deploySoldiersForWave(state);
3640
refillMana(state);
3741
resetSpellCooldowns(state);
@@ -55,6 +59,7 @@ export function endWave(state: GameState): void {
5559
state.waveIndex += 1;
5660
state.phase = 'build';
5761
clearSoldiersAfterWave(state);
62+
state.boilerRuntime = {};
5863
captureBuildBaseline(state);
5964
addMessage(state, `Reinforce the tower for wave ${state.levelIndex + 1}.`, 'info');
6065
}

0 commit comments

Comments
 (0)