diff --git a/docs/superpowers/contracts/reverse-survivors-director-powers-2026-08-09.json b/docs/superpowers/contracts/reverse-survivors-director-powers-2026-08-09.json new file mode 100644 index 00000000..0e1dccd7 --- /dev/null +++ b/docs/superpowers/contracts/reverse-survivors-director-powers-2026-08-09.json @@ -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" +} diff --git a/docs/superpowers/evidence/reverse-survivors-director-powers-2026-08-09.json b/docs/superpowers/evidence/reverse-survivors-director-powers-2026-08-09.json new file mode 100644 index 00000000..9b5884c5 --- /dev/null +++ b/docs/superpowers/evidence/reverse-survivors-director-powers-2026-08-09.json @@ -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: 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." + ] +} diff --git a/reverse_survivors/src/App.test.tsx b/reverse_survivors/src/App.test.tsx index 460da746..dbce883d 100644 --- a/reverse_survivors/src/App.test.tsx +++ b/reverse_survivors/src/App.test.tsx @@ -60,6 +60,15 @@ describe('App', () => { expect(screen.getByText('30 / 400')).toBeInTheDocument() }) + it('dispatches a director power from the bar', () => { + const raf = installRaf() + render( { 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( { state.duration = 0.05 }} />) diff --git a/reverse_survivors/src/core/director.test.ts b/reverse_survivors/src/core/director.test.ts index 7e859d35..24823bb6 100644 --- a/reverse_survivors/src/core/director.test.ts +++ b/reverse_survivors/src/core/director.test.ts @@ -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() + 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) + }) +}) diff --git a/reverse_survivors/src/core/director.ts b/reverse_survivors/src/core/director.ts index 72828a3e..c4bcf661 100644 --- a/reverse_survivors/src/core/director.ts +++ b/reverse_survivors/src/core/director.ts @@ -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 = { + 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) } } diff --git a/reverse_survivors/src/core/sim.test.ts b/reverse_survivors/src/core/sim.test.ts index d7fc41e7..ca3c5802 100644 --- a/reverse_survivors/src/core/sim.test.ts +++ b/reverse_survivors/src/core/sim.test.ts @@ -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) + }) +}) diff --git a/reverse_survivors/src/core/sim.ts b/reverse_survivors/src/core/sim.ts index 90f2ea7f..b8083e2c 100644 --- a/reverse_survivors/src/core/sim.ts +++ b/reverse_survivors/src/core/sim.ts @@ -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, diff --git a/reverse_survivors/src/core/snapshot.ts b/reverse_survivors/src/core/snapshot.ts index 54f8ca5a..98143d60 100644 --- a/reverse_survivors/src/core/snapshot.ts +++ b/reverse_survivors/src/core/snapshot.ts @@ -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 + 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 = { + ambush: ENEMY_SPECS[AMBUSH_UNIT].cost, + frenzy: 0, + rift: 0, +} + +const powerButtons = (state: GameState): Record => { + const out = {} as Record + 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, }) diff --git a/reverse_survivors/src/core/types.ts b/reverse_survivors/src/core/types.ts index d589c426..1dd5fea0 100644 --- a/reverse_survivors/src/core/types.ts +++ b/reverse_survivors/src/core/types.ts @@ -156,6 +156,31 @@ export const BOSS_COOLDOWN: Record = { 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 = { + ambush: 'Ambush', frenzy: 'Frenzy', rift: 'Rift', +} +/** Ambush additionally costs the summoned unit's own price. */ +export const POWER_COST: Record = { ambush: 45, frenzy: 90, rift: 25 } +export const POWER_COOLDOWN: Record = { 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 = { + 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 + powerCooldowns: Record + 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' diff --git a/reverse_survivors/src/styles.css b/reverse_survivors/src/styles.css index 69f3ebf2..8ac77120 100644 --- a/reverse_survivors/src/styles.css +++ b/reverse_survivors/src/styles.css @@ -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; +} diff --git a/reverse_survivors/src/ui/Hud.tsx b/reverse_survivors/src/ui/Hud.tsx index c2cf9df1..3b5a0e59 100644 --- a/reverse_survivors/src/ui/Hud.tsx +++ b/reverse_survivors/src/ui/Hud.tsx @@ -55,6 +55,10 @@ export const Hud = ({ snap, onRestart }: Props): ReactElement => { + {snap.frenzyActive && ( +

The horde is frenzied

+ )} + {snap.status === 'directorWon' && (

The intruder falls

diff --git a/reverse_survivors/src/ui/SpawnBar.tsx b/reverse_survivors/src/ui/SpawnBar.tsx index ebb8cb9b..6c218145 100644 --- a/reverse_survivors/src/ui/SpawnBar.tsx +++ b/reverse_survivors/src/ui/SpawnBar.tsx @@ -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 ( ) } diff --git a/reverse_survivors/src/ui/panels.test.tsx b/reverse_survivors/src/ui/panels.test.tsx index e7b953d7..541605c4 100644 --- a/reverse_survivors/src/ui/panels.test.tsx +++ b/reverse_survivors/src/ui/panels.test.tsx @@ -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( { 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( + { + 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() + expect(screen.getByRole('button', { name: /frenzy/i })).toBeDisabled() + rerender( + { 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( { 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( + { + 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( undefined} />) + expect(screen.queryByText(/frenzied/i)).not.toBeInTheDocument() + rerender( + { s.director.frenzyTimer = 3 })} + onRestart={() => undefined} + />, + ) + expect(screen.getByText(/frenzied/i)).toBeInTheDocument() + }) +})