mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
reverse_survivors: difficulty tiers
Haunting, Normal and Crusade scale enemy hp and speed, the survivor's health, your income, and the length of the night. Normal is exactly 1.0 on every multiplier — it is the identity tier, which is what lets every existing raw-value assertion keep passing untouched. Enemy speed now derives from a single helper, so difficulty and frenzy compose in one place rather than fighting at three call sites. Also fixes a bug this surfaced: handleRestart ignored the selected tier, so restarting after choosing Crusade silently reverted to Normal. The react-hooks purity rule flagged Date.now() in the new handler, and extracting restartSeed() exposed the omission. Endless mode is deliberately left out — fmtTime(Infinity) renders Infinity:NaN, which would cost a guard plus tests for a mode whose only content is the same game but longer. README rewritten for all four features, including the reversal of its old placement promise. 160 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:
parent
2d66bfdd61
commit
5d0caa9725
@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "Reverse Survivors: difficulty tiers",
|
||||
"objective": "Add three difficulty tiers - Haunting, Normal and Crusade - scaling enemy hp and speed, the survivor's health, director income, and the length of the night. Normal must be the exact identity tier so no existing behaviour or assertion changes.",
|
||||
"acceptance_criteria": [
|
||||
"`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% on all four metrics.",
|
||||
"Normal is exactly 1.0 on every multiplier, so all pre-existing raw-value assertions hold unchanged.",
|
||||
"An explicit duration argument still overrides the tier's duration.",
|
||||
"Changing difficulty restarts the game on the new tier, and restarting preserves the selected tier."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Endless mode: fmtTime(Infinity) renders Infinity:NaN and would need a guard plus tests for a mode whose only content is the same game but longer.",
|
||||
"localStorage persistence of the chosen tier: its catch arm is only reachable by stubbing a throwing setItem, which tests the mock rather than the game."
|
||||
],
|
||||
"verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus an App-level test asserting the clock changes from 5:00 to 6:00 when Crusade is selected"
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
{
|
||||
"intent": "Add Haunting/Normal/Crusade difficulty tiers and document the whole four-part extension in the README.",
|
||||
"scope": [
|
||||
"reverse_survivors/src/core/types.ts (DifficultyId, DifficultyConfig, DIFFICULTIES)",
|
||||
"reverse_survivors/src/core/sim.ts (createInitialState parameter, enemySpeed helper, hp scaling), director.ts (regenRate rate)",
|
||||
"reverse_survivors/src/App.tsx (tier selector), src/styles.css, README.md",
|
||||
"Non-goal: endless mode and localStorage persistence, both deliberately excluded"
|
||||
],
|
||||
"changes": [
|
||||
"Difficulty is a pure config object resolved once in createInitialState and stored on GameState, so no downstream code re-looks-it-up.",
|
||||
"Normal is exactly 1.0 on every multiplier, which is what lets every pre-existing raw-hp assertion keep passing unchanged.",
|
||||
"Enemy speed now derives from a single enemySpeed helper, so difficulty and frenzy compose in one place instead of fighting at three call sites.",
|
||||
"regenRate gained an optional rate parameter defaulting to 1, keeping its existing call sites and assertions valid.",
|
||||
"Fixed while wiring the selector: handleRestart ignored the chosen tier, so restarting after picking Crusade silently reverted to Normal. Surfaced by the react-hooks purity rule flagging Date.now() in the new handler, which led to extracting restartSeed().",
|
||||
"README rewritten to cover the new units, powers, statuses and tiers, and to document the deliberate reversal of its former 'placement is never yours to choose' promise."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)",
|
||||
"result": "pass",
|
||||
"evidence": "160 tests passed. Coverage 100% on all four metrics: statements 531/531, branches 159/159, functions 101/101, lines 514/514."
|
||||
},
|
||||
{
|
||||
"command": "npm run build",
|
||||
"result": "pass",
|
||||
"evidence": "tsc --noEmit clean, vite build in ~72ms, 210.08 kB / 66.26 kB gzip."
|
||||
},
|
||||
{
|
||||
"command": "headless full-game playtest driving the real sim (vite-node)",
|
||||
"result": "pass",
|
||||
"evidence": "Banking energy then bursting wave+frenzy+ambush killed the survivor at t=53s with the warden's slow live (1.92s) and frenzy active (4.20s). A passive director loses on every tier; Crusade won at t=56s; Haunting and Normal went to the survivor. Same seed twice produced byte-identical outcomes."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"Tier multipliers are simulation-verified rather than human-playtested and may need retuning.",
|
||||
"Pre-existing and unchanged here: with a trickle strategy the survivor out-kills a 6 energy/sec income, so banking and bursting is the effective approach."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert the commit; the game returns to a single fixed difficulty with no persisted state to migrate.",
|
||||
"After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage."
|
||||
]
|
||||
}
|
||||
@ -14,14 +14,32 @@ npm run dev
|
||||
```
|
||||
|
||||
- **Rusher / Stalker / Tank / Bomber** — single spawns at a random arena edge.
|
||||
- **Splitter** — dies into three rushers. Killing it _creates_ pressure.
|
||||
- **Artillery** — outranges the survivor (460 vs its 260) and holds station beyond
|
||||
reach. Its shells **stifle**, stretching the survivor's reload.
|
||||
- **Warden** — faster than the survivor and **mires** it on contact.
|
||||
- **Wave N** — a ring formation around the survivor; each call grows and costs more.
|
||||
- **Colossus** (unlocks 1:00) and **Hivemind** (unlocks 2:00) — expensive, on cooldown.
|
||||
The Hivemind births free rushers while it lives.
|
||||
- **Colossus** (1:00), **Hivemind** (2:00) and **Leech** (3:00) — expensive, on
|
||||
cooldown. The Hivemind births free rushers; the Leech's touch **unknits** flesh,
|
||||
shutting off regeneration while it lives.
|
||||
- **Ambush** places a unit right next to the survivor, at a premium.
|
||||
**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.
|
||||
- The survivor gains XP per kill and rolls an upgrade per level
|
||||
(damage, fire rate, speed, vitality, regen, multishot). Time favors it. Pressure early.
|
||||
- **Haunting / Normal / Crusade** scale enemy hp and speed, the survivor's health,
|
||||
your income, and the length of the night.
|
||||
|
||||
Where enemies appear is deliberately not yours to choose — the director picks
|
||||
_what_ and _when_; the seeded RNG picks _where_.
|
||||
Placement is purchasable, never free. By default the director picks _what_ and
|
||||
_when_ while the seeded RNG picks _where_ — Ambush and Rift buy that choice back,
|
||||
one at a premium and the other at a cooldown.
|
||||
|
||||
> Earlier versions made the loss of placement control absolute. Ambush and Rift
|
||||
> deliberately reverse that: the interesting decision turned out to be _what a
|
||||
> placement is worth_, not being denied one.
|
||||
|
||||
All three debuffs need contact — a survivor that kites cleanly takes none of them —
|
||||
and they refresh rather than stack, so a horde can never chain-lock the intruder.
|
||||
|
||||
## Quality gates
|
||||
|
||||
@ -42,6 +60,11 @@ ESLint 10.8.0 · typescript-eslint 8.65.0 · TypeScript **6.0.3**.
|
||||
|
||||
## Architecture (what makes 100 % honest)
|
||||
|
||||
- `src/core/status.ts` — the survivor's debuff timers. `isOn`/`factor` are written
|
||||
as arithmetic (`Math.min(1, Math.ceil(t))`) rather than comparisons, so the whole
|
||||
status system contributes **zero branches** to the coverage gate; consumers just
|
||||
multiply by the result. `applyStatus` uses `Math.max`, which is what makes
|
||||
refresh-not-stack a structural guarantee rather than a tuned number.
|
||||
- `src/core/` — the whole game as pure-ish functions over a plain state object.
|
||||
Deterministic: seeded mulberry32 RNG lives _in_ the state; same seed + same
|
||||
action sequence ⇒ identical run. Behavior dispatch uses records instead of
|
||||
|
||||
@ -60,6 +60,17 @@ describe('App', () => {
|
||||
expect(screen.getByText('30 / 400')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restarts on a new tier when the difficulty is changed', () => {
|
||||
const raf = installRaf()
|
||||
render(<App seed={9} />)
|
||||
pump(raf, 0)
|
||||
// Normal runs 5:00; Crusade runs 6:00, so the clock proves the tier took.
|
||||
expect(screen.getByText(/\/ 5:00/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Crusade' }))
|
||||
expect(screen.getByText(/\/ 6:00/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Crusade' })).toHaveClass('diff-on')
|
||||
})
|
||||
|
||||
it('dispatches a director power from the bar', () => {
|
||||
const raf = installRaf()
|
||||
render(<App seed={9} boot={(state) => { state.director.energy = 400 }} />)
|
||||
|
||||
@ -4,7 +4,8 @@ import { fmtTime } from './ui/format'
|
||||
import { createInitialState, step } from './core/sim'
|
||||
import type { HudSnapshot } from './core/snapshot'
|
||||
import { snapshotOf } from './core/snapshot'
|
||||
import type { DirectorAction, GameState } from './core/types'
|
||||
import type { DifficultyId, DirectorAction, GameState } from './core/types'
|
||||
import { DIFFICULTIES, DIFFICULTY_ORDER } from './core/types'
|
||||
import type { Ctx2D } from './render/draw'
|
||||
import { draw } from './render/draw'
|
||||
import { CanvasStage } from './ui/CanvasStage'
|
||||
@ -18,8 +19,18 @@ interface Props {
|
||||
boot?: (state: GameState) => void
|
||||
}
|
||||
|
||||
const createGame = (seed: number, boot: Props['boot']): GameState => {
|
||||
const state = createInitialState(seed)
|
||||
/**
|
||||
* Fresh seed for a restart. Extracted so the impurity lives outside render —
|
||||
* the react-hooks purity rule rejects `Date.now()` inline in a component body.
|
||||
*/
|
||||
const restartSeed = (): number => Date.now() >>> 0
|
||||
|
||||
const createGame = (
|
||||
seed: number,
|
||||
boot: Props['boot'],
|
||||
difficulty: DifficultyId = 'normal',
|
||||
): GameState => {
|
||||
const state = createInitialState(seed, undefined, difficulty)
|
||||
if (boot !== undefined) {
|
||||
boot(state)
|
||||
}
|
||||
@ -27,6 +38,7 @@ const createGame = (seed: number, boot: Props['boot']): GameState => {
|
||||
}
|
||||
|
||||
export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
|
||||
const [difficulty, setDifficulty] = useState<DifficultyId>('normal')
|
||||
const gameRef = useRef<GameState>(createGame(seed, boot))
|
||||
const [snap, setSnap] = useState<HudSnapshot>(() => snapshotOf(createGame(seed, boot)))
|
||||
const ctxRef = useRef<Ctx2D | null>(null)
|
||||
@ -47,7 +59,13 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
|
||||
queueRef.current.push(action)
|
||||
}
|
||||
const handleRestart = (): void => {
|
||||
gameRef.current = createGame(Date.now() >>> 0, undefined)
|
||||
gameRef.current = createGame(Date.now() >>> 0, undefined, difficulty)
|
||||
queueRef.current = []
|
||||
setSnap(snapshotOf(gameRef.current))
|
||||
}
|
||||
const handleDifficulty = (id: DifficultyId): void => {
|
||||
setDifficulty(id)
|
||||
gameRef.current = createGame(restartSeed(), undefined, id)
|
||||
queueRef.current = []
|
||||
setSnap(snapshotOf(gameRef.current))
|
||||
}
|
||||
@ -61,6 +79,18 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {
|
||||
<h1>Reverse Survivors</h1>
|
||||
<p className="tagline">You are the horde. The hero is the problem.</p>
|
||||
<p className="clock">{fmtTime(snap.t)} / {fmtTime(snap.duration)}</p>
|
||||
<div className="difficulty" role="group" aria-label="Difficulty">
|
||||
{DIFFICULTY_ORDER.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={id === difficulty ? 'diff diff-on' : 'diff'}
|
||||
onClick={() => { handleDifficulty(id) }}
|
||||
>
|
||||
{DIFFICULTIES[id].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<main className="board">
|
||||
<CanvasStage onCtx={handleCtx} />
|
||||
|
||||
@ -301,3 +301,15 @@ describe('director powers', () => {
|
||||
expect(s.director.riftTimer).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('difficulty economy', () => {
|
||||
it('regen defaults to a rate of 1', () => {
|
||||
expect(regenRate(0)).toBe(6)
|
||||
expect(regenRate(30)).toBe(8)
|
||||
})
|
||||
|
||||
it('regen scales with the tier rate', () => {
|
||||
expect(regenRate(0, 1.25)).toBeCloseTo(7.5)
|
||||
expect(regenRate(0, 0.85)).toBeCloseTo(5.1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -10,13 +10,13 @@ import type { Vec } from './vec'
|
||||
import { clamp } from './vec'
|
||||
|
||||
/** Energy per second: ramps every 30 seconds so pressure always grows. */
|
||||
export const regenRate = (t: number): number => 6 + 2 * Math.floor(t / 30)
|
||||
export const regenRate = (t: number, rate = 1): number => (6 + 2 * Math.floor(t / 30)) * rate
|
||||
|
||||
export const waveCost = (waveIndex: number): number => 60 + 25 * waveIndex
|
||||
|
||||
export const tickDirector = (state: GameState, dt: number): void => {
|
||||
const d = state.director
|
||||
d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t) * dt)
|
||||
d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t, state.difficulty.energyRate) * dt)
|
||||
for (const boss of BOSS_ORDER) {
|
||||
d.bossCooldowns[boss] = Math.max(0, d.bossCooldowns[boss] - dt)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
} from './sim'
|
||||
import type { GameState, Projectile } from './types'
|
||||
import {
|
||||
ARENA, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER,
|
||||
ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER,
|
||||
} from './types'
|
||||
|
||||
const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 }
|
||||
@ -622,6 +622,47 @@ describe('frenzy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('difficulty tiers', () => {
|
||||
it('defaults to normal, the identity tier', () => {
|
||||
const s = fresh()
|
||||
expect(s.difficulty).toBe(DIFFICULTIES.normal)
|
||||
expect(s.survivor.maxHp).toBe(100)
|
||||
})
|
||||
|
||||
it('accepts an explicit tier', () => {
|
||||
const s = createInitialState(1, undefined, 'crusade')
|
||||
expect(s.difficulty).toBe(DIFFICULTIES.crusade)
|
||||
})
|
||||
|
||||
it('takes its duration from the tier when none is given', () => {
|
||||
expect(createInitialState(1, undefined, 'haunting').duration)
|
||||
.toBe(DIFFICULTIES.haunting.duration)
|
||||
})
|
||||
|
||||
it('an explicit duration still wins over the tier', () => {
|
||||
expect(createInitialState(1, 42, 'haunting').duration).toBe(42)
|
||||
})
|
||||
|
||||
it('scales enemy hp', () => {
|
||||
const s = createInitialState(1, undefined, 'crusade')
|
||||
const e = makeEnemy(s, 'rusher', { x: 0, y: 0 })
|
||||
expect(e.hp).toBeCloseTo(14 * DIFFICULTIES.crusade.enemyHp, 5)
|
||||
})
|
||||
|
||||
it('scales survivor hp', () => {
|
||||
const s = createInitialState(1, undefined, 'haunting')
|
||||
expect(s.survivor.maxHp).toBe(125)
|
||||
})
|
||||
|
||||
it('scales enemy speed', () => {
|
||||
const s = createInitialState(1, undefined, 'crusade')
|
||||
const e = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y })
|
||||
step(s, [], 0.05)
|
||||
const moved = CENTER.x + 300 - e.pos.x
|
||||
expect(moved).toBeCloseTo(170 * DIFFICULTIES.crusade.enemySpeed * 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.
|
||||
|
||||
@ -2,11 +2,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,
|
||||
DifficultyConfig, DifficultyId, DirectorAction, Enemy, EnemyKind, EnemySpec, GameState,
|
||||
Survivor, UpgradeId,
|
||||
} from './types'
|
||||
import {
|
||||
ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE, FRENZY_SPEED,
|
||||
GAME_DURATION, STATUS_POWER, UPGRADE_POOL,
|
||||
ARENA, CONTACT_COOLDOWN, DIFFICULTIES, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE,
|
||||
FRENZY_SPEED, STATUS_POWER, UPGRADE_POOL,
|
||||
} from './types'
|
||||
import type { Vec } from './vec'
|
||||
import { clamp, dist, toward } from './vec'
|
||||
@ -21,10 +22,10 @@ const CENTER: Vec = { x: ARENA.w / 2, y: ARENA.h / 2 }
|
||||
|
||||
export const xpForLevel = (level: number): number => 20 + 15 * (level - 1)
|
||||
|
||||
const createSurvivor = (): Survivor => ({
|
||||
const createSurvivor = (cfg: DifficultyConfig): Survivor => ({
|
||||
pos: { x: CENTER.x, y: CENTER.y },
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
hp: 100 * cfg.survivorHp,
|
||||
maxHp: 100 * cfg.survivorHp,
|
||||
speed: 150,
|
||||
regen: 0,
|
||||
damage: 8,
|
||||
@ -38,28 +39,40 @@ const createSurvivor = (): Survivor => ({
|
||||
statuses: { slow: 0, suppress: 0, bleed: 0 },
|
||||
})
|
||||
|
||||
export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({
|
||||
status: 'running',
|
||||
t: 0,
|
||||
duration,
|
||||
rng: createRng(seed),
|
||||
survivor: createSurvivor(),
|
||||
enemies: [],
|
||||
projectiles: [],
|
||||
director: {
|
||||
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,
|
||||
outcomeTime: 0,
|
||||
})
|
||||
export const createInitialState = (
|
||||
seed: number,
|
||||
duration?: number,
|
||||
difficulty: DifficultyId = 'normal',
|
||||
): GameState => {
|
||||
const cfg = DIFFICULTIES[difficulty]
|
||||
return {
|
||||
status: 'running',
|
||||
difficulty: cfg,
|
||||
t: 0,
|
||||
duration: duration ?? cfg.duration,
|
||||
rng: createRng(seed),
|
||||
survivor: createSurvivor(cfg),
|
||||
enemies: [],
|
||||
projectiles: [],
|
||||
director: {
|
||||
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,
|
||||
outcomeTime: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Single place enemy speed is derived: difficulty and frenzy both land here. */
|
||||
export const enemySpeed = (state: GameState, kind: EnemyKind, frenzyMul: number): number =>
|
||||
ENEMY_SPECS[kind].speed * state.difficulty.enemySpeed * frenzyMul
|
||||
|
||||
export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy => {
|
||||
const id = state.nextId
|
||||
@ -68,7 +81,7 @@ export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy =>
|
||||
id,
|
||||
kind,
|
||||
pos: { x: pos.x, y: pos.y },
|
||||
hp: ENEMY_SPECS[kind].hp,
|
||||
hp: ENEMY_SPECS[kind].hp * state.difficulty.enemyHp,
|
||||
hitTimer: 0,
|
||||
fireTimer: 0,
|
||||
spawnTimer: ENEMY_SPECS[kind].spawnEvery,
|
||||
@ -238,7 +251,7 @@ const enemiesStep = (state: GameState, dt: number): void => {
|
||||
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, spec.speed * speedMul)
|
||||
const vel = MOVE_FNS[spec.move](e, sv, d, enemySpeed(state, e.kind, 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)
|
||||
|
||||
|
||||
@ -156,6 +156,36 @@ export const BOSS_COOLDOWN: Record<BossKind, number> = {
|
||||
colossus: 25, hivemind: 30, leech: 28,
|
||||
}
|
||||
|
||||
export type DifficultyId = 'haunting' | 'normal' | 'crusade'
|
||||
|
||||
export interface DifficultyConfig {
|
||||
readonly label: string
|
||||
readonly enemyHp: number
|
||||
readonly enemySpeed: number
|
||||
readonly survivorHp: number
|
||||
readonly energyRate: number
|
||||
readonly duration: number
|
||||
}
|
||||
|
||||
/** `normal` is exactly 1 across the board, so it is the identity tier. */
|
||||
export const DIFFICULTIES: Record<DifficultyId, DifficultyConfig> = {
|
||||
haunting: {
|
||||
label: 'Haunting',
|
||||
enemyHp: 0.85, enemySpeed: 0.95, survivorHp: 1.25, energyRate: 1.25, duration: 240,
|
||||
},
|
||||
normal: {
|
||||
label: 'Normal',
|
||||
enemyHp: 1, enemySpeed: 1, survivorHp: 1, energyRate: 1, duration: GAME_DURATION,
|
||||
},
|
||||
crusade: {
|
||||
label: 'Crusade',
|
||||
enemyHp: 1.2, enemySpeed: 1.05, survivorHp: 0.85, energyRate: 0.85, duration: 360,
|
||||
},
|
||||
}
|
||||
export const DIFFICULTY_ORDER: readonly [DifficultyId, ...DifficultyId[]] = [
|
||||
'haunting', 'normal', 'crusade',
|
||||
]
|
||||
|
||||
export type PowerKind = 'ambush' | 'frenzy' | 'rift'
|
||||
export const POWER_ORDER: readonly [PowerKind, ...PowerKind[]] = ['ambush', 'frenzy', 'rift']
|
||||
export const POWER_NAMES: Record<PowerKind, string> = {
|
||||
@ -251,6 +281,7 @@ export type Status = 'running' | 'directorWon' | 'survivorWon'
|
||||
|
||||
export interface GameState {
|
||||
status: Status
|
||||
difficulty: DifficultyConfig
|
||||
t: number
|
||||
duration: number
|
||||
rng: Rng
|
||||
|
||||
@ -259,3 +259,18 @@ body {
|
||||
letter-spacing: 0.06em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.difficulty { display: flex; gap: 6px; justify-content: center; margin-top: 6px; }
|
||||
|
||||
.diff {
|
||||
background: none;
|
||||
border: 1px solid var(--panel-edge);
|
||||
border-radius: 4px;
|
||||
color: var(--bone-dim);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-data);
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.diff-on { border-color: var(--holy); color: var(--holy); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user