reverse_survivors: director powers — ambush, frenzy, rift

Ambush drops a unit beside the survivor, inside the ring its kiting AI
would otherwise keep clear. Frenzy drives the whole horde faster and harder
for six seconds. Rift pins every spawn to one edge for ten, so you can mass
a flank instead of scattering.

This reverses the README's old promise that placement is never yours to
choose. The interesting decision turned out to be what a placement is
worth, not being denied one — so placement is purchasable, never free: a
premium for Ambush, a cooldown and a commitment window for Rift.

A shared spendPower gate covers cooldown and affordability for all three,
so the next power costs no new branches. EDGES became a Record keyed by a
finite union rather than an array: under noUncheckedIndexedAccess an array
index is possibly-undefined and would force an unreachable fallback.

The unpinned spawn path still draws the same RNG values in the same order,
verified against the old implementation across 50 seeds — existing seeded
replays are unchanged.

145 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
This commit is contained in:
Krzysztof Rudnicki 2026-08-09 23:10:38 +02:00
parent 43b63ac266
commit 2d66bfdd61
13 changed files with 610 additions and 26 deletions

View File

@ -0,0 +1,17 @@
{
"title": "Reverse Survivors: director powers (ambush, frenzy, rift)",
"objective": "Give the director three powers beyond spawn/wave/boss, buying back some control over where enemies appear. Ambush places a unit next to the survivor at a premium, Frenzy drives the horde harder for six seconds, and Rift pins spawns to one edge for ten. Placement becomes purchasable rather than free.",
"acceptance_criteria": [
"`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% on all four metrics.",
"Existing seeded replays are unaffected: the unpinned edgeSpawnPos path draws the same RNG values in the same order as before.",
"Ambush placement is deterministic and draws no RNG.",
"Ambush lanes never repeat, including past a full turn.",
"Frenzy scales every damage channel - contact, projectile and blast - not just melee.",
"Each power is independently gated by cooldown and affordability, and rejects cleanly when either fails."
],
"out_of_scope": [
"Difficulty tiers, which ship as a separate commit.",
"Letting the player choose the ambush unit: the bar binds it to a fixed cheap unit so SpawnBar stays a pure snapshot-to-buttons function with no local state."
],
"verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus director.test.ts assertions on cooldown gating, lane rotation across 24 summons, and rift edge pinning"
}

View File

@ -0,0 +1,47 @@
{
"intent": "Add three director powers - ambush, frenzy and rift - giving the player priced control over enemy placement and a burst tempo lever.",
"scope": [
"reverse_survivors/src/core/types.ts (PowerKind, costs/cooldowns, EdgeId, DirectorState fields, new DirectorAction variants)",
"reverse_survivors/src/core/director.ts (spendPower gate, ambush/frenzy/rift, rift-aware edgeSpawnPos)",
"reverse_survivors/src/core/sim.ts (frenzy speed and damage), snapshot.ts, src/ui/SpawnBar.tsx, src/ui/Hud.tsx, src/styles.css",
"Non-goal: difficulty tiers ship as their own commit"
],
"changes": [
"A shared spendPower gate handles cooldown and affordability for all three powers, so each additional power costs no new branches in the dispatcher.",
"EDGES converted from an array to Record<EdgeId, EdgeFn>: under noUncheckedIndexedAccess an array index would be possibly-undefined and force an unreachable fallback branch, whereas a finite-union Record stays non-optional.",
"Ambush placement uses a golden-angle step so successive summons never reuse a lane. An earlier 2*PI/7 formulation was caught in review: it silently cycled every 7 summons, with index 7 landing byte-identically on index 0.",
"Frenzy damage derives from one shared frenzyDamageMul helper. Review caught that an earlier version multiplied contact damage only, leaving enemy projectiles and bomber blasts unscaled - which contradicted the README and left the bomber, whose contactDamage is 0, entirely unaffected.",
"MOVE_FNS gained a speed parameter so frenzy (and later difficulty) multiply in one place rather than at three call sites.",
"The README's former promise that enemy placement is never the player's to choose is deliberately reversed here and documented as such."
],
"verification": [
{
"command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
"result": "pass",
"evidence": "145 tests passed. Coverage 100% on all four metrics: statements 516/516, branches 153/153, functions 96/96, lines 499/499."
},
{
"command": "ambush lane probe via vite-node",
"result": "pass",
"evidence": "24 consecutive ambushes produced 24 distinct spawn positions. Before the golden-angle fix the same probe produced only 7, with index 7 identical to index 0 at (610.0000, 300.0000)."
},
{
"command": "frenzy damage-channel probe via vite-node",
"result": "pass",
"evidence": "Ratios with frenzy on vs off, measured over 360 ticks: warden contact 1.300, artillery projectile 1.300, stalker projectile 1.300, bomber blast 1.300. Before the fix the last three measured 1.000."
},
{
"command": "RNG-identity check against the pre-change implementation",
"result": "pass",
"evidence": "The old array-based pick was reimplemented from git HEAD and compared across 50 seeds x 20 calls on both the returned position and the final RNG state: 0 mismatches. Existing seeded replays are unchanged."
}
],
"risks": [
"Powers are gated by cooldown rather than energy alone, because late-game income reaches 24/s and an energy-only gate would degenerate into spamming the highest-value button.",
"Ambush lets the director bypass the survivor's kiting AI, which is the intended counter to its late-game power curve but is the most likely thing to need retuning."
],
"rollback": [
"git revert the commit; no persisted state or data migration is involved.",
"After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
]
}

