mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 12:07:52 +01:00
reverse_survivors: survivor status effects (slow, suppress, bleed)
The warden mires, the artillery stifles, the leech unknits. All three need contact, so a survivor that kites cleanly takes none of them — debuffs punish positional failure rather than taxing existence. isOn/factor are written as arithmetic rather than comparisons: Math.min(1, Math.ceil(t)) is 1 while a timer is live and 0 once it expires, so consumers multiply instead of branching. The whole status module therefore contributes zero branches, which is what makes the 100% gate affordable for a system this cross-cutting. Statuses are applied unconditionally too; a 0-duration spec is a provable no-op. applyStatus uses Math.max, so re-applying refreshes rather than stacks. That caps a horde's total lockdown at one duration by construction — a structural guarantee, not a tuned number. 123 tests, 100% coverage. Branches 117 -> 119: the draw halo gate and the snapshot chip filter. The status logic itself adds none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc
This commit is contained in:
parent
f0712d3854
commit
43b63ac266
@ -0,0 +1,16 @@
|
||||
{
|
||||
"title": "Reverse Survivors: survivor status effects",
|
||||
"objective": "Give the survivor three debuff channels - slow (movement), suppress (fire rate) and bleed (regen block) - so ranged and fast units have a lever beyond raw damage. The system must be cross-cutting yet contribute zero branches, so the 100% coverage gate stays affordable.",
|
||||
"acceptance_criteria": [
|
||||
"`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% statements/branches/functions/lines.",
|
||||
"The status module itself adds zero branches, verified against the v8 branch count (117 -> 119 accounted for solely by the draw halo gate and the snapshot chip filter).",
|
||||
"Re-applying a status refreshes rather than stacks, so total lockdown is capped at one duration regardless of horde size.",
|
||||
"An enemy that inflicts nothing leaves all three timers at exactly 0.",
|
||||
"Survivor-fired projectiles can never debuff the survivor."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Director powers and difficulty tiers, which ship as separate commits.",
|
||||
"Aura or ranged-application debuffs: all three require contact by design, so a cleanly kiting survivor takes none."
|
||||
],
|
||||
"verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus targeted sim.test.ts assertions comparing slowed movement against an unslowed control run"
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
{
|
||||
"intent": "Give the survivor three status debuffs - slow, suppress (fire rate) and bleed (regen block) - so the new ranged and fast units have a lever beyond raw damage.",
|
||||
"scope": [
|
||||
"reverse_survivors/src/core/status.ts (new leaf module) and status.test.ts",
|
||||
"reverse_survivors/src/core/types.ts (StatusKind, STATUS_POWER/LABELS, per-spec debuff fields, Survivor.statuses, Projectile payload)",
|
||||
"reverse_survivors/src/core/sim.ts (tick and consume), snapshot.ts (chips), src/ui/Hud.tsx, src/render/draw.ts (halo), src/styles.css",
|
||||
"Non-goal: director powers and difficulty tiers ship as their own commits"
|
||||
],
|
||||
"changes": [
|
||||
"isOn/factor are arithmetic (Math.min(1, Math.ceil(t))) rather than comparisons, so the whole status system contributes ZERO branches; consumers multiply by the result instead of branching on it.",
|
||||
"applyStatus uses Math.max, making refresh-not-stack a structural guarantee: a horde can never chain-lock the survivor past one status duration, regardless of how many enemies land hits.",
|
||||
"Statuses are applied unconditionally at contact and projectile-hit sites; a 0-duration spec makes the call a provable no-op, which is cheaper than a guard branch under the 100% gate.",
|
||||
"Projectiles carry their own debuff payload so a hit needs no lookup of a possibly-dead firer.",
|
||||
"Statuses tick at the top of survivorStep, before they are read, so a status applied on tick N is felt from N+1 and an inert application is never observable.",
|
||||
"Suppression scales the reload rather than the countdown, keeping the remaining timer linear in dt so replays and assertions stay exact.",
|
||||
"The draw.ts halo is gated so an unafflicted survivor renders exactly as before, keeping the existing BASE draw-call counts valid."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
|
||||
"result": "pass",
|
||||
"evidence": "123 tests passed. Coverage 100% on all four metrics: statements 446/446, branches 119/119, functions 84/84, lines 429/429. Branch count rose 117 -> 119, accounted for by the halo gate and the snapshot chip filter; the status module itself adds none."
|
||||
},
|
||||
{
|
||||
"command": "targeted assertions in sim.test.ts",
|
||||
"result": "pass",
|
||||
"evidence": "Warden contact slows movement by exactly STATUS_POWER.slow against an unslowed control run; artillery shells lengthen the next reload to 0.55 * 1.75; a leech contact blocks regen and it resumes on expiry; two simultaneous warden contacts do not exceed the 2.0s cap; a plain rusher leaves all three timers at 0."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Bleed is a no-op unless the survivor has bought the regen upgrade, since it starts at regen 0. This is intended - the Leech counters a specific build - and matches the README description.",
|
||||
"All three debuffs require contact, so a survivor that kites cleanly takes none of them. This is deliberate: debuffs punish positional failure rather than taxing existence."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; src/core/status.ts and status.test.ts are new files and disappear with it.",
|
||||
"After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
|
||||
]
|
||||
}
|
||||
@ -4,7 +4,7 @@ import {
|
||||
xpForLevel,
|
||||
} from './sim'
|
||||
import type { GameState, Projectile } from './types'
|
||||
import { ARENA, GAME_DURATION } from './types'
|
||||
import { ARENA, GAME_DURATION, STATUS_POWER } from './types'
|
||||
|
||||
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
|
||||
|
||||
@ -20,6 +20,8 @@ const shot = (state: GameState, x: number, y: number, damage: number): Projectil
|
||||
radius: 4,
|
||||
damage,
|
||||
ttl: 1,
|
||||
debuff: 'slow',
|
||||
debuffDuration: 0,
|
||||
}
|
||||
state.nextId += 1
|
||||
state.projectiles.push(p)
|
||||
@ -312,6 +314,7 @@ describe('projectiles', () => {
|
||||
s.projectiles.push({
|
||||
id: 500, from: 'enemy', pos: { x: CENTER.x, y: CENTER.y },
|
||||
vel: { x: 0, y: 0 }, radius: 5, damage: 7, ttl: 1,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
})
|
||||
step(s, [], 0.001)
|
||||
expect(s.survivor.hp).toBe(93)
|
||||
@ -323,6 +326,7 @@ describe('projectiles', () => {
|
||||
s.projectiles.push({
|
||||
id: 501, from: 'enemy', pos: { x: 30, y: 30 },
|
||||
vel: { x: 0, y: 0 }, radius: 5, damage: 7, ttl: 1,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
})
|
||||
step(s, [], 0.001)
|
||||
expect(s.survivor.hp).toBe(100)
|
||||
@ -485,3 +489,96 @@ describe('new units', () => {
|
||||
expect(s.enemies.some((e) => e.kind === 'rusher')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status effects', () => {
|
||||
it('a warden contact mires the survivor and halves its movement', () => {
|
||||
const slowed = fresh()
|
||||
slowed.survivor.pos = { x: 100, y: CENTER.y }
|
||||
makeEnemy(slowed, 'warden', { x: 100, y: CENTER.y })
|
||||
step(slowed, [], 0.01)
|
||||
// Statuses tick in survivorStep, which runs before enemiesStep applies
|
||||
// contact — so a status landing this tick starts at its full duration.
|
||||
expect(slowed.survivor.statuses.slow).toBe(2.0)
|
||||
|
||||
// Control: same displacement, an enemy that inflicts nothing.
|
||||
const control = fresh()
|
||||
control.survivor.pos = { x: 100, y: CENTER.y }
|
||||
makeEnemy(control, 'rusher', { x: 100, y: CENTER.y })
|
||||
step(control, [], 0.01)
|
||||
expect(control.survivor.statuses.slow).toBe(0)
|
||||
|
||||
const before = { slowed: slowed.survivor.pos.x, control: control.survivor.pos.x }
|
||||
slowed.enemies = []
|
||||
control.enemies = []
|
||||
step(slowed, [], 0.05)
|
||||
step(control, [], 0.05)
|
||||
const movedSlowed = slowed.survivor.pos.x - before.slowed
|
||||
const movedControl = control.survivor.pos.x - before.control
|
||||
expect(movedSlowed).toBeCloseTo(movedControl * STATUS_POWER.slow, 5)
|
||||
})
|
||||
|
||||
it('slow expires and full speed returns', () => {
|
||||
const s = fresh()
|
||||
s.survivor.pos = { x: 100, y: CENTER.y }
|
||||
s.survivor.statuses.slow = 0.02
|
||||
step(s, [], 0.05)
|
||||
expect(s.survivor.statuses.slow).toBe(0)
|
||||
})
|
||||
|
||||
it('an artillery shell stifles the survivor and lengthens its reload', () => {
|
||||
const s = fresh()
|
||||
s.projectiles.push({
|
||||
id: 900, from: 'enemy', pos: { x: CENTER.x, y: CENTER.y },
|
||||
vel: { x: 0, y: 0 }, radius: 5, damage: 13, ttl: 1,
|
||||
debuff: 'suppress', debuffDuration: 2.5,
|
||||
})
|
||||
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
|
||||
step(s, [], 0.001)
|
||||
expect(s.survivor.statuses.suppress).toBeGreaterThan(0)
|
||||
// The shot fired this tick reloaded before the status landed; the next one pays.
|
||||
s.survivor.fireTimer = 0
|
||||
step(s, [], 0.001)
|
||||
expect(s.survivor.fireTimer).toBeCloseTo(0.55 * STATUS_POWER.suppress, 5)
|
||||
})
|
||||
|
||||
it('a leech contact blocks regeneration until it expires', () => {
|
||||
const s = fresh()
|
||||
s.survivor.regen = 5
|
||||
s.survivor.hp = 50
|
||||
makeEnemy(s, 'leech', { x: CENTER.x, y: CENTER.y })
|
||||
const hp0 = s.survivor.hp
|
||||
step(s, [], 0.1)
|
||||
// Contact damage lands and regen is suppressed, so hp cannot have risen.
|
||||
expect(s.survivor.hp).toBeLessThan(hp0)
|
||||
expect(s.survivor.statuses.bleed).toBeGreaterThan(0)
|
||||
|
||||
s.enemies = []
|
||||
s.survivor.statuses.bleed = 0
|
||||
const hp1 = s.survivor.hp
|
||||
step(s, [], 0.1)
|
||||
expect(s.survivor.hp).toBeGreaterThan(hp1)
|
||||
})
|
||||
|
||||
it('repeated warden contacts refresh rather than stack', () => {
|
||||
const s = fresh()
|
||||
makeEnemy(s, 'warden', { x: CENTER.x, y: CENTER.y })
|
||||
makeEnemy(s, 'warden', { x: CENTER.x, y: CENTER.y })
|
||||
step(s, [], 0.01)
|
||||
expect(s.survivor.statuses.slow).toBeLessThanOrEqual(2.0)
|
||||
})
|
||||
|
||||
it('a plain rusher inflicts no status at all', () => {
|
||||
const s = fresh()
|
||||
makeEnemy(s, 'rusher', { x: CENTER.x, y: CENTER.y })
|
||||
step(s, [], 0.01)
|
||||
expect(s.survivor.statuses).toEqual({ slow: 0, suppress: 0, bleed: 0 })
|
||||
})
|
||||
|
||||
it('survivor fire never debuffs the survivor', () => {
|
||||
const s = fresh()
|
||||
makeEnemy(s, 'tank', { x: CENTER.x + 200, y: CENTER.y })
|
||||
step(s, [], 0.001)
|
||||
const own = s.projectiles.find((p) => p.from === 'survivor')
|
||||
expect(own?.debuffDuration).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { applyAction, tickDirector } from './director'
|
||||
import { createRng, pick } from './rng'
|
||||
import { applyStatus, factor, tickStatuses } from './status'
|
||||
import type {
|
||||
DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, Survivor, UpgradeId,
|
||||
} from './types'
|
||||
import {
|
||||
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, UPGRADE_POOL,
|
||||
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, GAME_DURATION, STATUS_POWER,
|
||||
UPGRADE_POOL,
|
||||
} from './types'
|
||||
import type { Vec } from './vec'
|
||||
import { clamp, dist, toward } from './vec'
|
||||
@ -33,6 +35,7 @@ const createSurvivor = (): Survivor => ({
|
||||
xp: 0,
|
||||
xpNext: xpForLevel(1),
|
||||
kills: 0,
|
||||
statuses: { slow: 0, suppress: 0, bleed: 0 },
|
||||
})
|
||||
|
||||
export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({
|
||||
@ -110,6 +113,13 @@ const nearestEnemy = (state: GameState): Enemy | null => {
|
||||
|
||||
const survivorStep = (state: GameState, dt: number): void => {
|
||||
const sv = state.survivor
|
||||
// Tick before reading: a status applied this tick is felt from the next one,
|
||||
// and a 0-second (inert) application is never observable.
|
||||
tickStatuses(sv, dt)
|
||||
const moveMul = factor(sv.statuses.slow, STATUS_POWER.slow)
|
||||
const fireMul = factor(sv.statuses.suppress, STATUS_POWER.suppress)
|
||||
const regenMul = factor(sv.statuses.bleed, STATUS_POWER.bleed)
|
||||
const speed = sv.speed * moveMul
|
||||
let fleeX = 0
|
||||
let fleeY = 0
|
||||
for (const e of state.enemies) {
|
||||
@ -123,14 +133,14 @@ const survivorStep = (state: GameState, dt: number): void => {
|
||||
const fleeMag = Math.hypot(fleeX, fleeY)
|
||||
let vel: Vec = { x: 0, y: 0 }
|
||||
if (fleeMag > 0) {
|
||||
vel = { x: (fleeX / fleeMag) * sv.speed, y: (fleeY / fleeMag) * sv.speed }
|
||||
vel = { x: (fleeX / fleeMag) * speed, y: (fleeY / fleeMag) * speed }
|
||||
} else if (dist(sv.pos, CENTER) > 4) {
|
||||
vel = toward(sv.pos, CENTER, sv.speed * 0.4)
|
||||
vel = toward(sv.pos, CENTER, speed * 0.4)
|
||||
}
|
||||
sv.pos.x = clamp(sv.pos.x + vel.x * dt, SURVIVOR_RADIUS, ARENA.w - SURVIVOR_RADIUS)
|
||||
sv.pos.y = clamp(sv.pos.y + vel.y * dt, SURVIVOR_RADIUS, ARENA.h - SURVIVOR_RADIUS)
|
||||
|
||||
sv.hp = Math.min(sv.maxHp, sv.hp + sv.regen * dt)
|
||||
sv.hp = Math.min(sv.maxHp, sv.hp + sv.regen * regenMul * dt)
|
||||
|
||||
sv.fireTimer = Math.max(0, sv.fireTimer - dt)
|
||||
const target = nearestEnemy(state)
|
||||
@ -148,9 +158,13 @@ const survivorStep = (state: GameState, dt: number): void => {
|
||||
radius: 4,
|
||||
damage: sv.damage,
|
||||
ttl: PROJ_TTL,
|
||||
debuff: 'slow',
|
||||
debuffDuration: 0,
|
||||
})
|
||||
}
|
||||
sv.fireTimer = sv.fireCooldown
|
||||
// Suppression scales the reload, not the countdown: the remaining timer stays
|
||||
// linear in dt, which keeps replays and assertions exact.
|
||||
sv.fireTimer = sv.fireCooldown * fireMul
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,6 +233,9 @@ const enemiesStep = (state: GameState, dt: number): void => {
|
||||
detonate(state, e)
|
||||
} else if (e.hitTimer === 0) {
|
||||
sv.hp -= spec.contactDamage
|
||||
// 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)
|
||||
e.hitTimer = CONTACT_COOLDOWN
|
||||
}
|
||||
}
|
||||
@ -236,6 +253,8 @@ const enemiesStep = (state: GameState, dt: number): void => {
|
||||
radius: 5,
|
||||
damage: spec.projDamage,
|
||||
ttl: ENEMY_PROJ_TTL,
|
||||
debuff: spec.debuff,
|
||||
debuffDuration: spec.debuffDuration,
|
||||
})
|
||||
e.fireTimer = spec.fireCooldown
|
||||
}
|
||||
@ -284,6 +303,7 @@ const projectilesStep = (state: GameState, dt: number): void => {
|
||||
}
|
||||
} else if (dist(p.pos, sv.pos) <= p.radius + SURVIVOR_RADIUS) {
|
||||
sv.hp -= p.damage
|
||||
applyStatus(sv, p.debuff, p.debuffDuration)
|
||||
continue
|
||||
}
|
||||
surviving.push(p)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { waveCost } from './director'
|
||||
import type { BossKind, GameState, Status, UnitKind, UpgradeId } from './types'
|
||||
import type { BossKind, GameState, Status, StatusKind, UnitKind, UpgradeId } from './types'
|
||||
import {
|
||||
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, UNIT_ORDER,
|
||||
BOSS_ORDER, BOSS_UNLOCK_AT, ENERGY_CAP, ENEMY_SPECS, STATUS_LABELS, STATUS_ORDER, UNIT_ORDER,
|
||||
} from './types'
|
||||
|
||||
export type BossPhase = 'locked' | 'cooling' | 'ready'
|
||||
@ -28,6 +28,12 @@ export interface UpgradeEntry {
|
||||
id: UpgradeId
|
||||
}
|
||||
|
||||
export interface StatusChip {
|
||||
kind: StatusKind
|
||||
label: string
|
||||
seconds: number
|
||||
}
|
||||
|
||||
export interface HudSnapshot {
|
||||
status: Status
|
||||
t: number
|
||||
@ -44,6 +50,7 @@ export interface HudSnapshot {
|
||||
upgrades: UpgradeEntry[]
|
||||
units: UnitButton[]
|
||||
bosses: BossButton[]
|
||||
statuses: StatusChip[]
|
||||
outcomeTime: number
|
||||
}
|
||||
|
||||
@ -71,6 +78,13 @@ export const snapshotOf = (state: GameState): HudSnapshot => ({
|
||||
survivorLevel: state.survivor.level,
|
||||
kills: state.survivor.kills,
|
||||
upgrades: state.upgrades.map((id, i) => ({ n: i + 1, id })).slice(-3),
|
||||
statuses: STATUS_ORDER
|
||||
.map((kind) => ({
|
||||
kind,
|
||||
label: STATUS_LABELS[kind],
|
||||
seconds: Math.ceil(state.survivor.statuses[kind]),
|
||||
}))
|
||||
.filter((chip) => chip.seconds > 0),
|
||||
units: UNIT_ORDER.map((kind) => ({
|
||||
kind,
|
||||
name: ENEMY_SPECS[kind].name,
|
||||
|
||||
82
reverse_survivors/src/core/status.test.ts
Normal file
82
reverse_survivors/src/core/status.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyStatus, factor, isOn, tickStatuses } from './status'
|
||||
import type { Survivor } from './types'
|
||||
import { STATUS_POWER } from './types'
|
||||
|
||||
const sv = (): Survivor => ({
|
||||
pos: { x: 0, y: 0 },
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
speed: 150,
|
||||
regen: 0,
|
||||
damage: 8,
|
||||
multishot: 1,
|
||||
fireCooldown: 0.55,
|
||||
fireTimer: 0,
|
||||
level: 1,
|
||||
xp: 0,
|
||||
xpNext: 20,
|
||||
kills: 0,
|
||||
statuses: { slow: 0, suppress: 0, bleed: 0 },
|
||||
})
|
||||
|
||||
describe('isOn', () => {
|
||||
it('is 0 only at exactly zero', () => {
|
||||
expect(isOn(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('is 1 for any live remainder, however small', () => {
|
||||
expect(isOn(0.001)).toBe(1)
|
||||
expect(isOn(5)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('factor', () => {
|
||||
it('is 1 when the timer is idle', () => {
|
||||
expect(factor(0, STATUS_POWER.slow)).toBe(1)
|
||||
})
|
||||
|
||||
it('is the active multiplier while the timer runs', () => {
|
||||
expect(factor(2, STATUS_POWER.slow)).toBeCloseTo(0.55)
|
||||
})
|
||||
|
||||
it('supports a total shutdown multiplier', () => {
|
||||
expect(factor(1, STATUS_POWER.bleed)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tickStatuses', () => {
|
||||
it('drains every timer and floors at zero', () => {
|
||||
const s = sv()
|
||||
s.statuses.slow = 1
|
||||
s.statuses.suppress = 0.4
|
||||
tickStatuses(s, 0.5)
|
||||
expect(s.statuses.slow).toBeCloseTo(0.5)
|
||||
expect(s.statuses.suppress).toBe(0)
|
||||
expect(s.statuses.bleed).toBe(0)
|
||||
})
|
||||
|
||||
it('never goes negative on a large dt', () => {
|
||||
const s = sv()
|
||||
s.statuses.bleed = 1
|
||||
tickStatuses(s, 99)
|
||||
expect(s.statuses.bleed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyStatus', () => {
|
||||
it('refreshes to the longer remainder and never stacks', () => {
|
||||
const s = sv()
|
||||
applyStatus(s, 'slow', 2)
|
||||
applyStatus(s, 'slow', 1)
|
||||
expect(s.statuses.slow).toBe(2)
|
||||
applyStatus(s, 'slow', 3)
|
||||
expect(s.statuses.slow).toBe(3)
|
||||
})
|
||||
|
||||
it('a zero duration is a no-op, which is how inert specs call it', () => {
|
||||
const s = sv()
|
||||
applyStatus(s, 'bleed', 0)
|
||||
expect(s.statuses.bleed).toBe(0)
|
||||
})
|
||||
})
|
||||
34
reverse_survivors/src/core/status.ts
Normal file
34
reverse_survivors/src/core/status.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { StatusKind, Survivor } from './types'
|
||||
import { STATUS_ORDER } from './types'
|
||||
|
||||
/**
|
||||
* 1 while the timer is live, 0 once it has expired.
|
||||
*
|
||||
* Deliberately arithmetic rather than `timer > 0`: every consumer of this value
|
||||
* multiplies by it, so an `if` here would add a branch pair per status per
|
||||
* consumer — the coverage gate is 100 % and those branches buy nothing.
|
||||
* Timers only reach 0 via `Math.max(0, t - dt)`, so the domain is [0, inf).
|
||||
*/
|
||||
export const isOn = (timer: number): number => Math.min(1, Math.ceil(timer))
|
||||
|
||||
/** `active` while the timer runs, 1 when idle. Branch-free blend. */
|
||||
export const factor = (timer: number, active: number): number =>
|
||||
1 - isOn(timer) * (1 - active)
|
||||
|
||||
export const tickStatuses = (sv: Survivor, dt: number): void => {
|
||||
for (const kind of STATUS_ORDER) {
|
||||
sv.statuses[kind] = Math.max(0, sv.statuses[kind] - dt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-not-stack: re-applying extends the timer, never compounds it.
|
||||
* This is what caps a horde's total lockdown at one status duration — the
|
||||
* guarantee is structural, not a tuned number.
|
||||
*
|
||||
* A 0-second duration is a no-op, which is how non-debuffing enemies call this
|
||||
* unconditionally without needing a guard branch.
|
||||
*/
|
||||
export const applyStatus = (sv: Survivor, kind: StatusKind, seconds: number): void => {
|
||||
sv.statuses[kind] = Math.max(sv.statuses[kind], seconds)
|
||||
}
|
||||
@ -7,6 +7,22 @@ export const CONTACT_COOLDOWN = 0.5
|
||||
export const ENERGY_CAP = 400
|
||||
export const ENERGY_START = 30
|
||||
|
||||
export type StatusKind = 'slow' | 'suppress' | 'bleed'
|
||||
export const STATUS_ORDER: readonly [StatusKind, ...StatusKind[]] = ['slow', 'suppress', 'bleed']
|
||||
|
||||
/** Multiplier applied to the survivor while the matching status is live. */
|
||||
export const STATUS_POWER: Record<StatusKind, number> = {
|
||||
slow: 0.55, // move speed
|
||||
suppress: 1.75, // fire cooldown — higher is slower
|
||||
bleed: 0, // regen
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StatusKind, string> = {
|
||||
slow: 'mired',
|
||||
suppress: 'stifled',
|
||||
bleed: 'unknitting',
|
||||
}
|
||||
|
||||
export type UnitKind =
|
||||
| 'rusher' | 'stalker' | 'tank' | 'bomber' | 'splitter' | 'artillery' | 'warden'
|
||||
export type BossKind = 'colossus' | 'hivemind' | 'leech'
|
||||
@ -38,6 +54,10 @@ export interface EnemySpec {
|
||||
readonly spawnKind: UnitKind
|
||||
/** 0 disables on-death splitting. */
|
||||
readonly splitCount: number
|
||||
/** Status inflicted on contact (or on projectile hit). Inert when duration is 0. */
|
||||
readonly debuff: StatusKind
|
||||
/** 0 means this kind inflicts nothing — `applyStatus` becomes a no-op. */
|
||||
readonly debuffDuration: number
|
||||
}
|
||||
|
||||
const spec = (s: EnemySpec): EnemySpec => s
|
||||
@ -49,6 +69,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
stalker: spec({
|
||||
name: 'Stalker', cost: 24, hp: 20, speed: 110, radius: 10,
|
||||
@ -56,6 +77,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 7, projSpeed: 260, fireRange: 250, fireCooldown: 1.6, keepDistance: 200,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
tank: spec({
|
||||
name: 'Tank', cost: 45, hp: 120, speed: 55, radius: 18,
|
||||
@ -63,6 +85,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
bomber: spec({
|
||||
name: 'Bomber', cost: 30, hp: 16, speed: 140, radius: 10,
|
||||
@ -70,6 +93,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: true, blastRadius: 70, blastDamage: 22, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
splitter: spec({
|
||||
name: 'Splitter', cost: 34, hp: 30, speed: 95, radius: 13,
|
||||
@ -77,6 +101,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 3,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
artillery: spec({
|
||||
name: 'Artillery', cost: 52, hp: 26, speed: 70, radius: 12,
|
||||
@ -84,6 +109,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 13, projSpeed: 190, fireRange: 460, fireCooldown: 2.6, keepDistance: 420,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'suppress', debuffDuration: 2.5,
|
||||
}),
|
||||
warden: spec({
|
||||
name: 'Warden', cost: 40, hp: 34, speed: 215, radius: 8,
|
||||
@ -91,6 +117,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 2.0,
|
||||
}),
|
||||
colossus: spec({
|
||||
name: 'Colossus', cost: 150, hp: 700, speed: 40, radius: 34,
|
||||
@ -98,6 +125,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
hivemind: spec({
|
||||
name: 'Hivemind', cost: 220, hp: 420, speed: 60, radius: 28,
|
||||
@ -105,6 +133,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 2.5,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'slow', debuffDuration: 0,
|
||||
}),
|
||||
leech: spec({
|
||||
name: 'Leech', cost: 190, hp: 500, speed: 75, radius: 30,
|
||||
@ -112,6 +141,7 @@ export const ENEMY_SPECS: Record<EnemyKind, EnemySpec> = {
|
||||
projDamage: 0, projSpeed: 0, fireRange: 0, fireCooldown: 0, keepDistance: 0,
|
||||
detonates: false, blastRadius: 0, blastDamage: 0, spawnEvery: 0,
|
||||
spawnKind: 'rusher', splitCount: 0,
|
||||
debuff: 'bleed', debuffDuration: 3.5,
|
||||
}),
|
||||
} as const
|
||||
|
||||
@ -145,6 +175,8 @@ export interface Survivor {
|
||||
xp: number
|
||||
xpNext: number
|
||||
kills: number
|
||||
/** Seconds remaining per status. A finite-union Record, so indexing stays `number`. */
|
||||
statuses: Record<StatusKind, number>
|
||||
}
|
||||
|
||||
export interface Enemy {
|
||||
@ -165,6 +197,9 @@ export interface Projectile {
|
||||
radius: number
|
||||
damage: number
|
||||
ttl: number
|
||||
/** Carried on the shot itself so a hit needs no lookup of a possibly-dead firer. */
|
||||
debuff: StatusKind
|
||||
debuffDuration: number
|
||||
}
|
||||
|
||||
export interface DirectorState {
|
||||
|
||||
@ -24,8 +24,14 @@ describe('draw', () => {
|
||||
const ctx = makeCtxStub()
|
||||
const s = fresh()
|
||||
s.projectiles.push(
|
||||
{ id: 1, from: 'survivor', pos: { x: 10, y: 10 }, vel: { x: 0, y: 0 }, radius: 4, damage: 1, ttl: 1 },
|
||||
{ id: 2, from: 'enemy', pos: { x: 20, y: 20 }, vel: { x: 0, y: 0 }, radius: 5, damage: 1, ttl: 1 },
|
||||
{
|
||||
id: 1, from: 'survivor', pos: { x: 10, y: 10 }, vel: { x: 0, y: 0 },
|
||||
radius: 4, damage: 1, ttl: 1, debuff: 'slow', debuffDuration: 0,
|
||||
},
|
||||
{
|
||||
id: 2, from: 'enemy', pos: { x: 20, y: 20 }, vel: { x: 0, y: 0 },
|
||||
radius: 5, damage: 1, ttl: 1, debuff: 'slow', debuffDuration: 0,
|
||||
},
|
||||
)
|
||||
draw(ctx, s)
|
||||
expect(ctx.fill).toHaveBeenCalledTimes(BASE.fill + 2)
|
||||
@ -61,3 +67,19 @@ describe('draw', () => {
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(BASE.arc + 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status halo', () => {
|
||||
it('is absent for an unafflicted survivor', () => {
|
||||
const ctx = makeCtxStub()
|
||||
draw(ctx, fresh())
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke)
|
||||
})
|
||||
|
||||
it('draws one extra ring when a status is live', () => {
|
||||
const ctx = makeCtxStub()
|
||||
const s = fresh()
|
||||
s.survivor.statuses.slow = 1.5
|
||||
draw(ctx, s)
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(BASE.stroke + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FIRE_RANGE, SURVIVOR_RADIUS } from '../core/sim'
|
||||
import type { EnemyKind, GameState } from '../core/types'
|
||||
import { ARENA, ENEMY_SPECS } from '../core/types'
|
||||
import { ARENA, ENEMY_SPECS, STATUS_ORDER } from '../core/types'
|
||||
|
||||
/** Structural subset of CanvasRenderingContext2D — stubbable in tests. */
|
||||
export interface Ctx2D {
|
||||
@ -112,4 +112,13 @@ export const draw = (ctx: Ctx2D, state: GameState): void => {
|
||||
-Math.PI / 2, -Math.PI / 2 + (Math.max(0, sv.hp) / sv.maxHp) * Math.PI * 2,
|
||||
)
|
||||
ctx.stroke()
|
||||
|
||||
// Afflicted halo. Gated so an unafflicted survivor draws exactly as before.
|
||||
const afflicted = STATUS_ORDER.some((kind) => sv.statuses[kind] > 0)
|
||||
if (afflicted) {
|
||||
ctx.strokeStyle = COLORS.arcane
|
||||
ctx.lineWidth = 2
|
||||
circle(ctx, sv.pos.x, sv.pos.y, SURVIVOR_RADIUS + 10)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,6 +143,29 @@ body {
|
||||
|
||||
.feed li::before { content: "▲ "; color: var(--bone-dim); }
|
||||
|
||||
.status-chips {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--font-data);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentcolor;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chip-slow { color: var(--arcane); }
|
||||
.chip-suppress { color: var(--blood); }
|
||||
.chip-bleed { color: var(--bone-dim); }
|
||||
|
||||
.spawnbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@ -41,6 +41,13 @@ export const Hud = ({ snap, onRestart }: Props): ReactElement => {
|
||||
<p className="hud-num">
|
||||
{Math.ceil(snap.survivorHp)} hp · level {snap.survivorLevel} · {snap.kills} of yours slain
|
||||
</p>
|
||||
<ul className="status-chips">
|
||||
{snap.statuses.map((c) => (
|
||||
<li key={`st-${c.kind}`} className={`chip chip-${c.kind}`}>
|
||||
{c.label} {c.seconds}s
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="feed">
|
||||
{snap.upgrades.map((u) => (
|
||||
<li key={`up-${String(u.n)}`}>{UPGRADE_LABELS[u.id]}</li>
|
||||
|
||||
@ -21,6 +21,24 @@ describe('Hud', () => {
|
||||
expect(screen.getByText('200 / 400')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists live status chips and omits idle ones', () => {
|
||||
const snap = snapWith((s) => {
|
||||
s.survivor.statuses.slow = 1.2
|
||||
s.survivor.statuses.bleed = 3
|
||||
})
|
||||
const { container } = render(<Hud snap={snap} onRestart={() => undefined} />)
|
||||
const chips = container.querySelectorAll('.status-chips li')
|
||||
expect(chips).toHaveLength(2)
|
||||
expect(screen.getByText(/mired/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/unknitting/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/stifled/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no status chips for an unafflicted survivor', () => {
|
||||
const { container } = render(<Hud snap={snapWith()} onRestart={() => undefined} />)
|
||||
expect(container.querySelectorAll('.status-chips li')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports the intruder and its recent upgrades', () => {
|
||||
const snap = snapWith((s) => {
|
||||
s.survivor.hp = 50
|
||||
|
||||
Loading…
Reference in New Issue
Block a user