View File

@ -60,6 +60,15 @@ describe('App', () => {
expect(screen.getByText('30 / 400')).toBeInTheDocument()
})
it('dispatches a director power from the bar', () => {
const raf = installRaf()
render(<App seed={9} boot={(state) => { state.director.energy = 400 }} />)
pump(raf, 0)
fireEvent.click(screen.getByRole('button', { name: /frenzy/i }))
pump(raf, 16)
expect(screen.getByText(/frenzied/i)).toBeInTheDocument()
})
it('shows the dawn overlay when the survivor outlasts the horde', () => {
const raf = installRaf()
render(<App boot={(state) => { state.duration = 0.05 }} />)

View File

@ -3,7 +3,10 @@ import { applyAction, edgeSpawnPos, regenRate, tickDirector, waveCost } from './
import { createInitialState } from './sim'
import { snapshotOf } from './snapshot'
import type { GameState } from './types'
import { ARENA, BOSS_COOLDOWN, ENERGY_CAP } from './types'
import {
AMBUSH_RADIUS, ARENA, BOSS_COOLDOWN, ENEMY_SPECS, ENERGY_CAP, FRENZY_DURATION,
POWER_COOLDOWN, POWER_COST, RIFT_DURATION,
} from './types'
const fresh = (seed = 1): GameState => createInitialState(seed)
@ -169,3 +172,132 @@ describe('snapshot', () => {
expect(snapshotOf(s).survivorHp).toBe(0)
})
})
describe('director powers', () => {
it('power cooldowns drain and floor at zero', () => {
const s = fresh()
s.director.powerCooldowns.ambush = 0.3
s.director.powerCooldowns.frenzy = 2
tickDirector(s, 0.5)
expect(s.director.powerCooldowns.ambush).toBe(0)
expect(s.director.powerCooldowns.frenzy).toBeCloseTo(1.5)
})
it('frenzy and rift timers drain and floor at zero', () => {
const s = fresh()
s.director.frenzyTimer = 0.2
s.director.riftTimer = 5
tickDirector(s, 0.5)
expect(s.director.frenzyTimer).toBe(0)
expect(s.director.riftTimer).toBeCloseTo(4.5)
})
it('ambush lands a unit near the survivor and debits both costs', () => {
const s = fresh()
s.director.energy = 400
expect(applyAction(s, { type: 'ambush', kind: 'rusher' })).toBe(true)
expect(s.enemies).toHaveLength(1)
const e = s.enemies[0]
const d = Math.hypot(
(e?.pos.x ?? 0) - s.survivor.pos.x,
(e?.pos.y ?? 0) - s.survivor.pos.y,
)
expect(d).toBeCloseTo(AMBUSH_RADIUS, 5)
expect(s.director.energy).toBe(400 - POWER_COST.ambush - ENEMY_SPECS.rusher.cost)
expect(s.director.powerCooldowns.ambush).toBe(POWER_COOLDOWN.ambush)
})
it('ambush is rejected when it cannot be afforded', () => {
const s = fresh()
s.director.energy = 5
expect(applyAction(s, { type: 'ambush', kind: 'rusher' })).toBe(false)
expect(s.enemies).toHaveLength(0)
expect(s.director.energy).toBe(5)
})
it('ambush is rejected while cooling', () => {
const s = fresh()
s.director.energy = 400
expect(applyAction(s, { type: 'ambush', kind: 'rusher' })).toBe(true)
expect(applyAction(s, { type: 'ambush', kind: 'rusher' })).toBe(false)
expect(s.enemies).toHaveLength(1)
})
it('ambush lanes never repeat, even past a full turn', () => {
// Regression: a 2*PI/7 step cycled silently, so index 7 reused index 0.
const s = fresh()
const seen: string[] = []
for (let i = 0; i < 24; i += 1) {
s.director.powerCooldowns.ambush = 0
s.director.energy = 400
s.enemies = []
applyAction(s, { type: 'ambush', kind: 'rusher' })
const e = s.enemies[0]
seen.push(`${(e?.pos.x ?? 0).toFixed(6)},${(e?.pos.y ?? 0).toFixed(6)}`)
}
expect(new Set(seen).size).toBe(24)
})
it('successive ambushes rotate to a different lane, deterministically', () => {
const angles = (): number[] => {
const s = fresh()
s.director.energy = 400
const out: number[] = []
for (let i = 0; i < 3; i += 1) {
s.director.powerCooldowns.ambush = 0
s.director.energy = 400
applyAction(s, { type: 'ambush', kind: 'rusher' })
}
for (const e of s.enemies) {
out.push(Math.atan2(e.pos.y - s.survivor.pos.y, e.pos.x - s.survivor.pos.x))
}
return out
}
const first = angles()
expect(new Set(first).size).toBe(3)
expect(angles()).toEqual(first)
})
it('frenzy sets its timer and debits the cost', () => {
const s = fresh()
s.director.energy = 400
expect(applyAction(s, { type: 'frenzy' })).toBe(true)
expect(s.director.frenzyTimer).toBe(FRENZY_DURATION)
expect(s.director.energy).toBe(400 - POWER_COST.frenzy)
})
it('frenzy is rejected when unaffordable', () => {
const s = fresh()
s.director.energy = 10
expect(applyAction(s, { type: 'frenzy' })).toBe(false)
expect(s.director.frenzyTimer).toBe(0)
})
it('a rift pins every spawn to the chosen edge', () => {
const s = fresh()
s.director.energy = 400
expect(applyAction(s, { type: 'rift', edge: 2 })).toBe(true)
expect(s.director.riftTimer).toBe(RIFT_DURATION)
for (let i = 0; i < 20; i += 1) {
expect(edgeSpawnPos(s).x).toBe(0)
}
})
it('once the rift closes, all four edges return', () => {
const s = fresh(7)
s.director.riftTimer = 0
const seen = new Set<string>()
for (let i = 0; i < 40; i += 1) {
const p = edgeSpawnPos(s)
seen.add(`${String(p.x === 0 || p.x === ARENA.w)}-${String(p.y === 0 || p.y === ARENA.h)}`)
}
expect(seen.size).toBeGreaterThan(1)
})
it('a rift is rejected when unaffordable', () => {
const s = fresh()
s.director.energy = 1
expect(applyAction(s, { type: 'rift', edge: 1 })).toBe(false)
expect(s.director.riftTimer).toBe(0)
})
})

View File

@ -1,8 +1,10 @@
import { makeEnemy } from './sim'
import { nextFloat, pick } from './rng'
import type { BossKind, DirectorAction, GameState, UnitKind } from './types'
import type { BossKind, DirectorAction, EdgeId, GameState, PowerKind, UnitKind } from './types'
import {
ARENA, BOSS_COOLDOWN, BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS,
AMBUSH_RADIUS, ARENA, BOSS_COOLDOWN, BOSS_ORDER, BOSS_UNLOCK_AT, EDGE_ORDER, ENERGY_CAP,
ENEMY_SPECS, FRENZY_DURATION, GOLDEN_ANGLE, POWER_COOLDOWN, POWER_COST, POWER_ORDER,
RIFT_DURATION,
} from './types'
import type { Vec } from './vec'
import { clamp } from './vec'
@ -18,19 +20,30 @@ export const tickDirector = (state: GameState, dt: number): void => {
for (const boss of BOSS_ORDER) {
d.bossCooldowns[boss] = Math.max(0, d.bossCooldowns[boss] - dt)
}
for (const power of POWER_ORDER) {
d.powerCooldowns[power] = Math.max(0, d.powerCooldowns[power] - dt)
}
d.frenzyTimer = Math.max(0, d.frenzyTimer - dt)
d.riftTimer = Math.max(0, d.riftTimer - dt)
}
type EdgeFn = (roll: number) => Vec
const EDGES: readonly [EdgeFn, ...EdgeFn[]] = [
(roll): Vec => ({ x: roll * ARENA.w, y: 0 }),
(roll): Vec => ({ x: roll * ARENA.w, y: ARENA.h }),
(roll): Vec => ({ x: 0, y: roll * ARENA.h }),
(roll): Vec => ({ x: ARENA.w, y: roll * ARENA.h }),
]
/** Keyed by EdgeId rather than an array: index access then stays non-optional. */
const EDGES: Record<EdgeId, EdgeFn> = {
0: (roll): Vec => ({ x: roll * ARENA.w, y: 0 }),
1: (roll): Vec => ({ x: roll * ARENA.w, y: ARENA.h }),
2: (roll): Vec => ({ x: 0, y: roll * ARENA.h }),
3: (roll): Vec => ({ x: ARENA.w, y: roll * ARENA.h }),
}
/**
* A live rift pins the edge; otherwise the RNG picks. The unpinned path draws
* exactly two values in the original order, so existing seeded replays are unchanged.
*/
export const edgeSpawnPos = (state: GameState): Vec => {
const edge = pick(state.rng, EDGES)
return edge(nextFloat(state.rng))
const d = state.director
const chosen = d.riftTimer > 0 ? d.riftEdge : pick(state.rng, EDGE_ORDER)
return EDGES[chosen](nextFloat(state.rng))
}
const spawnUnit = (state: GameState, kind: UnitKind): boolean => {
@ -83,6 +96,62 @@ const summonBoss = (state: GameState, kind: BossKind): boolean => {
return true
}
/**
* Shared gate for every power: one cooldown check and one affordability check,
* so adding a power costs no new branches here.
*
* Named `spendPower`, not `usePower`: the React hooks lint rules treat any
* `use`-prefixed function as a hook and reject calls from plain functions.
*/
const spendPower = (state: GameState, kind: PowerKind, extra: number): boolean => {
const d = state.director
if (d.powerCooldowns[kind] > 0) {
return false
}
const cost = POWER_COST[kind] + extra
if (d.energy < cost) {
return false
}
d.energy -= cost
d.powerCooldowns[kind] = POWER_COOLDOWN[kind]
return true
}
const ambush = (state: GameState, kind: UnitKind): boolean => {
if (!spendPower(state, 'ambush', ENEMY_SPECS[kind].cost)) {
return false
}
const d = state.director
// Golden angle: an irrational fraction of a turn, so successive ambushes keep
// landing in the widest remaining gap and never repeat a lane. (A rational
// step like 2*PI/7 looks varied but silently cycles every 7 summons.)
const angle = d.ambushIndex * GOLDEN_ANGLE
d.ambushIndex += 1
const sv = state.survivor.pos
makeEnemy(state, kind, {
x: clamp(sv.x + Math.cos(angle) * AMBUSH_RADIUS, 10, ARENA.w - 10),
y: clamp(sv.y + Math.sin(angle) * AMBUSH_RADIUS, 10, ARENA.h - 10),
})
return true
}
const frenzy = (state: GameState): boolean => {
if (!spendPower(state, 'frenzy', 0)) {
return false
}
state.director.frenzyTimer = FRENZY_DURATION
return true
}
const rift = (state: GameState, edge: EdgeId): boolean => {
if (!spendPower(state, 'rift', 0)) {
return false
}
state.director.riftEdge = edge
state.director.riftTimer = RIFT_DURATION
return true
}
/** Applies a director order. Returns false when rejected (cost, lock, or cooldown). */
export const applyAction = (state: GameState, action: DirectorAction): boolean => {
switch (action.type) {
@ -92,5 +161,11 @@ export const applyAction = (state: GameState, action: DirectorAction): boolean =
return callWave(state)
case 'boss':
return summonBoss(state, action.kind)
case 'ambush':
return ambush(state, action.kind)
case 'frenzy':
return frenzy(state)
case 'rift':
return rift(state, action.edge)
}
}

View File

@ -4,7 +4,9 @@ import {
xpForLevel,
} from './sim'
import type { GameState, Projectile } from './types'
import { ARENA, GAME_DURATION, STATUS_POWER } from './types'
import {
ARENA, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER,
} from './types'
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
@ -582,3 +584,74 @@ describe('status effects', () => {
expect(own?.debuffDuration).toBe(0)
})
})
describe('frenzy', () => {
it('speeds the horde while it lasts', () => {
const base = fresh()
const b = makeEnemy(base, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
step(base, [], 0.05)
const normal = CENTER.x + 300 - b.pos.x
const hyped = fresh()
hyped.director.frenzyTimer = 5
const h = makeEnemy(hyped, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
step(hyped, [], 0.05)
const rushed = CENTER.x + 300 - h.pos.x
expect(rushed).toBeCloseTo(normal * FRENZY_SPEED, 5)
})
it('raises contact damage while it lasts', () => {
const s = fresh()
s.director.frenzyTimer = 5
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
step(s, [], 0.01)
expect(s.survivor.hp).toBeCloseTo(100 - 6 * FRENZY_DAMAGE, 5)
})
it('expires back to baseline speed', () => {
const s = fresh()
s.director.frenzyTimer = 0.01
const e = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
step(s, [], 0.5)
expect(s.director.frenzyTimer).toBe(0)
const after = e.pos.x
step(s, [], 0.05)
const moved = after - e.pos.x
expect(moved).toBeCloseTo(170 * 0.05, 5)
})
})
describe('frenzy damage reaches every channel', () => {
// Regression: frenzy multiplied contact damage only, so ranged and blast
// units — including the bomber, whose whole purpose is damage — ignored it.
const dealt = (frenzied: boolean, kind: 'warden' | 'artillery' | 'bomber', at: number): number => {
const s = fresh(3)
s.survivor.hp = 1e6
s.survivor.maxHp = 1e6
s.survivor.speed = 0
s.duration = 1e6
if (frenzied) {
s.director.frenzyTimer = 1e6
}
makeEnemy(s, kind, { x: CENTER.x + at, y: CENTER.y })
const before = s.survivor.hp
for (let i = 0; i < 360; i += 1) {
step(s, [], 1 / 60)
}
return before - s.survivor.hp
}
it('scales contact damage', () => {
expect(dealt(true, 'warden', 0)).toBeCloseTo(dealt(false, 'warden', 0) * FRENZY_DAMAGE, 5)
})
it('scales ranged damage', () => {
expect(dealt(true, 'artillery', 300))
.toBeCloseTo(dealt(false, 'artillery', 300) * FRENZY_DAMAGE, 5)
})
it('scales blast damage', () => {
expect(dealt(true, 'bomber', 0)).toBeCloseTo(dealt(false, 'bomber', 0) * FRENZY_DAMAGE, 5)
})
})

View File

@ -5,8 +5,8 @@ import type {
DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, Survivor, UpgradeId,
} from './types'
import {
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, STATUS_POWER,
UPGRADE_POOL,
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE, FRENZY_SPEED,
GAME_DURATION, STATUS_POWER, UPGRADE_POOL,
} from './types'
import type { Vec } from './vec'
import { clamp, dist, toward } from './vec'
@ -50,6 +50,11 @@ export const createInitialState = (seed: number, duration = GAME_DURATION): Game
energy: ENERGY_START,
waveIndex: 0,
bossCooldowns: { colossus: 0, hivemind: 0, leech: 0 },
powerCooldowns: { ambush: 0, frenzy: 0, rift: 0 },
frenzyTimer: 0,
riftTimer: 0,
riftEdge: 0,
ambushIndex: 0,
},
upgrades: [],
nextId: 1,
@ -168,11 +173,15 @@ const survivorStep = (state: GameState, dt: number): void => {
}
}
/** Frenzy's damage multiplier, derived from state so every damage channel agrees. */
export const frenzyDamageMul = (state: GameState): number =>
factor(state.director.frenzyTimer, FRENZY_DAMAGE)
const detonate = (state: GameState, enemy: Enemy): void => {
const spec = ENEMY_SPECS[enemy.kind]
enemy.hp = 0
if (dist(enemy.pos, state.survivor.pos) <= spec.blastRadius) {
state.survivor.hp -= spec.blastDamage
state.survivor.hp -= spec.blastDamage * frenzyDamageMul(state)
}
}
@ -203,15 +212,17 @@ const killByShot = (state: GameState, enemy: Enemy): void => {
split(state, spec, enemy.pos)
}
const MOVE_FNS: Record<'chase' | 'skirmish', (e: Enemy, sv: Survivor, d: number) => Vec> = {
chase: (e, sv) => toward(e.pos, sv.pos, ENEMY_SPECS[e.kind].speed),
skirmish: (e, sv, d) => {
type MoveFn = (e: Enemy, sv: Survivor, d: number, speed: number) => Vec
const MOVE_FNS: Record<'chase' | 'skirmish', MoveFn> = {
chase: (e, sv, _d, speed) => toward(e.pos, sv.pos, speed),
skirmish: (e, sv, d, speed) => {
const spec = ENEMY_SPECS[e.kind]
if (d > spec.keepDistance) {
return toward(e.pos, sv.pos, spec.speed)
return toward(e.pos, sv.pos, speed)
}
if (d < spec.keepDistance * 0.65) {
return toward(sv.pos, e.pos, spec.speed)
return toward(sv.pos, e.pos, speed)
}
return { x: 0, y: 0 }
},
@ -220,11 +231,14 @@ const MOVE_FNS: Record<'chase' | 'skirmish', (e: Enemy, sv: Survivor, d: number)
const enemiesStep = (state: GameState, dt: number): void => {
const sv = state.survivor
const spawned: { kind: EnemyKind; pos: Vec }[] = []
// Frenzy is read straight off director state — no import, so no new import cycle.
const speedMul = factor(state.director.frenzyTimer, FRENZY_SPEED)
const damageMul = frenzyDamageMul(state)
for (const e of state.enemies) {
const spec = ENEMY_SPECS[e.kind]
e.hitTimer = Math.max(0, e.hitTimer - dt)
const d = dist(e.pos, sv.pos)
const vel = MOVE_FNS[spec.move](e, sv, d)
const vel = MOVE_FNS[spec.move](e, sv, d, spec.speed * speedMul)
e.pos.x = clamp(e.pos.x + vel.x * dt, spec.radius, ARENA.w - spec.radius)
e.pos.y = clamp(e.pos.y + vel.y * dt, spec.radius, ARENA.h - spec.radius)
@ -232,7 +246,7 @@ const enemiesStep = (state: GameState, dt: number): void => {
if (spec.detonates) {
detonate(state, e)
} else if (e.hitTimer === 0) {
sv.hp -= spec.contactDamage
sv.hp -= spec.contactDamage * damageMul
// Unconditional: a 0-duration spec makes this a provable no-op, which is
// cheaper than a guard branch under the 100 % gate.
applyStatus(sv, spec.debuff, spec.debuffDuration)
@ -251,7 +265,7 @@ const enemiesStep = (state: GameState, dt: number): void => {
pos: { x: e.pos.x, y: e.pos.y },
vel: toward(e.pos, sv.pos, spec.projSpeed),
radius: 5,
damage: spec.projDamage,
damage: spec.projDamage * damageMul,
ttl: ENEMY_PROJ_TTL,
debuff: spec.debuff,
debuffDuration: spec.debuffDuration,

View File

@ -1,11 +1,17 @@
import { waveCost } from './director'
import type { BossKind, GameState, Status, StatusKind, UnitKind, UpgradeId } from './types'
import type {
BossKind, EdgeId, GameState, PowerKind, Status, StatusKind, UnitKind, UpgradeId,
} from './types'
import {
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, STATUS_LABELS, STATUS_ORDER, UNIT_ORDER,
BOSS_ORDER, BOSS_UNLOCK_AT, EDGE_NAMES, EDGE_ORDER, ENERGY_CAP, ENEMY_SPECS, POWER_COST,
POWER_NAMES, POWER_ORDER, STATUS_LABELS, STATUS_ORDER, UNIT_ORDER,
} from './types'
export type BossPhase = 'locked' | 'cooling' | 'ready'
/** Ambush summons a fixed cheap unit, so the bar needs no unit-selection mode. */
export const AMBUSH_UNIT: UnitKind = 'rusher'
export interface UnitButton {
kind: UnitKind
name: string
@ -28,6 +34,21 @@ export interface UpgradeEntry {
id: UpgradeId
}
export interface PowerButton {
kind: PowerKind
name: string
cost: number
cooldown: number
ready: boolean
affordable: boolean
}
export interface EdgeButton {
edge: EdgeId
name: string
active: boolean
}
export interface StatusChip {
kind: StatusKind
label: string
@ -51,6 +72,11 @@ export interface HudSnapshot {
units: UnitButton[]
bosses: BossButton[]
statuses: StatusChip[]
powers: Record<PowerKind, PowerButton>
edges: EdgeButton[]
frenzyActive: boolean
riftSeconds: number
ambushKind: UnitKind
outcomeTime: number
}
@ -64,6 +90,29 @@ const bossPhase = (state: GameState, kind: BossKind): BossPhase => {
return 'ready'
}
/** Ambush also pays for the unit it summons; the others carry no surcharge. */
const POWER_SURCHARGE: Record<PowerKind, number> = {
ambush: ENEMY_SPECS[AMBUSH_UNIT].cost,
frenzy: 0,
rift: 0,
}
const powerButtons = (state: GameState): Record<PowerKind, PowerButton> => {
const out = {} as Record<PowerKind, PowerButton>
for (const kind of POWER_ORDER) {
const cost = POWER_COST[kind] + POWER_SURCHARGE[kind]
out[kind] = {
kind,
name: POWER_NAMES[kind],
cost,
cooldown: Math.ceil(state.director.powerCooldowns[kind]),
ready: state.director.powerCooldowns[kind] === 0,
affordable: state.director.energy >= cost,
}
}
return out
}
export const snapshotOf = (state: GameState): HudSnapshot => ({
status: state.status,
t: state.t,
@ -100,5 +149,14 @@ export const snapshotOf = (state: GameState): HudSnapshot => ({
cooldown: Math.ceil(state.director.bossCooldowns[kind]),
affordable: state.director.energy >= ENEMY_SPECS[kind].cost,
})),
powers: powerButtons(state),
edges: EDGE_ORDER.map((edge) => ({
edge,
name: EDGE_NAMES[edge],
active: state.director.riftTimer > 0 && state.director.riftEdge === edge,
})),
frenzyActive: state.director.frenzyTimer > 0,
riftSeconds: Math.ceil(state.director.riftTimer),
ambushKind: AMBUSH_UNIT,
outcomeTime: state.outcomeTime,
})

View File

@ -156,6 +156,31 @@ export const BOSS_COOLDOWN: Record<BossKind, number> = {
colossus: 25, hivemind: 30, leech: 28,
}
export type PowerKind = 'ambush' | 'frenzy' | 'rift'
export const POWER_ORDER: readonly [PowerKind, ...PowerKind[]] = ['ambush', 'frenzy', 'rift']
export const POWER_NAMES: Record<PowerKind, string> = {
ambush: 'Ambush', frenzy: 'Frenzy', rift: 'Rift',
}
/** Ambush additionally costs the summoned unit's own price. */
export const POWER_COST: Record<PowerKind, number> = { ambush: 45, frenzy: 90, rift: 25 }
export const POWER_COOLDOWN: Record<PowerKind, number> = { ambush: 8, frenzy: 45, rift: 12 }
/** How close to the survivor an ambush lands. Just outside its contact ring. */
export const AMBUSH_RADIUS = 130
/** ~137.5deg. Irrational share of a turn, so repeated steps never land twice. */
export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
export const FRENZY_DURATION = 6
export const FRENZY_SPEED = 1.35
export const FRENZY_DAMAGE = 1.3
export const RIFT_DURATION = 10
/** Arena edge, as a finite union so `Record` indexing stays non-optional. */
export type EdgeId = 0 | 1 | 2 | 3
export const EDGE_ORDER: readonly [EdgeId, ...EdgeId[]] = [0, 1, 2, 3]
export const EDGE_NAMES: Record<EdgeId, string> = {
0: 'North', 1: 'South', 2: 'West', 3: 'East',
}
export type UpgradeId = 'damage' | 'fireRate' | 'speed' | 'vitality' | 'regen' | 'multishot'
export const UPGRADE_POOL: readonly [UpgradeId, ...UpgradeId[]] = [
'damage', 'fireRate', 'speed', 'vitality', 'regen', 'multishot',
@ -206,12 +231,21 @@ export interface DirectorState {
energy: number
waveIndex: number
bossCooldowns: Record<BossKind, number>
powerCooldowns: Record<PowerKind, number>
frenzyTimer: number
riftTimer: number
riftEdge: EdgeId
/** Rotates ambush placement deterministically, without drawing from the RNG. */
ambushIndex: number
}
export type DirectorAction =
| { type: 'spawn'; kind: UnitKind }
| { type: 'wave' }
| { type: 'boss'; kind: BossKind }
| { type: 'ambush'; kind: UnitKind }
| { type: 'frenzy' }
| { type: 'rift'; edge: EdgeId }
export type Status = 'running' | 'directorWon' | 'survivorWon'

View File

@ -243,3 +243,19 @@ body {
.board { flex-direction: column; }
.hud { width: 100%; }
}
.card-power { border-color: var(--arcane); }
.card-rift { border-color: var(--bone-dim); }
.card-rift-active { border-color: var(--holy); color: var(--holy); }
.frenzy-banner {
margin: 10px 0 0;
padding: 5px 9px;
border: 1px solid var(--blood);
border-radius: 4px;
color: var(--blood);
font-family: var(--font-data);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-align: center;
}

View File

@ -55,6 +55,10 @@ export const Hud = ({ snap, onRestart }: Props): ReactElement => {
</ul>
</div>
{snap.frenzyActive && (
<p className="frenzy-banner">The horde is frenzied</p>
)}
{snap.status === 'directorWon' && (
<div className="overlay overlay-win">
<h2>The intruder falls</h2>

View File

@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import type { BossButton } from '../core/snapshot'
import type { BossButton, PowerButton } from '../core/snapshot'
import type { HudSnapshot } from '../core/snapshot'
import type { DirectorAction } from '../core/types'
@ -18,8 +18,12 @@ const bossCaption = (b: BossButton): string => {
return `${String(b.cost)} souls`
}
const powerCaption = (p: PowerButton): string =>
p.ready ? `${String(p.cost)} souls` : `recovering ${String(p.cooldown)}s`
export const SpawnBar = ({ snap, onAction }: Props): ReactElement => {
const running = snap.status === 'running'
const { ambush, frenzy, rift } = snap.powers
return (
<nav className="spawnbar" aria-label="Summoning">
{snap.units.map((u) => (
@ -55,6 +59,36 @@ export const SpawnBar = ({ snap, onAction }: Props): ReactElement => {
<span className="card-cost">{bossCaption(b)}</span>
</button>
))}
<button
type="button"
className="card card-power"
disabled={!running || !ambush.ready || !ambush.affordable}
onClick={() => { onAction({ type: 'ambush', kind: snap.ambushKind }) }}
>
<span className="card-name">Ambush</span>
<span className="card-cost">{powerCaption(ambush)}</span>
</button>
<button
type="button"
className="card card-power"
disabled={!running || !frenzy.ready || !frenzy.affordable}
onClick={() => { onAction({ type: 'frenzy' }) }}
>
<span className="card-name">Frenzy</span>
<span className="card-cost">{powerCaption(frenzy)}</span>
</button>
{snap.edges.map((e) => (
<button
key={`edge-${String(e.edge)}`}
type="button"
className={e.active ? 'card card-rift card-rift-active' : 'card card-rift'}
disabled={!running || !rift.ready || !rift.affordable}
onClick={() => { onAction({ type: 'rift', edge: e.edge }) }}
>
<span className="card-name">{e.name}</span>
<span className="card-cost">{e.active ? `open ${String(snap.riftSeconds)}s` : 'rift'}</span>
</button>
))}
</nav>
)
}

View File

@ -116,3 +116,74 @@ describe('SpawnBar', () => {
expect(onAction).toHaveBeenCalledWith({ type: 'boss', kind: 'colossus' })
})
})
describe('SpawnBar powers', () => {
it('dispatches an ambush when funded and ready', () => {
const onAction = vi.fn()
render(<SpawnBar snap={snapWith((s) => { s.director.energy = 400 })} onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: /ambush/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'ambush', kind: 'rusher' })
})
it('disables ambush while it is cooling and shows the timer', () => {
render(
<SpawnBar
snap={snapWith((s) => {
s.director.energy = 400
s.director.powerCooldowns.ambush = 4
})}
onAction={() => undefined}
/>,
)
const btn = screen.getByRole('button', { name: /ambush/i })
expect(btn).toBeDisabled()
expect(btn).toHaveTextContent('recovering 4s')
})
it('dispatches frenzy when funded and blocks it when broke', () => {
const onAction = vi.fn()
const { rerender } = render(<SpawnBar snap={snapWith()} onAction={onAction} />)
expect(screen.getByRole('button', { name: /frenzy/i })).toBeDisabled()
rerender(
<SpawnBar snap={snapWith((s) => { s.director.energy = 400 })} onAction={onAction} />,
)
fireEvent.click(screen.getByRole('button', { name: /frenzy/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'frenzy' })
})
it('dispatches a rift on the chosen edge', () => {
const onAction = vi.fn()
render(<SpawnBar snap={snapWith((s) => { s.director.energy = 400 })} onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: /west/i }))
expect(onAction).toHaveBeenCalledWith({ type: 'rift', edge: 2 })
})
it('marks the open rift edge and counts it down', () => {
const { container } = render(
<SpawnBar
snap={snapWith((s) => {
s.director.energy = 400
s.director.riftTimer = 7
s.director.riftEdge = 3
})}
onAction={() => undefined}
/>,
)
expect(container.querySelectorAll('.card-rift-active')).toHaveLength(1)
expect(screen.getByRole('button', { name: /east/i })).toHaveTextContent('open 7s')
})
})
describe('Hud frenzy banner', () => {
it('appears only while the horde is frenzied', () => {
const { rerender } = render(<Hud snap={snapWith()} onRestart={() => undefined} />)
expect(screen.queryByText(/frenzied/i)).not.toBeInTheDocument()
rerender(
<Hud
snap={snapWith((s) => { s.director.frenzyTimer = 3 })}
onRestart={() => undefined}
/>,
)
expect(screen.getByText(/frenzied/i)).toBeInTheDocument()
})
})