diff --git a/AGENTS.md b/AGENTS.md index 40b0ed3..9b52e46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,8 +81,7 @@ Determinism caveat: tier-2 fallbacks must stay seed-deterministic so saves remai | `sw.js` | Production service worker (plain JS) | | `sw-dev.js` | Development service worker (plain JS) | | `tsconfig.json` | Main build config (strict, ESNext, dist/ output) | -| `tsconfig.tests.json` | Test type-checking (extends main) | -| `tsconfig.test-build.json` | Test transpile-only build (noCheck: true) | +| `tsconfig.tests.json` | Strict, type-check-only config for `tests/` | ## Event Reference @@ -151,8 +150,11 @@ UpdateNotification (dispatched by component) ## Testing -- **Unit tests:** `npm test` — runs `typecheck` (main config) → `build:tests` (transpile-only) → `node --test "dist/tests/**/*.test.js"`. -- **Type-check tests only:** `npm run typecheck:tests` — currently has residual errors from intentionally loose test stubs; deferred. +- **Unit tests:** `npm test` — strictly type-checks app and tests, then runs the + `.test.ts` files directly under Node 24's built-in type stripping. The loader + in `tests/support/` maps runtime `.js` specifiers back to TypeScript source, so + test artifacts are never emitted into `dist/`. +- **Type-check tests only:** `npm run typecheck:tests`. - **Browser:** Use @Browser at `http://localhost:8099` (assume server is already running). Verify UI, interactions, console, service worker. ## Checklist diff --git a/package.json b/package.json index 69e01eb..bbdcb6c 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,9 @@ "dev:assets": "chokidar \"*.html\" \"main.css\" \"manifest.json\" \"icon.svg\" \"sw*.js\" \"icons/**\" \"images/**\" \"fonts/**\" \"debug/*.html\" -c \"node scripts/copy-assets.mjs\" --initial", "dev:serve": "live-server dist --no-browser --port=8099", "build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json", "typecheck:tests": "tsc -p tsconfig.tests.json", - "build:tests": "tsc -p tsconfig.test-build.json", - "test": "npm run typecheck && npm run build:tests && node --test \"dist/tests/**/*.test.js\"", + "test": "npm run typecheck && node --import ./tests/support/loader.mjs --test \"tests/**/*.test.ts\"", "lint": "oxlint", "lint:fix": "oxlint --fix", "format": "prettier --write .", @@ -25,6 +24,9 @@ }, "author": "Rylee Corradini", "license": "GPL-3.0", + "engines": { + "node": ">=24" + }, "devDependencies": { "@types/node": "^25.8.0", "chokidar-cli": "^3.0.0", diff --git a/src/game/Combat.ts b/src/game/Combat.ts index 14ff6c0..9c57229 100644 --- a/src/game/Combat.ts +++ b/src/game/Combat.ts @@ -24,7 +24,6 @@ import type { DamageResolution, MeleeAttackResult, RangedAttackResult } from '../types.js'; import type { Entity } from './Entity.js'; import type { World } from './World.js'; -import type { Rng } from '../rng.js'; import { AP_COST, BASE_HIT_CHANCE, @@ -59,6 +58,11 @@ export type ResolveMeleeOptions = { coverDodgeBonus?: number; }; +/** Minimal deterministic random source required by combat resolution. */ +export type CombatRng = { + next: () => number; +}; + function attackerRangedDamage(attacker: Entity, override?: number): number { if (override !== undefined) return override; const rangedAttackDamage = (attacker as { rangedAttackDamage?: () => number }).rangedAttackDamage; @@ -127,7 +131,7 @@ export function resolveRanged( world: World, attacker: Entity, target: Entity, - rng: Rng, + rng: CombatRng, options: ResolveRangedOptions = {} ): RangedAttackResult { const check = canFireRanged(world, attacker, target, options); @@ -258,7 +262,7 @@ export function resolveMelee( world: World, attacker: Entity, target: Entity, - rng: Rng, + rng: CombatRng, options: ResolveMeleeOptions = {} ): MeleeAttackResult { const check = canMelee(world, attacker, target); diff --git a/src/game/Entity.ts b/src/game/Entity.ts index e77b588..356fa48 100644 --- a/src/game/Entity.ts +++ b/src/game/Entity.ts @@ -72,6 +72,8 @@ export class Entity { shieldHp: number; damageReduction: number; alive: boolean; + /** Salvage carried by a defeated entity; populated when the run resolves a kill. */ + loot?: { salvage: TypedSalvage }; /** * P3.5.M1: generic timed status-effect channel. Maps an effect id * (`STATUS_EFFECT.*`) to the number of this entity's own `refreshAp()` @@ -394,11 +396,13 @@ type LabelableEntity = { callsign?: string | null; displayName?: string; principalTag?: string; - maxAp: number; - hp: number; - maxHp: number; - shieldHp: number; + maxAp?: number; + hp?: number; + maxHp?: number; + shieldHp?: number; }; +type LabelableEntityWithStats = LabelableEntity & + Required>; /** * Player-facing label for an entity, in priority order: @@ -408,9 +412,14 @@ type LabelableEntity = { * 3. Legacy `[Faction]Kind` from the id prefix (e.g. `[Corp]Drone`, * `[Neutral]Civilian`, `Turret`) — un-aliased entities and pre-2.9 saves. */ +export function entityLabel(entity: LabelableEntity): string; +export function entityLabel(entity: LabelableEntityWithStats, showStats: true): string; +export function entityLabel(entity: LabelableEntityWithStats, showStats: boolean): string; export function entityLabel(entity: LabelableEntity, showStats: boolean = false): string { if (entity.callsign) return entity.callsign; - const stats = `${entity.hp}/${entity.maxHp} HP, ${entity.maxAp} AP${entity.shieldHp > 0 ? `, ${entity.shieldHp} shield` : ''}`; + const stats = showStats + ? `${entity.hp}/${entity.maxHp} HP, ${entity.maxAp} AP${(entity.shieldHp ?? 0) > 0 ? `, ${entity.shieldHp} shield` : ''}` + : ''; if (entity.displayName) { const tag = entity.principalTag ? `[${entity.principalTag}]` : ''; return `${tag}${entity.displayName}${showStats ? ` (${stats})` : ''}`; diff --git a/src/game/Turret.ts b/src/game/Turret.ts index 5c49c52..ecb293e 100644 --- a/src/game/Turret.ts +++ b/src/game/Turret.ts @@ -3,6 +3,7 @@ import { Hostile } from './Hostile.js'; import { FACTION, TURRET_MAX_HP, TURRET_RANGE, TURRET_DAMAGE } from './constants.js'; import { resolveRanged, canFireRanged } from './Combat.js'; import type { EntityInit } from './Entity.js'; +import type { FactionId } from './constants.js'; import type { World } from './World.js'; import type { Rng } from '../rng.js'; @@ -36,6 +37,7 @@ type TurretAutoFireFireResult = { type: 'fire'; target: Entity; result: ReturnType; + reason?: never; }; type TurretAutoFireIdleResult = { type: 'idle'; @@ -44,7 +46,9 @@ type TurretAutoFireIdleResult = { export type TurretAutoFireResult = TurretAutoFireFireResult | TurretAutoFireIdleResult; export type TurretAnnotatedResult = { turret: Turret; action: TurretAutoFireResult }; -export interface TurretInit extends EntityInit { +export interface TurretInit extends Omit { + /** Accepted for snapshot/factory compatibility; turrets are always player-aligned. */ + faction?: FactionId; range?: number; attackDamage?: number; ownerId?: string | null; diff --git a/src/game/Vision.ts b/src/game/Vision.ts index ceac90b..7227c0b 100644 --- a/src/game/Vision.ts +++ b/src/game/Vision.ts @@ -16,6 +16,7 @@ import { hasLineOfSight } from './LineOfSight.js'; import { SIGHT_RANGE } from './constants.js'; import { coordKey } from './mapConnectivity.js'; import type { Entity } from './Entity.js'; +import type { GridPoint } from '../types.js'; import type { Grid } from './Grid.js'; type RecomputeOptions = { @@ -48,7 +49,7 @@ export class VisionField { * listener when `killed` is true and the corpse tile is currently visible. * Cleared with {@link resetFogState} when a new combat episode starts. */ - memoriseCorpse(entity: Entity) { + memoriseCorpse(entity: Pick) { const k = coordKey(entity.x, entity.y); this.memorisedCorpses.set(k, { x: entity.x, @@ -104,7 +105,7 @@ export class VisionField { * on `entity:moved` for non-player factions, so a drone walking into LOS * becomes visible without waiting for the player to step. */ - recompute(grid: Grid, viewer: Entity, range = SIGHT_RANGE, options: RecomputeOptions = {}) { + recompute(grid: Grid, viewer: GridPoint, range = SIGHT_RANGE, options: RecomputeOptions = {}) { if (!Number.isInteger(range) || range < 0) { throw new RangeError(`vision range must be a non-negative integer, got ${range}`); } diff --git a/src/game/archetypes/Adept.ts b/src/game/archetypes/Adept.ts index 39097fa..18830f9 100644 --- a/src/game/archetypes/Adept.ts +++ b/src/game/archetypes/Adept.ts @@ -4,7 +4,7 @@ import { ADEPT_DEFAULT_HIT_CHANCE, ADEPT_DEFAULT_DODGE_CHANCE } from '../constan import type { CrewInit } from '../Crew.js'; import type { World } from '../World.js'; import type { Entity } from '../Entity.js'; -import type { Rng } from '../../rng.js'; +import type { RandomSource } from '../../rng.js'; /** * Curated callsign pool for the Adept archetype. See `Merc.ts` CALLSIGNS for @@ -66,7 +66,7 @@ export class Adept extends Crew { * AP burned); on a legal attempt, debits AP once and rolls the success * chance — a failure still costs AP and may trip the alarm. */ - influenceTarget(world: World, target: Entity, rng: Rng) { + influenceTarget(world: World, target: Entity, rng: RandomSource) { return influenceTarget(world, this, target, rng); } } diff --git a/src/game/archetypes/Merc.ts b/src/game/archetypes/Merc.ts index 0149385..4b664ed 100644 --- a/src/game/archetypes/Merc.ts +++ b/src/game/archetypes/Merc.ts @@ -12,7 +12,7 @@ import type { Entity } from '../Entity.js'; import type { World } from '../World.js'; export type VaultCheck = - | { ok: true; mode: 'hop' | 'shove'; occupant: Entity | null } + | { ok: true; mode: 'hop' | 'shove'; occupant: Entity | null; reason?: never } | { ok: false; reason: string }; /** diff --git a/src/game/archetypes/index.ts b/src/game/archetypes/index.ts index 2fbe72c..8128a9a 100644 --- a/src/game/archetypes/index.ts +++ b/src/game/archetypes/index.ts @@ -225,6 +225,18 @@ export type BuildCrewMemberSpawn = { maxHp?: number; faction?: FactionId; }; +export function buildCrewMember( + archetypeId: K, + spawn: BuildCrewMemberSpawn, + rng: Rng, + options?: BuildCrewMemberOptions +): InstanceType<(typeof BUILDERS)[K]>; +export function buildCrewMember( + archetypeId: string, + spawn: BuildCrewMemberSpawn, + rng: Rng, + options?: BuildCrewMemberOptions +): Archetype; export function buildCrewMember( archetypeId: string, spawn: BuildCrewMemberSpawn, diff --git a/src/game/corpTurnStatusCopy.ts b/src/game/corpTurnStatusCopy.ts index 322247b..e1171b8 100644 --- a/src/game/corpTurnStatusCopy.ts +++ b/src/game/corpTurnStatusCopy.ts @@ -35,7 +35,7 @@ const GENERIC_STATUS_MESSAGES = [ * @returns {number} */ export function countVisibleCorpEntities( - entities: Iterable, + entities: Iterable>, isTileVisible: IsVisibleFn, hostileFaction: FactionId = FACTION.CORP ): number { @@ -62,7 +62,7 @@ export function resetCorpTurnStatusCache(): void { * @param {number} turnNumber * @returns {string} */ -export function corpTurnStatusBody(visibleCorpCount: number, turnNumber: number): string { +export function corpTurnStatusBody(visibleCorpCount: number, turnNumber = 0): string { if (visibleCorpCount >= 2) { return 'Multiple hostiles in sight — units repositioning.'; } diff --git a/src/game/cyber/CyberAvatar.ts b/src/game/cyber/CyberAvatar.ts index 3ee2298..ddb86dc 100644 --- a/src/game/cyber/CyberAvatar.ts +++ b/src/game/cyber/CyberAvatar.ts @@ -20,7 +20,7 @@ import { Entity } from '../Entity.js'; import { canInfluence, influenceTarget } from '../mindInfluence.js'; import { CYBER_AVATAR_HIT_CHANCE, CYBER_AVATAR_MAX_AP, FACTION } from '../constants.js'; import type { World } from '../World.js'; -import type { Rng } from '../../rng.js'; +import type { RandomSource } from '../../rng.js'; export type CyberAvatarInit = { id: string; @@ -77,7 +77,7 @@ export class CyberAvatar extends Entity { } /** Attempt to flip ICE to the avatar's faction for the normal override duration. */ - overrideDrone(world: World, target: Entity, rng: Rng) { + overrideDrone(world: World, target: Entity, rng: RandomSource) { return influenceTarget(world, this, target, rng); } diff --git a/src/game/empBlast.ts b/src/game/empBlast.ts index b6ccdb7..d2ab7d4 100644 --- a/src/game/empBlast.ts +++ b/src/game/empBlast.ts @@ -23,7 +23,9 @@ import type { Entity } from './Entity.js'; import type { World } from './World.js'; /** Pre-flight legality verdict, mirroring the other archetype perks. */ -export type EmpCheck = { ok: true } | { ok: false; reason: 'dead' | 'insufficient-ap' }; +export type EmpCheck = + | { ok: true; reason?: never } + | { ok: false; reason: 'dead' | 'insufficient-ap' }; /** * Pure legality check for detonating an EMP. Never mutates. A self-centered diff --git a/src/game/knockback.ts b/src/game/knockback.ts index 3a29e91..d8e6f42 100644 --- a/src/game/knockback.ts +++ b/src/game/knockback.ts @@ -24,7 +24,7 @@ export function awayVector(attacker: Entity, target: Entity): GridPoint | null { } export type KnockbackCheck = - | { ok: true } + | { ok: true; reason?: never } | { ok: false; reason: 'knockback-oob' | 'knockback-blocked' | 'knockback-occupied' }; export function canKnockbackTo(world: World, entity: Entity, x: number, y: number): KnockbackCheck { diff --git a/src/game/mindInfluence.ts b/src/game/mindInfluence.ts index 15555d8..16f34a6 100644 --- a/src/game/mindInfluence.ts +++ b/src/game/mindInfluence.ts @@ -42,11 +42,13 @@ import { } from './constants.js'; import type { Entity } from './Entity.js'; import type { World } from './World.js'; -import type { Rng } from '../rng.js'; +import type { RandomSource, Rng } from '../rng.js'; import type { TurnActionStep } from '../types.js'; /** Pre-flight legality verdict, mirroring the Tech/Razor/Merc perk shape. */ -export type InfluenceCheck = { ok: true } | { ok: false; reason: InfluenceDenyReason }; +export type InfluenceCheck = + | { ok: true; reason?: never } + | { ok: false; reason: InfluenceDenyReason }; export type InfluenceDenyReason = | 'dead' @@ -114,7 +116,7 @@ export function influenceTarget( world: World, operator: Entity, target: Entity, - rng: Rng + rng: RandomSource ): InfluenceResult { const check = canInfluence(world, operator, target); if (!check.ok) { diff --git a/src/game/nanoRepair.ts b/src/game/nanoRepair.ts index 7f58532..fd27fa5 100644 --- a/src/game/nanoRepair.ts +++ b/src/game/nanoRepair.ts @@ -19,7 +19,7 @@ import type { Crew } from './Crew.js'; /** Pre-flight legality verdict, mirroring the other archetype perks. */ export type NaniteHealCheck = - | { ok: true } + | { ok: true; reason?: never } | { ok: false; reason: 'dead' | 'insufficient-ap' | 'no-inventory' | 'insufficient-salvage'; diff --git a/src/game/procgen/prefabs/types.ts b/src/game/procgen/prefabs/types.ts index 7ce18c4..24efe9e 100644 --- a/src/game/procgen/prefabs/types.ts +++ b/src/game/procgen/prefabs/types.ts @@ -37,7 +37,7 @@ export type PrefabMetadata = { id: string; w?: number; h?: number; - anchors: PrefabAnchorsSpec; + anchors?: Partial; /** Patrol waypoint lists, assigned to nearest fodder anchor. */ patrolPaths?: PrefabAnchor[][]; }; diff --git a/src/game/slide.ts b/src/game/slide.ts index c13b557..8ae9d6c 100644 --- a/src/game/slide.ts +++ b/src/game/slide.ts @@ -4,7 +4,7 @@ import type { Entity } from './Entity.js'; import type { World } from './World.js'; import type { GridPoint } from '../types.js'; -export type SlideCheck = { ok: true } | { ok: false; reason: string }; +export type SlideCheck = { ok: true; reason?: never } | { ok: false; reason: string }; /** * Shared two-tile SLIDE geometry for Razor and Flanker. diff --git a/src/game/surge.ts b/src/game/surge.ts index 95e404c..f3ec199 100644 --- a/src/game/surge.ts +++ b/src/game/surge.ts @@ -2,7 +2,7 @@ import { AP_COST, STATUS_EFFECT, SURGE_DURATION } from './constants.js'; import type { Entity } from './Entity.js'; export type SurgeCheck = - | { ok: true } + | { ok: true; reason?: never } | { ok: false; reason: 'dead' | 'insufficient-ap' | 'already-surging' | 'crashing'; diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts index 9caa0ac..4c35e06 100644 --- a/src/input/KeyboardController.ts +++ b/src/input/KeyboardController.ts @@ -19,10 +19,15 @@ import type { AimKind, Mode, PerkAim } from './keymap.js'; * `evt.key` is forwarded case-sensitively into `dispatch` — only lower-case * letter bindings in the keymap produce gameplay intents. */ +type KeyboardTarget = { + addEventListener(type: 'keydown', listener: (evt: KeyboardEvent) => void): void; + removeEventListener(type: 'keydown', listener: (evt: KeyboardEvent) => void): void; +}; + type KeyboardControllerInit = { - target?: Document; + target?: KeyboardTarget; onIntent: (intent: Intent) => void; - onModeChange: (mode: Mode) => void; + onModeChange?: (mode: Mode) => void; isBlocked?: () => boolean; /** * Resolve the *current* archetype's perk-aim so the `x` key fires a @@ -33,7 +38,7 @@ type KeyboardControllerInit = { getSpecialAim?: () => PerkAim; }; export class KeyboardController { - target: Document; + target: KeyboardTarget; onIntent: (intent: Intent) => void; onModeChange: (mode: Mode) => void; isBlocked: () => boolean; diff --git a/src/input/applyIntent.ts b/src/input/applyIntent.ts index 763b92b..83610f5 100644 --- a/src/input/applyIntent.ts +++ b/src/input/applyIntent.ts @@ -73,7 +73,7 @@ import type { CyberAvatar } from '../game/cyber/CyberAvatar.js'; import type { Entity } from '../game/Entity.js'; import type { World } from '../game/World.js'; import type { TurnQueue } from '../game/TurnQueue.js'; -import type { Rng } from '../rng.js'; +import type { RandomSource } from '../rng.js'; import type { Tech } from '../game/archetypes/Tech.js'; import type { Merc } from '../game/archetypes/Merc.js'; import type { Razor } from '../game/archetypes/Razor.js'; @@ -97,7 +97,7 @@ export type ApplyIntentContext = { */ player: Archetype | CyberAvatar; queue: TurnQueue; - rng: Rng; + rng: RandomSource; log: (line: string) => void; advanceTurn: () => void; /** @@ -521,7 +521,11 @@ function doEmp(ctx: ApplyIntentContext) { */ type OverrideActor = ApplyIntentContext['player'] & { canOverride(world: World, target: Entity | null): ReturnType; - overrideDrone(world: World, target: Entity, rng: Rng): ReturnType; + overrideDrone( + world: World, + target: Entity, + rng: RandomSource + ): ReturnType; }; function doOverride(intent: Intent, ctx: ApplyIntentContext) { @@ -559,7 +563,11 @@ function doOverride(intent: Intent, ctx: ApplyIntentContext) { */ type InfluenceActor = ApplyIntentContext['player'] & { canInfluence(world: World, target: Entity | null): ReturnType; - influenceTarget(world: World, target: Entity, rng: Rng): ReturnType; + influenceTarget( + world: World, + target: Entity, + rng: RandomSource + ): ReturnType; }; function doInfluence(intent: Intent, ctx: ApplyIntentContext) { diff --git a/src/render/animations.ts b/src/render/animations.ts index 0c9e428..f05a3ab 100644 --- a/src/render/animations.ts +++ b/src/render/animations.ts @@ -104,7 +104,26 @@ export const DAMAGE_CLASS = 'kp-damage-flash'; export const MITIGATION_FLASH_CLASS = 'kp-mitigation-flash'; const IMPACT_FLASH_COLOR_PROPERTY = '--kp-impact-flash-color'; -const defaultTimers = Object.freeze({ +type AnimationTimers = Readonly<{ + now: () => number; + setTimeout: (fn: () => void, ms: number) => unknown; +}>; + +type AnimationElement = { + classList: { + add: (className: string) => unknown; + remove: (className: string) => unknown; + }; + style: { + setProperty: (name: string, value: string) => unknown; + removeProperty: (name: string) => unknown; + }; + readonly offsetWidth: number; +}; + +type FlashRenderer = Pick; + +const defaultTimers: AnimationTimers = Object.freeze({ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()), setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), }); @@ -116,7 +135,7 @@ const defaultTimers = Object.freeze({ * second add is a no-op because the class is already present. */ export function restartCssAnimation( - el: HTMLElement, + el: AnimationElement, className: string, duration: number, timers = defaultTimers @@ -139,11 +158,11 @@ export function restartCssAnimation( return true; } -export function triggerShake(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerShake(stageEl: AnimationElement, timers = defaultTimers) { return restartCssAnimation(stageEl, SHAKE_CLASS, ANIMATION_DURATIONS.SHAKE, timers); } -export function triggerDamageFlash(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerDamageFlash(stageEl: AnimationElement, timers = defaultTimers) { stageEl.classList.remove(MITIGATION_FLASH_CLASS); stageEl.style.removeProperty(IMPACT_FLASH_COLOR_PROPERTY); return restartCssAnimation(stageEl, DAMAGE_CLASS, ANIMATION_DURATIONS.DAMAGE_FLASH, timers); @@ -155,7 +174,7 @@ export function triggerDamageFlash(stageEl: HTMLElement, timers = defaultTimers) * mitigation flash drives) tinted electric cyan — the same hue a stunned glyph * takes, so the blast and its aftermath read as one effect. */ -export function triggerEmpFlash(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerEmpFlash(stageEl: AnimationElement, timers = defaultTimers) { stageEl.classList.remove(DAMAGE_CLASS); stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${STUNNED_FG}8c`); return restartCssAnimation( @@ -172,7 +191,7 @@ export function triggerEmpFlash(stageEl: HTMLElement, timers = defaultTimers) { * drive — the surge spike and its later Crash comedown share this one screen * effect, tinted differently, so the ability reads as a single arc. */ -export function triggerSurgeFlash(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerSurgeFlash(stageEl: AnimationElement, timers = defaultTimers) { stageEl.classList.remove(DAMAGE_CLASS); stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${SURGE_FLASH_FG}8c`); return restartCssAnimation( @@ -187,7 +206,7 @@ export function triggerSurgeFlash(stageEl: HTMLElement, timers = defaultTimers) * Ashen violet-grey pulse when a Berserk's Surge expires into Crash (P3.5.M3). * The comedown twin of {@link triggerSurgeFlash} on the shared vignette class. */ -export function triggerCrashFlash(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerCrashFlash(stageEl: AnimationElement, timers = defaultTimers) { stageEl.classList.remove(DAMAGE_CLASS); stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${CRASH_FLASH_FG}8c`); return restartCssAnimation( @@ -205,7 +224,7 @@ export function triggerCrashFlash(stageEl: HTMLElement, timers = defaultTimers) * tinted for "HP restored" generically — a beat of feedback beyond the * HP-tick itself, same shape as `triggerSurgeFlash`. */ -export function triggerHealFlash(stageEl: HTMLElement, timers = defaultTimers) { +export function triggerHealFlash(stageEl: AnimationElement, timers = defaultTimers) { stageEl.classList.remove(DAMAGE_CLASS); stageEl.style.setProperty(IMPACT_FLASH_COLOR_PROPERTY, `${HEAL_FLASH_FG}8c`); return restartCssAnimation( @@ -223,7 +242,7 @@ export type MitigationFlashKind = 'armor' | 'shield'; * The alpha suffix keeps the vignette at the same intensity as damage red. */ export function triggerMitigationFlash( - stageEl: HTMLElement, + stageEl: AnimationElement, kind: MitigationFlashKind, timers = defaultTimers ) { @@ -282,13 +301,13 @@ export function createAnimationLock(timers = defaultTimers) { */ type RunMuzzleFlashOptions = { duration?: number; - timers?: typeof defaultTimers; + timers?: AnimationTimers; char?: string; color?: string; fontScale?: number; }; export function runMuzzleFlash( - renderer: AsciiRenderer, + renderer: FlashRenderer, repaint: () => void, worldX: number, worldY: number, @@ -317,7 +336,7 @@ export function runMuzzleFlash( type RunInteractSecuredFlashOptions = { duration?: number; - timers?: typeof defaultTimers; + timers?: AnimationTimers; color?: string; }; @@ -326,7 +345,7 @@ type RunInteractSecuredFlashOptions = { * glyph so it reads on both neutral lavender and post-activate mint. */ export function runInteractSecuredFlash( - renderer: AsciiRenderer, + renderer: FlashRenderer, repaint: () => void, worldX: number, worldY: number, @@ -348,7 +367,7 @@ export function runInteractSecuredFlash( type RunFireFlashOptions = { duration?: number; - timers?: typeof defaultTimers; + timers?: AnimationTimers; }; /** @@ -361,7 +380,7 @@ type RunFireFlashOptions = { * invisible. The star reads as the bottle shattering, then resolves into fire. */ export function runIncendiaryImpactFlash( - renderer: AsciiRenderer, + renderer: FlashRenderer, repaint: () => void, worldX: number, worldY: number, @@ -388,7 +407,7 @@ export function runIncendiaryImpactFlash( * tile underneath is already drawn as fire anyway. */ export function runBurnFlash( - renderer: AsciiRenderer, + renderer: FlashRenderer, repaint: () => void, worldX: number, worldY: number, diff --git a/src/render/frame.ts b/src/render/frame.ts index a8e1b81..6fed756 100644 --- a/src/render/frame.ts +++ b/src/render/frame.ts @@ -67,7 +67,7 @@ export type Frame = { * @param {{ width: number, height: number }} viewport * @returns {{ x: number, y: number, width: number, height: number }} */ -export function cameraFor(target: Entity, viewport: Viewport): Camera { +export function cameraFor(target: Pick, viewport: Viewport): Camera { if (!Number.isInteger(viewport.width) || viewport.width <= 0) { throw new RangeError(`viewport.width must be a positive integer, got ${viewport.width}`); } diff --git a/src/rng.ts b/src/rng.ts index c04bd61..cd824a8 100644 --- a/src/rng.ts +++ b/src/rng.ts @@ -29,6 +29,11 @@ export function mulberry32(seed: number): () => number { }; } +/** Smallest random-source protocol for consumers that only draw unit floats. */ +export interface RandomSource { + next(): number; +} + /** * Stateful wrapper around `mulberry32`. The internal `state` is a single u32 * that advances on every call — exposing it means a save can checkpoint a diff --git a/tests/support/hooks.mjs b/tests/support/hooks.mjs new file mode 100644 index 0000000..5d22a5a --- /dev/null +++ b/tests/support/hooks.mjs @@ -0,0 +1,48 @@ +// Node module-resolution hook for the test runner. +// +// The browser imports compiled `.js` modules, while tests execute the TypeScript +// source directly under Node 24. Resolve in-repository `.js` specifiers to their +// `.ts` source counterparts, including browser-root imports such as +// `/src/domUtils.js`. + +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '../..'); +const ROOT_PREFIX = `${ROOT}${path.sep}`; +const BROWSER_ROOT_PREFIXES = ['/src/', '/components/', '/debug/']; + +function isInsideRoot(filePath) { + return filePath === ROOT || filePath.startsWith(ROOT_PREFIX); +} + +function sourcePathFor(specifier, parentURL) { + if (!specifier.endsWith('.js')) return null; + + let jsPath; + if (BROWSER_ROOT_PREFIXES.some(prefix => specifier.startsWith(prefix))) { + jsPath = path.join(ROOT, specifier); + } else if ( + parentURL?.startsWith('file:') && + (specifier.startsWith('./') || specifier.startsWith('../')) + ) { + jsPath = fileURLToPath(new URL(specifier, parentURL)); + } else { + return null; + } + + if (!isInsideRoot(jsPath)) return null; + + const tsPath = `${jsPath.slice(0, -3)}.ts`; + return existsSync(tsPath) ? tsPath : null; +} + +export async function resolve(specifier, context, nextResolve) { + const tsPath = sourcePathFor(specifier, context.parentURL); + if (tsPath) { + return { url: pathToFileURL(tsPath).href, shortCircuit: true }; + } + return nextResolve(specifier, context); +} diff --git a/tests/support/loader.mjs b/tests/support/loader.mjs new file mode 100644 index 0000000..32310fa --- /dev/null +++ b/tests/support/loader.mjs @@ -0,0 +1,5 @@ +// Registers the source-resolution hook (see ./hooks.mjs) ahead of the test run. +// Wired via `node --import ./tests/support/loader.mjs` in the `test` script. +import { register } from 'node:module'; + +register('./hooks.mjs', import.meta.url); diff --git a/tests/unit/DataStore.test.ts b/tests/unit/DataStore.test.ts index 769f3fa..513e470 100644 --- a/tests/unit/DataStore.test.ts +++ b/tests/unit/DataStore.test.ts @@ -38,7 +38,7 @@ test('DataStore migrates legacy data and archives idempotent capped campaign his const { default: dataStore } = await import('../../src/DataStore.js'); await dataStore.init(); - assert.deepEqual(dataStore.campaignHistory, []); + assert.equal(dataStore.campaignHistory.length, 0); const original = summary(0); const first = dataStore.archiveCampaign(original); diff --git a/tests/unit/game/Adept.test.ts b/tests/unit/game/Adept.test.ts index 238871d..4a92454 100644 --- a/tests/unit/game/Adept.test.ts +++ b/tests/unit/game/Adept.test.ts @@ -16,10 +16,15 @@ import { Adept } from '../../../src/game/archetypes/Adept.js'; import { Crew } from '../../../src/game/Crew.js'; import { Grid } from '../../../src/game/Grid.js'; import { World } from '../../../src/game/World.js'; +import { Entity } from '../../../src/game/Entity.js'; import { Skirmisher } from '../../../src/game/ai/Skirmisher.js'; import { FACTION, AP_COST, INFLUENCE_DURATION } from '../../../src/game/constants.js'; -function makeWorld({ adeptAt = [1, 1], grid, extraEntities = [] } = {}) { +function makeWorld({ + adeptAt = [1, 1], + grid, + extraEntities = [], +}: { adeptAt?: [number, number]; grid?: Grid; extraEntities?: Entity[] } = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g); const adept = new Adept({ id: 'adept', x: adeptAt[0], y: adeptAt[1] }); @@ -28,7 +33,12 @@ function makeWorld({ adeptAt = [1, 1], grid, extraEntities = [] } = {}) { return { world: w, adept }; } -const makeDrone = (id, x, y, faction = FACTION.CORP) => new Skirmisher({ id, x, y, faction }); +const makeDrone = ( + id: string, + x: number, + y: number, + faction: ConstructorParameters[0]['faction'] = FACTION.CORP +) => new Skirmisher({ id, x, y, faction }); // Deterministic single-roll stubs: 0.1 < success chance → dominate; 0.9 → fail. const winRng = { next: () => 0.1 }; diff --git a/tests/unit/game/Bruiser.test.ts b/tests/unit/game/Bruiser.test.ts index 01f7837..e1f611f 100644 --- a/tests/unit/game/Bruiser.test.ts +++ b/tests/unit/game/Bruiser.test.ts @@ -17,12 +17,16 @@ import { } from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; -class StubRng { - constructor(values) { +class StubRng extends Rng { + values: number[]; + calls: number; + + constructor(values: number[]) { + super(0); this.values = [...values]; this.calls = 0; } - next() { + override next() { if (this.calls >= this.values.length) { throw new Error('StubRng drained — test under-supplied rolls'); } diff --git a/tests/unit/game/Campaign.test.ts b/tests/unit/game/Campaign.test.ts index 830f451..df80586 100644 --- a/tests/unit/game/Campaign.test.ts +++ b/tests/unit/game/Campaign.test.ts @@ -16,7 +16,7 @@ import { } from '../../../src/game/Campaign.js'; import { OUTCOME, RUN_STATE } from '../../../src/game/Run.js'; import { Rng } from '../../../src/rng.js'; -import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../src/game/hub/Curator.js'; import { snapshotCampaign, restoreCampaign } from '../../../src/game/persistence.js'; import { CONTRACT_DIFFICULTY, @@ -34,20 +34,21 @@ import { } from '../../../src/game/archetypeRewards.js'; import type { LocationSite } from '../../../src/types.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'test job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; function validSite(overrides: Partial = {}): LocationSite { return { @@ -192,7 +193,7 @@ test('deployCrewMember starts a job Run for a non-flatlined crew member', () => assert.equal(campaign.activeRun, run); assert.equal(run.state, RUN_STATE.BRIEFING); assert.equal(run.crewMember, member); - assert.equal(run.contract.label, 'test job'); + assert.equal(run.contract!.label, 'test job'); }); test('deployCrewMember rejects flatlined or unknown crew members', () => { @@ -335,6 +336,7 @@ test('willEndCampaignOnThisDeath is true only for the last surviving crew slot', campaign.flatlineMember(campaign.crew[0].id); campaign.flatlineMember(campaign.crew[1].id); assert.equal(willEndCampaignOnThisDeath(campaign), true); + // @ts-expect-error Verify runtime validation of a null campaign. assert.throws(() => willEndCampaignOnThisDeath(null), /Campaign-like/); }); @@ -347,7 +349,7 @@ test('onJobEnd with EXIT transfers crew inventory salvage to campaign pool', () run.enterCombat(); // Simulate the crew member collecting salvage during the job. member.initInventory(); - member.inventory.salvage = makeSalvage({ scrap: 7 }); + member.inventory!.salvage = makeSalvage({ scrap: 7 }); // Exit extracts inventory salvage (typed wallet passed through). campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: makeSalvage({ scrap: 7 }) }); assert.equal(campaign.salvage.scrap, 7, 'scrap accumulated from job'); @@ -363,7 +365,7 @@ test('onJobEnd with DEATH does not add salvage to the campaign pool', () => { ); run.enterCombat(); member.initInventory(); - member.inventory.salvage = makeSalvage({ scrap: 5 }); + member.inventory!.salvage = makeSalvage({ scrap: 5 }); campaign.onJobEnd({ outcome: OUTCOME.DEATH }); assert.equal(totalSalvage(campaign.salvage), 0, 'death forfeits salvage'); assert.equal(campaign.credits, 0, 'death forfeits contract Creds'); @@ -375,8 +377,8 @@ test('crew inventory survives campaign snapshot/restore round-trip', () => { const campaign = new Campaign({ seed: 42 }); const member = campaign.crew[0]; member.initInventory(); - member.inventory.salvage = makeSalvage({ scrap: 7 }); - member.inventory.consumables = []; + member.inventory!.salvage = makeSalvage({ scrap: 7 }); + member.inventory!.consumables = []; const snap = snapshotCampaign(campaign); const restored = restoreCampaign(snap); const restoredMember = restored.crew[0]; @@ -390,7 +392,7 @@ test('onJobEnd flatlines deaths and ends the campaign when everyone is gone', () const campaign = new Campaign({ seed: 42 }); for (const member of campaign.crew) { campaign.deployCrewMember(member.id, fakeContract()); - campaign.activeRun.enterCombat(); + campaign.activeRun!.enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.DEATH }); } assert.equal(campaign.state, CAMPAIGN_STATE.ENDED); @@ -483,6 +485,7 @@ test('untyped sellSalvage draws scrap → chips → bio → data applying per-ty test('sellSalvage rejects unknown salvage types', () => { const campaign = new Campaign({ seed: 42 }); campaign.salvage = makeSalvage({ scrap: 5 }); + // @ts-expect-error Verify runtime validation of an unknown salvage type. assert.throws(() => campaign.sellSalvage(1, 'nuclear-waste'), /unknown salvage type/i); }); @@ -517,8 +520,8 @@ test('purchase deducts Creds and adds a consumable to the target crew member', ( assert.equal(totalSalvage(campaign.salvage), 10, 'salvage untouched by purchase'); assert.equal(campaign.credits, 0); assert.ok(member.inventory, 'inventory should be initialised after purchase'); - assert.equal(member.inventory.consumables.length, 1); - assert.equal(member.inventory.consumables[0].id, 'stim'); + assert.equal(member.inventory!.consumables.length, 1); + assert.equal(member.inventory!.consumables[0].id, 'stim'); }); test('purchase applies campaign-scoped gear bonus (armour plating)', () => { @@ -528,21 +531,21 @@ test('purchase applies campaign-scoped gear bonus (armour plating)', () => { campaign.purchase({ itemId: 'armour-plating', targetMemberId: member.id }); assert.equal(campaign.credits, 0); assert.equal(member.maxHp, origMaxHp + 1); - assert.equal(member.gear.maxHpBonus, 1); + assert.equal(member.gear!.maxHpBonus, 1); }); test('purchase applies targeting chip gear bonus', () => { const campaign = new Campaign({ seed: 42, credits: SHOP_COST.TARGETING_CHIP }); const member = campaign.crew[0]; campaign.purchase({ itemId: 'targeting-chip', targetMemberId: member.id }); - assert.equal(member.gear.hitBonus, 0.1); + assert.equal(member.gear!.hitBonus, 0.1); }); test('purchase applies reflex weave gear bonus', () => { const campaign = new Campaign({ seed: 42, credits: SHOP_COST.GHOST_WEAVE }); const member = campaign.crew[0]; campaign.purchase({ itemId: 'reflex-weave', targetMemberId: member.id }); - assert.equal(member.gear.dodgeBonus, 0.1); + assert.equal(member.gear!.dodgeBonus, 0.1); }); test('purchase refuses limit-1 gear the target already has equipped, without charging', () => { @@ -630,12 +633,12 @@ test('onJobEnd preserves consumables but clears salvage', () => { const member = campaign.crew[0]; campaign.purchase({ itemId: 'stim', targetMemberId: member.id }); campaign.purchase({ itemId: 'stim', targetMemberId: member.id }); - assert.equal(member.inventory.consumables.length, 2); + assert.equal(member.inventory!.consumables.length, 2); campaign.deployCrewMember(member.id, fakeContract()); - campaign.activeRun.enterCombat(); + campaign.activeRun!.enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); - assert.equal(member.inventory.consumables.length, 2, 'consumables persist across jobs'); - assert.equal(totalSalvage(member.inventory.salvage), 0, 'salvage zeroed on job end'); + assert.equal(member.inventory!.consumables.length, 2, 'consumables persist across jobs'); + assert.equal(totalSalvage(member.inventory!.salvage), 0, 'salvage zeroed on job end'); }); test('crew member HP persists across jobs — no free heal on deploy', () => { @@ -645,14 +648,14 @@ test('crew member HP persists across jobs — no free heal on deploy', () => { // Deploy and enter combat — member takes damage. campaign.deployCrewMember(member.id, fakeContract()); - campaign.activeRun.enterCombat(); + campaign.activeRun!.enterCombat(); member.hp = startingHp - 2; // simulate taking 2 damage campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: emptySalvage() }); assert.equal(member.hp, startingHp - 2, 'HP should carry back from job'); // Deploy again — HP must NOT reset to maxHp. campaign.deployCrewMember(member.id, fakeContract({ seed: 99 })); - campaign.activeRun.enterCombat(); + campaign.activeRun!.enterCombat(); assert.equal(member.hp, startingHp - 2, 'HP must persist into the next job'); }); @@ -709,11 +712,11 @@ test('net-new scoreable gear survives campaign round-trip', () => { const restored = restoreCampaign(snapshotCampaign(campaign)).crew[0]; assert.equal(restored.damageReduction, 1, 'armour (damageReduction) round-trips'); assert.equal(restored.maxAp, baseMaxAp + 1, 'reflex booster maxAp round-trips'); - assert.equal(restored.gear.armorBonus, 1); - assert.equal(restored.gear.apBonus, 1); - assert.equal(restored.gear.meleeDamageBonus, 1); - assert.equal(restored.gear.shieldRegen, 1, 'phase shield regen round-trips'); - assert.equal(restored.gear.hpRegen, 1, 'regen mesh round-trips'); + assert.equal(restored.gear!.armorBonus, 1); + assert.equal(restored.gear!.apBonus, 1); + assert.equal(restored.gear!.meleeDamageBonus, 1); + assert.equal(restored.gear!.shieldRegen, 1, 'phase shield regen round-trips'); + assert.equal(restored.gear!.hpRegen, 1, 'regen mesh round-trips'); assert.equal(restored.meleeAttackDamage(), member.meleeAttackDamage()); }); @@ -735,8 +738,8 @@ test('consumables survive campaign snapshot/restore round-trip', () => { const snap = snapshotCampaign(campaign); const restored = restoreCampaign(snap); const restoredMember = restored.crew[0]; - assert.equal(restoredMember.inventory.consumables.length, 1); - assert.equal(restoredMember.inventory.consumables[0].id, 'stim'); + assert.equal(restoredMember.inventory!.consumables.length, 1); + assert.equal(restoredMember.inventory!.consumables[0].id, 'stim'); }); // --- Rep meter ----------------------------------------------------------- @@ -1361,7 +1364,7 @@ test('completed Score extraction settles synchronously before the exit move call seed: 47, rep: 65, completedJobs: 9, - onResult: result => { + onResult: (result: import('../../../src/game/Run.js').RunResult) => { campaign.onJobEnd({ outcome: result.outcome, completed: result.telemetry.objectiveComplete === true, diff --git a/tests/unit/game/Chimera.test.ts b/tests/unit/game/Chimera.test.ts index 9fbf49b..2d79e80 100644 --- a/tests/unit/game/Chimera.test.ts +++ b/tests/unit/game/Chimera.test.ts @@ -23,7 +23,7 @@ import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; function makeChimera({ salvage = SALVAGE_PER_NANITE_HEAL } = {}) { const chimera = new Chimera({ id: 'chimera', x: 0, y: 0 }); chimera.initInventory(); - chimera.inventory.salvage = makeSalvage({ scrap: salvage }); + chimera.inventory!.salvage = makeSalvage({ scrap: salvage }); return chimera; } @@ -65,12 +65,12 @@ test('Chimera.convertScrapToHp debits AP + scrap and heals, clamped at maxHp', ( const chimera = makeChimera({ salvage: 10 }); chimera.damage(2); const apBefore = chimera.ap; - const scrapBefore = chimera.inventory.salvage.scrap; + const scrapBefore = chimera.inventory!.salvage.scrap; const hpBefore = chimera.hp; const healed = chimera.convertScrapToHp(); assert.equal(chimera.ap, apBefore - AP_COST.NANITE_HEAL, 'AP debited once'); assert.equal( - chimera.inventory.salvage.scrap, + chimera.inventory!.salvage.scrap, scrapBefore - SALVAGE_PER_NANITE_HEAL, 'scrap deducted by nanite-heal cost' ); @@ -82,13 +82,13 @@ test('Chimera.convertScrapToHp clamps at maxHp and still spends the resources', const chimera = makeChimera({ salvage: 10 }); assert.equal(chimera.hp, chimera.maxHp, 'starts at full HP'); const apBefore = chimera.ap; - const scrapBefore = chimera.inventory.salvage.scrap; + const scrapBefore = chimera.inventory!.salvage.scrap; const healed = chimera.convertScrapToHp(); assert.equal(healed, 0, 'no HP actually restored at full health'); assert.equal(chimera.hp, chimera.maxHp); assert.equal(chimera.ap, apBefore - AP_COST.NANITE_HEAL, 'AP still spent'); assert.equal( - chimera.inventory.salvage.scrap, + chimera.inventory!.salvage.scrap, scrapBefore - SALVAGE_PER_NANITE_HEAL, 'scrap still spent' ); @@ -99,7 +99,7 @@ test('Chimera.convertScrapToHp throws on illegal preconditions without mutating const apBefore = chimera.ap; assert.throws(() => chimera.convertScrapToHp(), /Illegal nanite conversion/); assert.equal(chimera.ap, apBefore, 'AP not debited on illegal conversion'); - assert.equal(totalSalvage(chimera.inventory.salvage), 0, 'salvage wallet untouched'); + assert.equal(totalSalvage(chimera.inventory!.salvage), 0, 'salvage wallet untouched'); }); test('Chimera.convertScrapToHp is repeatable across turns as long as scrap lasts', () => { @@ -109,6 +109,6 @@ test('Chimera.convertScrapToHp is repeatable across turns as long as scrap lasts chimera.refreshAp(); const secondHeal = chimera.convertScrapToHp(); assert.ok(secondHeal >= 0); - assert.equal(chimera.inventory.salvage.scrap, 0, 'both activations spent scrap'); + assert.equal(chimera.inventory!.salvage.scrap, 0, 'both activations spent scrap'); assert.equal(chimera.canConvertScrap().reason, 'insufficient-salvage', 'scrap now exhausted'); }); diff --git a/tests/unit/game/Combat.test.ts b/tests/unit/game/Combat.test.ts index becaefd..2348a41 100644 --- a/tests/unit/game/Combat.test.ts +++ b/tests/unit/game/Combat.test.ts @@ -21,9 +21,28 @@ import { import { canFireRanged, resolveRanged, canMelee, resolveMelee } from '../../../src/game/Combat.js'; import { MELEE_DAMAGE, NOISE_RADIUS } from '../../../src/game/constants.js'; +type PointTuple = [number, number]; +type DamageEvent = { + attacker: Entity; + target: Entity; + source: string; + damage: number; + dodged?: boolean; + damageResolution?: unknown; +}; +type NoiseEvent = { + kind: string; + radius: number; + origin: { x: number; y: number }; + source: Entity; +}; + /** Tiny stub Rng — emits a queued sequence. Lets tests pin hit/miss. */ class StubRng { - constructor(values) { + values: number[]; + calls: number; + + constructor(values: number[]) { this.values = [...values]; this.calls = 0; } @@ -35,7 +54,11 @@ class StubRng { } } -const makeFight = ({ grid, attackerAt = [1, 1], targetAt = [4, 1] } = {}) => { +const makeFight = ({ + grid, + attackerAt = [1, 1], + targetAt = [4, 1], +}: { grid?: Grid; attackerAt?: PointTuple; targetAt?: PointTuple } = {}) => { const g = grid ?? new Grid(8, 8); const w = new World(g); const attacker = new Entity({ @@ -233,6 +256,7 @@ test('resolveRanged is reproducible across runs with the same RNG state', () => test('resolveRanged crashes if no Rng is supplied (no Math.random fallback)', () => { const { world, attacker, target } = makeFight(); + // @ts-expect-error Runtime validation must reject a missing random source. assert.throws(() => resolveRanged(world, attacker, target, null), TypeError); }); @@ -245,8 +269,8 @@ test('resolveRanged emits entity:damaged on a connected hit when a bus is attach const target = new Entity({ id: 't', x: 4, y: 1, faction: FACTION.CORP, glyph: 'd' }); world.addEntity(attacker); world.addEntity(target); - const damaged = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: DamageEvent[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as DamageEvent)); resolveRanged(world, attacker, target, new StubRng([0])); // guaranteed hit assert.equal(damaged.length, 1); assert.equal(damaged[0].attacker, attacker); @@ -264,8 +288,8 @@ test('resolveRanged does NOT emit entity:damaged on a miss', async () => { const target = new Entity({ id: 't', x: 4, y: 1, faction: FACTION.CORP, glyph: 'd' }); world.addEntity(attacker); world.addEntity(target); - const damaged = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: DamageEvent[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as DamageEvent)); resolveRanged(world, attacker, target, new StubRng([0.99])); // guaranteed miss assert.deepEqual(damaged, []); }); @@ -279,8 +303,8 @@ test('resolveRanged emits a noise event (RANGED radius) on every shot, hit or mi const target = new Entity({ id: 't', x: 4, y: 1, faction: FACTION.CORP, glyph: 'd' }); world.addEntity(attacker); world.addEntity(target); - const noises = []; - bus.on(EVENT.NOISE, payload => noises.push(payload)); + const noises: NoiseEvent[] = []; + bus.on(EVENT.NOISE, payload => noises.push(payload as NoiseEvent)); resolveRanged(world, attacker, target, new StubRng([0])); // hit resolveRanged(world, attacker, target, new StubRng([0.99])); // miss assert.equal(noises.length, 2, 'gunshots are loud whether they connect or not'); @@ -294,7 +318,10 @@ test('resolveRanged emits a noise event (RANGED radius) on every shot, hit or mi // --- Melee -------------------------------------------------------------- -const makeMeleeFight = ({ attackerAt = [3, 3], targetAt = [4, 3] } = {}) => { +const makeMeleeFight = ({ + attackerAt = [3, 3], + targetAt = [4, 3], +}: { attackerAt?: PointTuple; targetAt?: PointTuple } = {}) => { const g = new Grid(8, 8); const w = new World(g); const attacker = new Entity({ @@ -391,8 +418,8 @@ test('resolveMelee reports armor, shield, and actual HP damage as separate layer world.events = bus; target.damageReduction = 1; target.addShield(1); - const damaged = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: DamageEvent[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as DamageEvent)); const result = resolveMelee(world, attacker, target, new StubRng([0.99]), { damage: 3 }); @@ -441,8 +468,8 @@ test('resolveMelee emits entity:damaged with source="melee"', async () => { const target = new Entity({ id: 't', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); world.addEntity(attacker); world.addEntity(target); - const damaged = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: DamageEvent[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as DamageEvent)); resolveMelee(world, attacker, target, new StubRng([0.99])); assert.equal(damaged.length, 1); assert.equal(damaged[0].source, 'melee'); @@ -460,8 +487,8 @@ test('resolveMelee can be dodged without changing target HP', async () => { world.addEntity(attacker); world.addEntity(target); const hpBefore = target.hp; - const damaged = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: DamageEvent[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as DamageEvent)); const result = resolveMelee(world, attacker, target, new StubRng([DODGE_CHANCE - 0.01])); @@ -540,6 +567,7 @@ test('resolveMelee never dodges stationary infrastructure even in corner cover', test('resolveMelee crashes if no Rng is supplied (no Math.random fallback)', () => { const { world, attacker, target } = makeMeleeFight(); + // @ts-expect-error Runtime validation must reject a missing random source. assert.throws(() => resolveMelee(world, attacker, target, null), TypeError); }); @@ -609,8 +637,8 @@ test('resolveMelee emits a noise event (MELEE radius)', async () => { const target = new Entity({ id: 't', x: 4, y: 3, faction: FACTION.CORP, glyph: 'd' }); world.addEntity(attacker); world.addEntity(target); - const noises = []; - bus.on(EVENT.NOISE, payload => noises.push(payload)); + const noises: NoiseEvent[] = []; + bus.on(EVENT.NOISE, payload => noises.push(payload as NoiseEvent)); resolveMelee(world, attacker, target, new StubRng([0.99])); assert.equal(noises.length, 1); assert.equal(noises[0].kind, 'melee'); diff --git a/tests/unit/game/Decker.test.ts b/tests/unit/game/Decker.test.ts index 22f41a0..3a82b5f 100644 --- a/tests/unit/game/Decker.test.ts +++ b/tests/unit/game/Decker.test.ts @@ -17,7 +17,15 @@ import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; import { FACTION, AP_COST, EMP_RADIUS, STATUS_EFFECT } from '../../../src/game/constants.js'; -function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [] } = {}) { +function makeWorld({ + deckerAt = [5, 5], + grid, + extraEntities = [], +}: { + deckerAt?: [number, number]; + grid?: Grid; + extraEntities?: Entity[]; +} = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g); const decker = new Decker({ id: 'decker', x: deckerAt[0], y: deckerAt[1] }); @@ -26,7 +34,8 @@ function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [] } = {}) { return { world: w, decker }; } -const corp = (id, x, y) => new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); +const corp = (id: string, x: number, y: number) => + new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); // --- class basics ---------------------------------------------------------- diff --git a/tests/unit/game/Entity.test.ts b/tests/unit/game/Entity.test.ts index 87781d8..fc2168d 100644 --- a/tests/unit/game/Entity.test.ts +++ b/tests/unit/game/Entity.test.ts @@ -14,17 +14,20 @@ const baseProps = () => ({ test('Entity requires an id', () => { const props = baseProps(); + // @ts-expect-error Verify runtime validation of a missing required id. delete props.id; assert.throws(() => new Entity(props), TypeError); }); test('Entity requires integer x and y', () => { assert.throws(() => new Entity({ ...baseProps(), x: 1.5 }), TypeError); + // @ts-expect-error Verify runtime validation of a non-numeric coordinate. assert.throws(() => new Entity({ ...baseProps(), y: '3' }), TypeError); }); test('Entity requires a faction', () => { const props = baseProps(); + // @ts-expect-error Verify runtime validation of a missing faction. delete props.faction; assert.throws(() => new Entity(props), TypeError); }); diff --git a/tests/unit/game/Flanker.test.ts b/tests/unit/game/Flanker.test.ts index 31245e6..12d6215 100644 --- a/tests/unit/game/Flanker.test.ts +++ b/tests/unit/game/Flanker.test.ts @@ -20,13 +20,18 @@ import { HEAVY_MELEE_DAMAGE, TILE, } from '../../../src/game/constants.js'; +import { Rng } from '../../../src/rng.js'; -class StubRng { - constructor(values) { +class StubRng extends Rng { + values: number[]; + calls: number; + + constructor(values: number[]) { + super(0); this.values = [...values]; this.calls = 0; } - next() { + override next() { if (this.calls >= this.values.length) { throw new Error('StubRng drained — test under-supplied rolls'); } @@ -34,8 +39,11 @@ class StubRng { } } -const makePlayer = (x, y, extra = {}) => - new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', maxHp: 10, ...extra }); +const makePlayer = ( + x: number, + y: number, + extra: Partial[0]> = {} +) => new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', maxHp: 10, ...extra }); test('Flanker is a corp-faction elite PatrolHostile with the flanker glyph', () => { const flanker = new Flanker({ id: 'flanker-0', x: 1, y: 1 }); diff --git a/tests/unit/game/Guard.test.ts b/tests/unit/game/Guard.test.ts index ed502a1..074565a 100644 --- a/tests/unit/game/Guard.test.ts +++ b/tests/unit/game/Guard.test.ts @@ -19,12 +19,16 @@ import { EventBus, EVENT } from '../../../src/game/events.js'; import { Rng } from '../../../src/rng.js'; /** Fixed-sequence RNG that crashes if over-drained — pins dodge/hit outcomes. */ -class StubRng { - constructor(values) { +class StubRng extends Rng { + values: number[]; + calls: number; + + constructor(values: number[]) { + super(0); this.values = [...values]; this.calls = 0; } - next() { + override next() { if (this.calls >= this.values.length) { throw new Error('StubRng drained — test under-supplied rolls'); } @@ -178,5 +182,6 @@ test('Guard constructor rejects malformed waypoints', () => { () => new Guard({ id: 'guard-0', x: 1, y: 1, patrolWaypoints: [{ x: 0.5, y: 1 }] }), TypeError ); + // @ts-expect-error Verify runtime validation of a non-array waypoint list. assert.throws(() => new Guard({ id: 'guard-0', x: 1, y: 1, patrolWaypoints: 'nope' }), TypeError); }); diff --git a/tests/unit/game/Juggernaut.test.ts b/tests/unit/game/Juggernaut.test.ts index b599486..fd36e2e 100644 --- a/tests/unit/game/Juggernaut.test.ts +++ b/tests/unit/game/Juggernaut.test.ts @@ -18,6 +18,7 @@ import { JUGGERNAUT_PREFERRED_MIN, TILE, } from '../../../src/game/constants.js'; +import { Rng } from '../../../src/rng.js'; // Walls boxing the juggernaut at (1,1) so no band-kite tile exists — every // neighbour except the player's tile (2,1) is sealed. Shared by the cornered @@ -32,12 +33,16 @@ const CORNER_WALLS = [ [2, 2], ]; -class StubRng { - constructor(values) { +class StubRng extends Rng { + values: number[]; + calls: number; + + constructor(values: number[]) { + super(0); this.values = [...values]; this.calls = 0; } - next() { + override next() { if (this.calls >= this.values.length) { throw new Error('StubRng drained — test under-supplied rolls'); } @@ -46,13 +51,17 @@ class StubRng { } // Always-hit rng for turns whose suppress-roll count we don't want to pin. -const alwaysHit = () => ({ next: () => 0.0 }); +const alwaysHit = () => new StubRng(Array(32).fill(0)); const openWorld = (w = 16, h = 6) => new World(new Grid(w, h)); -const cheb = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); - -const makePlayer = (x, y, extra = {}) => - new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', maxHp: 10, ...extra }); +const cheb = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); + +const makePlayer = ( + x: number, + y: number, + extra: Partial[0]> = {} +) => new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', maxHp: 10, ...extra }); test('Juggernaut is a corp-faction elite PatrolHostile with the elite glyph', () => { const jug = new Juggernaut({ id: 'juggernaut-0', x: 1, y: 1 }); diff --git a/tests/unit/game/LineOfSight.test.ts b/tests/unit/game/LineOfSight.test.ts index 1608149..6be3fb4 100644 --- a/tests/unit/game/LineOfSight.test.ts +++ b/tests/unit/game/LineOfSight.test.ts @@ -200,7 +200,7 @@ test('hasLineOfSight ignores blockers sitting on the endpoints (target stays sho test('hasLineOfSight is blocked by walls regardless of the blocker set', () => { const g = new Grid(8, 8); g.setTile(3, 1, TILE.WALL); - const blockers = new Set(); + const blockers = new Set(); assert.equal(hasLineOfSight(g, 1, 1, 5, 1, { blockers }), false); }); diff --git a/tests/unit/game/Lookout.test.ts b/tests/unit/game/Lookout.test.ts index eae4189..efd9a62 100644 --- a/tests/unit/game/Lookout.test.ts +++ b/tests/unit/game/Lookout.test.ts @@ -21,13 +21,13 @@ import { Rng } from '../../../src/rng.js'; const openWorld = (w = 14, h = 8) => new World(new Grid(w, h), { events: new EventBus() }); /** Capture every ALARM payload emitted on the world bus. */ -const captureAlarms = world => { - const seen = []; - world.events.on(EVENT.ALARM, payload => seen.push(payload)); +const captureAlarms = (world: World) => { + const seen: Record[] = []; + world.events!.on(EVENT.ALARM, payload => seen.push(payload as Record)); return seen; }; -const addPlayer = (world, x, y) => { +const addPlayer = (world: World, x: number, y: number) => { const player = new Entity({ id: 'player', x, y, faction: FACTION.PLAYER, glyph: '@' }); world.addEntity(player); return player; @@ -84,7 +84,7 @@ test('a subscribed patrol hostile force-engages on a lookout ping (fresh coords) // lookout's ping (it subscribes to ALARM; the lookout does not). const ally = new Skirmisher({ id: 'drone-0', x: 12, y: 7 }); world.addEntity(ally); - ally.bindToBus(world.events); + ally.bindToBus(world.events!); lookout.takeTurn(world, new Rng(1)); assert.equal(ally.state, PATROL_STATE.ENGAGE, 'ally re-engages on the shared target'); diff --git a/tests/unit/game/Medic.test.ts b/tests/unit/game/Medic.test.ts index 62bdb04..47175ef 100644 --- a/tests/unit/game/Medic.test.ts +++ b/tests/unit/game/Medic.test.ts @@ -18,7 +18,7 @@ import { Rng } from '../../../src/rng.js'; const openWorld = () => new World(new Grid(14, 8)); -const addPlayer = (world, x = 2, y = 2) => { +const addPlayer = (world: World, x = 2, y = 2) => { const player = new Entity({ id: 'player', x, y, faction: FACTION.PLAYER, glyph: '@' }); world.addEntity(player); return player; diff --git a/tests/unit/game/Merc.test.ts b/tests/unit/game/Merc.test.ts index d22de44..9437f6d 100644 --- a/tests/unit/game/Merc.test.ts +++ b/tests/unit/game/Merc.test.ts @@ -8,7 +8,11 @@ import { World } from '../../../src/game/World.js'; import { TILE, FACTION, AP_COST } from '../../../src/game/constants.js'; import { ConsumablePickup } from '../../../src/game/entities/ConsumablePickup.js'; -const makeWorld = ({ grid, mercAt = [3, 3], extraEntities = [] } = {}) => { +const makeWorld = ({ + grid, + mercAt = [3, 3], + extraEntities = [], +}: { grid?: Grid; mercAt?: [number, number]; extraEntities?: Entity[] } = {}) => { const g = grid ?? new Grid(8, 8); const w = new World(g); const merc = new Merc({ id: 'merc', x: mercAt[0], y: mercAt[1], glyph: '@' }); diff --git a/tests/unit/game/Pathfinding.test.ts b/tests/unit/game/Pathfinding.test.ts index 4ef1007..04ec5c2 100644 --- a/tests/unit/game/Pathfinding.test.ts +++ b/tests/unit/game/Pathfinding.test.ts @@ -113,6 +113,7 @@ test('findPath honours maxSteps cap', () => { test('findPath throws on non-integer or out-of-bounds endpoints', () => { const w = openWorld(); assert.throws(() => findPath(w, { x: 0.5, y: 1 }, { x: 4, y: 4 }), TypeError); + // @ts-expect-error Verify runtime validation of a non-numeric coordinate. assert.throws(() => findPath(w, { x: 1, y: 1 }, { x: 4, y: 'oops' }), TypeError); assert.throws(() => findPath(w, { x: -1, y: 1 }, { x: 4, y: 4 }), RangeError); assert.throws(() => findPath(w, { x: 1, y: 1 }, { x: 99, y: 4 }), RangeError); diff --git a/tests/unit/game/Razor.test.ts b/tests/unit/game/Razor.test.ts index 389358d..f09119b 100644 --- a/tests/unit/game/Razor.test.ts +++ b/tests/unit/game/Razor.test.ts @@ -8,7 +8,17 @@ import { World } from '../../../src/game/World.js'; import { TILE, FACTION, AP_COST, STATUS_EFFECT } from '../../../src/game/constants.js'; import { EventBus, EVENT } from '../../../src/game/events.js'; -const makeWorld = ({ grid, razorAt = [3, 3], extraEntities = [], bus = null } = {}) => { +const makeWorld = ({ + grid, + razorAt = [3, 3], + extraEntities = [], + bus = null, +}: { + grid?: Grid; + razorAt?: [number, number]; + extraEntities?: Entity[]; + bus?: EventBus | null; +} = {}) => { const g = grid ?? new Grid(8, 8); const w = new World(g, bus ? { events: bus } : {}); const razor = new Razor({ id: 'razor', x: razorAt[0], y: razorAt[1] }); @@ -123,8 +133,8 @@ test('Razor.slide throws on illegal slide and leaves state untouched', () => { test('Razor.slide emits entity:moved with the from/to delta', () => { const bus = new EventBus(); const { world, razor } = makeWorld({ bus }); - const moves = []; - bus.on(EVENT.ENTITY_MOVED, payload => moves.push(payload)); + const moves: Record[] = []; + bus.on(EVENT.ENTITY_MOVED, payload => moves.push(payload as Record)); razor.slide(world, 1, 0); assert.equal(moves.length, 1); assert.deepEqual(moves[0].from, { x: 3, y: 3 }); @@ -134,8 +144,8 @@ test('Razor.slide emits entity:moved with the from/to delta', () => { test('Razor.slide does NOT emit a noise event (perk is silent)', () => { const bus = new EventBus(); const { world, razor } = makeWorld({ bus }); - const noises = []; - bus.on(EVENT.NOISE, payload => noises.push(payload)); + const noises: Record[] = []; + bus.on(EVENT.NOISE, payload => noises.push(payload as Record)); razor.slide(world, 1, 0); assert.deepEqual(noises, [], 'slide is silent — that is the whole point'); }); diff --git a/tests/unit/game/Run.test.ts b/tests/unit/game/Run.test.ts index 18e8783..20bee43 100644 --- a/tests/unit/game/Run.test.ts +++ b/tests/unit/game/Run.test.ts @@ -1,8 +1,16 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Run, RUN_STATE, OUTCOME, isObjectiveSatisfied } from '../../../src/game/Run.js'; -import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { + Run, + RUN_STATE, + OUTCOME, + isObjectiveSatisfied, + type CrewArchetypeId, + type RunResult, + type RunSnapshot, +} from '../../../src/game/Run.js'; +import { OBJECTIVES, type Contract } from '../../../src/game/hub/Curator.js'; import { FACTION, SALVAGE_DROP_MIN, SALVAGE_DROP_MAX } from '../../../src/game/constants.js'; import { totalSalvage, emptySalvage } from '../../../src/game/salvage.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; @@ -26,23 +34,26 @@ import { ITEM_ID } from '../../../src/game/items.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; import { Chimera } from '../../../src/game/archetypes/Chimera.js'; +import { Lookout } from '../../../src/game/ai/Lookout.js'; +import { Entity } from '../../../src/game/Entity.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'test job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; -const terminalSliceContract = (overrides = {}) => +const terminalSliceContract = (overrides: Partial = {}) => fakeContract({ objective: { kind: OBJECTIVES.TERMINAL_SLICE, @@ -55,22 +66,22 @@ const terminalSliceContract = (overrides = {}) => ...overrides, }); -function makeCrew(archetype = 'razor') { +function makeCrew(archetype: CrewArchetypeId = 'razor') { return buildCrewMember(archetype, { x: 0, y: 0 }, new Rng(100), { id: `crew-${archetype}`, }); } -function relocateAdjacentTo(run, entity) { +function relocateAdjacentTo(run: Run, entity: Entity) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player!, x, y); return; } } @@ -173,11 +184,11 @@ test('enterCombat passes contract threat and difficulty into map generation', () // composition fills each anchor with a skirmisher (`drone-`) or a guard // (`guard-`). Count both so the assertion tracks the threat budget rather // than a single class. - const fodder = [...run.world.entities.values()].filter( + const fodder = [...run.world!.entities.values()].filter( entity => entity.id.startsWith('drone-') || entity.id.startsWith('guard-') ); assert.equal(fodder.length, 4); - const elites = [...run.world.entities.values()].filter( + const elites = [...run.world!.entities.values()].filter( entity => entity instanceof Bruiser || entity instanceof Juggernaut || entity instanceof Flanker ); assert.equal(elites.length, 1, 'CRITICAL contracts spawn one T3 elite anchor'); @@ -190,7 +201,7 @@ test('STANDARD encounter fills fodder anchors with a deterministic skirmisher/gu const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 42, difficulty: 'standard', threatCount: 3 })); run.enterCombat(); - const ids = [...run.world.entities.values()].map(e => e.id); + const ids = [...run.world!.entities.values()].map(e => e.id); assert.equal(ids.filter(id => id.startsWith('guard-')).length, 2); assert.equal(ids.filter(id => id.startsWith('drone-')).length, 1); assert.equal(ids.filter(id => id.startsWith('lookout-')).length, 0, 'STANDARD has no specialist'); @@ -202,12 +213,12 @@ test('ELEVATED encounter spawns fodder plus exactly one specialist', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 7, difficulty: 'elevated', threatCount: 3 })); run.enterCombat(); - const specialists = [...run.world.entities.values()].filter( + const specialists = [...run.world!.entities.values()].filter( e => e.id.startsWith('lookout-') || e.id.startsWith('sniper-') ); assert.equal(specialists.length, 1, 'exactly one T2 specialist'); assert.equal(specialists[0].constructor.name, 'Sniper'); - const fodder = [...run.world.entities.values()].filter( + const fodder = [...run.world!.entities.values()].filter( e => e.id.startsWith('drone-') || e.id.startsWith('guard-') ); assert.equal(fodder.length, 3, 'fodder count still tracks threatCount'); @@ -217,7 +228,9 @@ test('a spawned Lookout round-trips through a run snapshot', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 3, difficulty: 'elevated', threatCount: 3 })); run.enterCombat(); - const before = [...run.world.entities.values()].find(e => e.id.startsWith('lookout-')); + const before = [...run.world!.entities.values()].find( + (e): e is Lookout => e.id.startsWith('lookout-') && e instanceof Lookout + ); assert.ok(before, 'lookout present pre-snapshot'); before.state = 'investigate'; before.lastKnownTarget = { x: before.x, y: before.y }; @@ -228,7 +241,9 @@ test('a spawned Lookout round-trips through a run snapshot', () => { 'lookout serialised under its own archetype' ); const { world } = restore(rec); - const after = [...world.entities.values()].find(e => e.id.startsWith('lookout-')); + const after = [...world.entities.values()].find( + (e): e is Lookout => e.id.startsWith('lookout-') && e instanceof Lookout + ); assert.ok(after, 'lookout survives the round-trip'); assert.equal(after.constructor.name, 'Lookout'); assert.equal(after.x, before.x); @@ -241,7 +256,7 @@ test('a spawned Sniper round-trips aimTargetId through a run snapshot', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 7, difficulty: 'elevated', threatCount: 3 })); run.enterCombat(); - const before = [...run.world.entities.values()].find(e => e.id.startsWith('sniper-')); + const before = [...run.world!.entities.values()].find(e => e.id.startsWith('sniper-')); assert.ok(before instanceof Sniper, 'sniper present pre-snapshot'); before.aimTargetId = run.player!.id; @@ -262,7 +277,7 @@ test('a spawned Bruiser round-trips through a run snapshot', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 0, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const before = [...run.world.entities.values()].find(e => e instanceof Bruiser); + const before = [...run.world!.entities.values()].find(e => e instanceof Bruiser); assert.ok(before instanceof Bruiser, 'bruiser present pre-snapshot'); before.state = 'investigate'; before.lastKnownTarget = { x: before.x, y: before.y }; @@ -286,7 +301,7 @@ test('a spawned Juggernaut round-trips through a run snapshot', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 1, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const before = [...run.world.entities.values()].find(e => e instanceof Juggernaut); + const before = [...run.world!.entities.values()].find(e => e instanceof Juggernaut); assert.ok(before instanceof Juggernaut, 'juggernaut present pre-snapshot'); before.state = 'investigate'; before.lastKnownTarget = { x: before.x, y: before.y }; @@ -310,7 +325,7 @@ test('a spawned Flanker round-trips slide conceal through a run snapshot', () => const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 2, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const before = [...run.world.entities.values()].find(e => e instanceof Flanker); + const before = [...run.world!.entities.values()].find(e => e instanceof Flanker); assert.ok(before instanceof Flanker, 'flanker present pre-snapshot'); before.state = 'investigate'; before.lastKnownTarget = { x: before.x, y: before.y }; @@ -348,10 +363,10 @@ test('hostile-all sweep is not satisfied while a guard remains alive', () => { }) ); run.enterCombat(); - const fodder = [...run.world.entities.values()].filter( + const fodder = [...run.world!.entities.values()].filter( e => e.id.startsWith('drone-') || e.id.startsWith('guard-') ); - const turret = [...run.world.entities.values()].find(e => e instanceof CorpTurret); + const turret = [...run.world!.entities.values()].find(e => e instanceof CorpTurret); assert.ok(turret, 'hostile-all sweep places an ambient turret that counts as hostile'); // Kill only the skirmishers — guards still hold the room. for (const e of fodder) if (e.id.startsWith('drone-')) e.damage(e.hp); @@ -367,18 +382,18 @@ test('a killed guard drops scrap salvage', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 42, difficulty: 'standard', threatCount: 3 })); run.enterCombat(); - const guard = [...run.world.entities.values()].find(e => e.id.startsWith('guard-')); + const guard = [...run.world!.entities.values()].find(e => e.id.startsWith('guard-')); assert.ok(guard, 'seed 42 rolls at least one guard'); // Teleport the player adjacent and swing; the run's combat listener assigns // loot on a kill. dodgeChance 0 forces a connect. - run.player.x = guard.x + 1; - run.player.y = guard.y; + run.player!.x = guard.x + 1; + run.player!.y = guard.y; guard.hp = 1; // one swing kills regardless of melee tuning - resolveMelee(run.world, run.player, guard, new Rng(1), { dodgeChance: 0 }); + resolveMelee(run.world!, run.player!, guard, new Rng(1), { dodgeChance: 0 }); assert.ok(!guard.alive, 'guard down'); assert.ok(guard.loot, 'killed guard received loot'); - assert.ok(totalSalvage(guard.loot.salvage) > 0); - assert.ok(guard.loot.salvage.scrap > 0, 'fodder drops scrap'); + assert.ok(totalSalvage(guard.loot!.salvage) > 0); + assert.ok(guard.loot!.salvage.scrap > 0, 'fodder drops scrap'); }); test('terminal-slice contract spawns a terminal and gates objective satisfaction', () => { @@ -386,22 +401,22 @@ test('terminal-slice contract spawns a terminal and gates objective satisfaction run.enterBriefing(terminalSliceContract()); run.enterCombat(); - const terminal = [...run.world.entities.values()].find(entity => entity instanceof Terminal); + const terminal = [...run.world!.entities.values()].find(entity => entity instanceof Terminal); assert.ok(terminal, 'terminal-slice combat map should include a terminal'); assert.equal(terminal.glyph, '‡'); assert.ok( - Math.max(Math.abs(terminal.x - run.exitTile.x), Math.abs(terminal.y - run.exitTile.y)) > 1, + Math.max(Math.abs(terminal.x - run.exitTile!.x), Math.abs(terminal.y - run.exitTile!.y)) > 1, 'terminal should not spawn adjacent to extraction' ); - assert.equal(isObjectiveSatisfied(run.contract, run.world), false); + assert.equal(isObjectiveSatisfied(run.contract!, run.world!), false); relocateAdjacentTo(run, terminal); - const result = terminal.interact(run.world, run.player); + const result = terminal.interact(run.world!, run.player!); assert.equal(result.ok, true); assert.equal(terminal.sliced, true); - assert.equal(run.world.alarm.phase, 'alert'); - assert.equal(isObjectiveSatisfied(run.contract, run.world), true); + assert.equal(run.world!.alarm.phase, 'alert'); + assert.equal(isObjectiveSatisfied(run.contract!, run.world!), true); }); test('terminal-slice placement never blocks the route from spawn to exit', () => { @@ -409,7 +424,7 @@ test('terminal-slice placement never blocks the route from spawn to exit', () => const run = new Run({ crewMember: makeCrew('razor'), seed }); run.enterBriefing(terminalSliceContract({ seed })); run.enterCombat(); - const path = findPath(run.world, run.player, run.exitTile, { allowOccupiedGoal: false }); + const path = findPath(run.world!, run.player!, run.exitTile!, { allowOccupiedGoal: false }); assert.ok( path && path.length > 0, `seed ${seed.toString(16)}: exit unreachable after terminal placement` @@ -423,7 +438,7 @@ test('terminal-slice terminal placement varies across contract seeds', () => { const run = new Run({ crewMember: makeCrew('razor'), seed }); run.enterBriefing(terminalSliceContract({ seed })); run.enterCombat(); - const terminal = [...run.world.entities.values()].find(entity => entity instanceof Terminal); + const terminal = [...run.world!.entities.values()].find(entity => entity instanceof Terminal); assert.ok(terminal, 'terminal-slice combat map should include a terminal'); positions.add(`${terminal.x},${terminal.y}`); } @@ -467,36 +482,37 @@ test('enterResult rejects unknown outcomes', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); + // @ts-expect-error Runtime validation must reject an unknown outcome. assert.throws(() => run.enterResult({ outcome: 'undecided' })); }); test('turn:ended in COMBAT triggers onPersist with a snapshot record', () => { - const records = []; + const records: RunSnapshot[] = []; const run = new Run({ crewMember: makeCrew('razor'), seed: 1, - onPersist: rec => records.push(rec), + onPersist: (rec: RunSnapshot) => records.push(rec), }); run.enterBriefing(fakeContract()); run.enterCombat(); assert.equal(records.length, 0, 'no persist before any turn ends'); - run.queue.endTurn(run.world); + run.queue!.endTurn(run.world!); assert.equal(records.length, 1, 'one persist after one turn end'); const rec = records[0]; assert.equal(rec.type, 'run'); assert.equal(rec.state, RUN_STATE.COMBAT); assert.equal(rec.archetype, 'razor'); - assert.equal(rec.turnNumber, run.queue.turnNumber); + assert.equal(rec.turnNumber, run.queue!.turnNumber); assert.equal(rec.currentFaction, FACTION.CORP); }); test('enterResult persists RESULT snapshot before onResult (no stale COMBAT save)', () => { - const order = []; - const persists = []; + const order: string[] = []; + const persists: RunSnapshot[] = []; const run = new Run({ crewMember: makeCrew('razor'), seed: 1, - onPersist: rec => { + onPersist: (rec: RunSnapshot) => { order.push('persist'); persists.push(rec); }, @@ -512,19 +528,19 @@ test('enterResult persists RESULT snapshot before onResult (no stale COMBAT save }); test('player-killed entity:damaged transitions to RESULT(DEATH)', () => { - const results = []; + const results: RunResult[] = []; const run = new Run({ crewMember: makeCrew('razor'), seed: 1, - onResult: r => results.push(r), + onResult: (r: RunResult) => results.push(r), }); run.enterBriefing(fakeContract()); run.enterCombat(); - run.player.damage(run.player.hp); - run.bus.emit('entity:damaged', { + run.player!.damage(run.player!.hp); + run.bus!.emit('entity:damaged', { attacker: { id: 'drone-0', faction: FACTION.CORP }, target: run.player, - damage: run.player.maxHp, + damage: run.player!.maxHp, killed: true, source: 'ranged', }); @@ -538,9 +554,9 @@ test('player kill of a corp entity increments telemetry.kills', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone, 'expected at least one corp drone for threatCount=1'); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: drone, damage: 99, @@ -555,10 +571,15 @@ test('Tech turret kill increments telemetry.kills when ownerId matches player', const run = new Run({ crewMember: makeCrew('tech'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone); - const turret = new Turret({ id: `${run.player.id}-turret`, x: 1, y: 1, ownerId: run.player.id }); - run.bus.emit('entity:damaged', { + const turret = new Turret({ + id: `${run.player!.id}-turret`, + x: 1, + y: 1, + ownerId: run.player!.id, + }); + run.bus!.emit('entity:damaged', { attacker: turret, target: drone, damage: 1, @@ -570,18 +591,18 @@ test('Tech turret kill increments telemetry.kills when ownerId matches player', }); test('reaching the exit tile transitions to RESULT(EXIT)', () => { - const results = []; + const results: RunResult[] = []; const run = new Run({ crewMember: makeCrew('razor'), seed: 99, - onResult: r => results.push(r), + onResult: (r: RunResult) => results.push(r), }); run.enterBriefing(fakeContract()); run.enterCombat(); - run.bus.emit('entity:moved', { + run.bus!.emit('entity:moved', { entity: run.player, - from: { x: run.player.x, y: run.player.y }, - to: { x: run.exitTile.x, y: run.exitTile.y }, + from: { x: run.player!.x, y: run.player!.y }, + to: { x: run.exitTile!.x, y: run.exitTile!.y }, }); assert.equal(run.state, RUN_STATE.RESULT); assert.equal(results[0].outcome, OUTCOME.EXIT); @@ -593,10 +614,10 @@ test('killing a corp entity assigns loot to the target', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone); drone.damage(drone.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: drone, damage: drone.maxHp, @@ -606,10 +627,10 @@ test('killing a corp entity assigns loot to the target', () => { assert.ok(drone.loot, 'killed drone should have loot assigned'); // M4.2: drone loot is typed — scrap-only for drones; total stays in the // configured drop range. - assert.equal(drone.loot.salvage.chips, 0, 'drone loot has no chips'); - assert.equal(drone.loot.salvage.bio, 0, 'drone loot has no bio'); - assert.equal(drone.loot.salvage.data, 0, 'drone loot has no data'); - const total = totalSalvage(drone.loot.salvage); + assert.equal(drone.loot!.salvage.chips, 0, 'drone loot has no chips'); + assert.equal(drone.loot!.salvage.bio, 0, 'drone loot has no bio'); + assert.equal(drone.loot!.salvage.data, 0, 'drone loot has no data'); + const total = totalSalvage(drone.loot!.salvage); assert.ok( total >= SALVAGE_DROP_MIN && total <= SALVAGE_DROP_MAX, `salvage total ${total} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` @@ -620,11 +641,16 @@ test('killing a corp entity via turret also assigns loot', () => { const run = new Run({ crewMember: makeCrew('tech'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone); - const turret = new Turret({ id: `${run.player.id}-turret`, x: 1, y: 1, ownerId: run.player.id }); + const turret = new Turret({ + id: `${run.player!.id}-turret`, + x: 1, + y: 1, + ownerId: run.player!.id, + }); drone.damage(drone.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: turret, target: drone, damage: 1, @@ -632,7 +658,7 @@ test('killing a corp entity via turret also assigns loot', () => { source: 'ranged', }); assert.ok(drone.loot, 'turret-killed drone should have loot'); - assert.ok(totalSalvage(drone.loot.salvage) >= SALVAGE_DROP_MIN); + assert.ok(totalSalvage(drone.loot!.salvage) >= SALVAGE_DROP_MIN); }); test('killing a CorpTurret drops chips, not scrap (M4.2)', () => { @@ -641,20 +667,23 @@ test('killing a CorpTurret drops chips, not scrap (M4.2)', () => { run.enterCombat(); // Place a CorpTurret on a known floor tile near the player and kill it via // the same damage-emit path that drone kills use. - const player = run.player; + const player = run.player!; const turret = new CorpTurret({ id: 'corp-turret-loot-test', x: player.x + 2, y: player.y, }); // Find a passable tile if (x+2, y) is blocked — bumping is fine for the test. - while (!run.world.grid.isPassable(turret.x, turret.y) || run.world.entityAt(turret.x, turret.y)) { + while ( + !run.world!.grid.isPassable(turret.x, turret.y) || + run.world!.entityAt(turret.x, turret.y) + ) { turret.x++; - if (turret.x >= run.world.grid.w) throw new Error('no passable tile for CorpTurret'); + if (turret.x >= run.world!.grid.width) throw new Error('no passable tile for CorpTurret'); } - run.world.addEntity(turret); + run.world!.addEntity(turret); turret.damage(turret.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: turret, damage: turret.maxHp, @@ -662,12 +691,13 @@ test('killing a CorpTurret drops chips, not scrap (M4.2)', () => { source: 'ranged', }); assert.ok(turret.loot, 'killed corp turret should have loot assigned'); - assert.equal(turret.loot.salvage.scrap, 0, 'turret loot has no scrap'); - assert.equal(turret.loot.salvage.bio, 0, 'turret loot has no bio'); - assert.equal(turret.loot.salvage.data, 0, 'turret loot has no data'); + assert.equal(turret.loot!.salvage.scrap, 0, 'turret loot has no scrap'); + assert.equal(turret.loot!.salvage.bio, 0, 'turret loot has no bio'); + assert.equal(turret.loot!.salvage.data, 0, 'turret loot has no data'); assert.ok( - turret.loot.salvage.chips >= SALVAGE_DROP_MIN && turret.loot.salvage.chips <= SALVAGE_DROP_MAX, - `chips ${turret.loot.salvage.chips} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` + turret.loot!.salvage.chips >= SALVAGE_DROP_MIN && + turret.loot!.salvage.chips <= SALVAGE_DROP_MAX, + `chips ${turret.loot!.salvage.chips} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` ); }); @@ -675,10 +705,10 @@ test('killing a Bruiser drops bio salvage, not scrap or chips', () => { const run = new Run({ crewMember: makeCrew('merc'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 0, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const bruiser = [...run.world.entities.values()].find(e => e instanceof Bruiser); + const bruiser = [...run.world!.entities.values()].find(e => e instanceof Bruiser); assert.ok(bruiser instanceof Bruiser, 'critical job should spawn a bruiser'); bruiser.damage(bruiser.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: bruiser, damage: bruiser.maxHp, @@ -686,12 +716,12 @@ test('killing a Bruiser drops bio salvage, not scrap or chips', () => { source: 'ranged', }); assert.ok(bruiser.loot, 'killed bruiser should have loot assigned'); - assert.equal(bruiser.loot.salvage.scrap, 0, 'bruiser loot has no scrap'); - assert.equal(bruiser.loot.salvage.chips, 0, 'bruiser loot has no chips'); - assert.equal(bruiser.loot.salvage.data, 0, 'bruiser loot has no data'); + assert.equal(bruiser.loot!.salvage.scrap, 0, 'bruiser loot has no scrap'); + assert.equal(bruiser.loot!.salvage.chips, 0, 'bruiser loot has no chips'); + assert.equal(bruiser.loot!.salvage.data, 0, 'bruiser loot has no data'); assert.ok( - bruiser.loot.salvage.bio >= SALVAGE_DROP_MIN && bruiser.loot.salvage.bio <= SALVAGE_DROP_MAX, - `bio ${bruiser.loot.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` + bruiser.loot!.salvage.bio >= SALVAGE_DROP_MIN && bruiser.loot!.salvage.bio <= SALVAGE_DROP_MAX, + `bio ${bruiser.loot!.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` ); }); @@ -699,10 +729,10 @@ test('killing a Juggernaut drops bio salvage, not scrap or chips', () => { const run = new Run({ crewMember: makeCrew('merc'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 1, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const juggernaut = [...run.world.entities.values()].find(e => e instanceof Juggernaut); + const juggernaut = [...run.world!.entities.values()].find(e => e instanceof Juggernaut); assert.ok(juggernaut instanceof Juggernaut, 'critical job should spawn a juggernaut'); juggernaut.damage(juggernaut.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: juggernaut, damage: juggernaut.maxHp, @@ -710,13 +740,13 @@ test('killing a Juggernaut drops bio salvage, not scrap or chips', () => { source: 'ranged', }); assert.ok(juggernaut.loot, 'killed juggernaut should have loot assigned'); - assert.equal(juggernaut.loot.salvage.scrap, 0, 'juggernaut loot has no scrap'); - assert.equal(juggernaut.loot.salvage.chips, 0, 'juggernaut loot has no chips'); - assert.equal(juggernaut.loot.salvage.data, 0, 'juggernaut loot has no data'); + assert.equal(juggernaut.loot!.salvage.scrap, 0, 'juggernaut loot has no scrap'); + assert.equal(juggernaut.loot!.salvage.chips, 0, 'juggernaut loot has no chips'); + assert.equal(juggernaut.loot!.salvage.data, 0, 'juggernaut loot has no data'); assert.ok( - juggernaut.loot.salvage.bio >= SALVAGE_DROP_MIN && - juggernaut.loot.salvage.bio <= SALVAGE_DROP_MAX, - `bio ${juggernaut.loot.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` + juggernaut.loot!.salvage.bio >= SALVAGE_DROP_MIN && + juggernaut.loot!.salvage.bio <= SALVAGE_DROP_MAX, + `bio ${juggernaut.loot!.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` ); }); @@ -724,10 +754,10 @@ test('killing a Flanker drops bio salvage, not scrap or chips', () => { const run = new Run({ crewMember: makeCrew('merc'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 2, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const flanker = [...run.world.entities.values()].find(e => e instanceof Flanker); + const flanker = [...run.world!.entities.values()].find(e => e instanceof Flanker); assert.ok(flanker instanceof Flanker, 'critical job should spawn a flanker'); flanker.damage(flanker.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: flanker, damage: flanker.maxHp, @@ -735,12 +765,12 @@ test('killing a Flanker drops bio salvage, not scrap or chips', () => { source: 'ranged', }); assert.ok(flanker.loot, 'killed flanker should have loot assigned'); - assert.equal(flanker.loot.salvage.scrap, 0, 'flanker loot has no scrap'); - assert.equal(flanker.loot.salvage.chips, 0, 'flanker loot has no chips'); - assert.equal(flanker.loot.salvage.data, 0, 'flanker loot has no data'); + assert.equal(flanker.loot!.salvage.scrap, 0, 'flanker loot has no scrap'); + assert.equal(flanker.loot!.salvage.chips, 0, 'flanker loot has no chips'); + assert.equal(flanker.loot!.salvage.data, 0, 'flanker loot has no data'); assert.ok( - flanker.loot.salvage.bio >= SALVAGE_DROP_MIN && flanker.loot.salvage.bio <= SALVAGE_DROP_MAX, - `bio ${flanker.loot.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` + flanker.loot!.salvage.bio >= SALVAGE_DROP_MIN && flanker.loot!.salvage.bio <= SALVAGE_DROP_MAX, + `bio ${flanker.loot!.salvage.bio} outside [${SALVAGE_DROP_MIN}, ${SALVAGE_DROP_MAX}]` ); }); @@ -748,9 +778,9 @@ test('non-lethal damage does not assign loot', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: drone, damage: 1, @@ -762,21 +792,22 @@ test('non-lethal damage does not assign loot', () => { test('loot rolls are deterministic across seeds', () => { // Two runs with the same seed should produce the same loot roll. - const loots = []; + const loots: number[] = []; for (let i = 0; i < 2; i++) { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract()); run.enterCombat(); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); + assert.ok(drone); drone.damage(drone.maxHp); - run.bus.emit('entity:damaged', { + run.bus!.emit('entity:damaged', { attacker: run.player, target: drone, damage: drone.maxHp, killed: true, source: 'ranged', }); - loots.push(totalSalvage(drone.loot.salvage)); + loots.push(totalSalvage(drone.loot!.salvage)); } assert.equal(loots[0], loots[1], 'same seed should produce same loot'); }); @@ -785,10 +816,10 @@ test('player inventory is initialised at job deploy (enterCombat)', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42 }); run.enterBriefing(fakeContract()); run.enterCombat(); - assert.ok(run.player.inventory, 'inventory should be initialised'); + assert.ok(run.player!.inventory, 'inventory should be initialised'); // M4.2: fresh inventory has a typed-empty wallet. - assert.deepEqual(run.player.inventory.salvage, emptySalvage()); - assert.deepEqual(run.player.inventory.consumables, []); + assert.deepEqual(run.player!.inventory.salvage, emptySalvage()); + assert.deepEqual(run.player!.inventory.consumables, []); }); test('Run places deterministic consumable pickups from the contract seed', () => { @@ -799,8 +830,8 @@ test('Run places deterministic consumable pickups from the contract seed', () => second.enterBriefing(fakeContract({ seed: 4 })); second.enterCombat(); - const serialize = run => - [...run.world.entities.values()] + const serialize = (run: Run) => + [...run.world!.entities.values()] .filter(entity => entity instanceof ConsumablePickup) .map(pickup => ({ id: pickup.id, @@ -819,7 +850,7 @@ test('Run snapshot/restore preserves on-map consumable pickups', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 1 }); run.enterBriefing(fakeContract({ seed: 4 })); run.enterCombat(); - const before = [...run.world.entities.values()] + const before = [...run.world!.entities.values()] .filter(entity => entity instanceof ConsumablePickup) .map(pickup => ({ id: pickup.id, diff --git a/tests/unit/game/Skirmisher.test.ts b/tests/unit/game/Skirmisher.test.ts index 06741f4..99243a6 100644 --- a/tests/unit/game/Skirmisher.test.ts +++ b/tests/unit/game/Skirmisher.test.ts @@ -21,12 +21,16 @@ import { Rng } from '../../../src/rng.js'; * Lets each combat-touching test pin hit/miss outcomes without coupling to * mulberry32 state. */ -class StubRng { - constructor(values) { +class StubRng extends Rng { + values: number[]; + calls: number; + + constructor(values: number[]) { + super(0); this.values = [...values]; this.calls = 0; } - next() { + override next() { if (this.calls >= this.values.length) { throw new Error('StubRng drained — test under-supplied rolls'); } @@ -170,7 +174,7 @@ test('drone investigates last known position when target leaves LOS', () => { // x=4 and x=6, the drone needs to detour through y=1 or y=3. assert.notEqual(drone.state, PATROL_STATE.ENGAGE); assert.ok( - drone.state === PATROL_STATE.INVESTIGATE || drone.state === PATROL_STATE.PATROL, + new Set([PATROL_STATE.INVESTIGATE, PATROL_STATE.PATROL]).has(drone.state), `unexpected state ${drone.state}` ); // Either the drone moved (investigating) or marked the lead abandoned. @@ -248,6 +252,7 @@ test('Skirmisher constructor rejects malformed waypoints', () => { TypeError ); assert.throws( + // @ts-expect-error Verify runtime validation of a non-array waypoint list. () => new Skirmisher({ id: 'd', x: 1, y: 1, patrolWaypoints: 'not-an-array' }), TypeError ); @@ -357,12 +362,16 @@ test('takeTurnSteps pauses mid-turn — caller can inspect state between yields' const gen = drone.takeTurnSteps(w, new StubRng([0])); const first = gen.next(); + assert.equal(first.done, false); + if (first.done) throw new Error('expected first turn step'); assert.equal(first.value.type, 'fire'); // After the fire yield: player is damaged, drone hasn't moved yet. assert.equal(player.hp, player.maxHp - 1); assert.equal(drone.x, 6, 'drone has not stepped yet at the fire-yield boundary'); const second = gen.next(); + assert.equal(second.done, false); + if (second.done) throw new Error('expected second turn step'); assert.equal(second.value.type, 'move-engage'); assert.ok(drone.x < 6, 'drone has now stepped'); @@ -414,7 +423,7 @@ test('takeTurnSteps does NOT crash the safety cap on unreachable patrol waypoint // Just calling `takeTurn` (which drains the generator) must not throw. // We also assert it produced *some* log entries (the skips themselves) so // a future regression that silently aborts the generator pre-yield fails too. - let log; + let log: ReturnType = []; assert.doesNotThrow(() => { log = drone.takeTurn(w, new Rng(1)); }); @@ -446,7 +455,7 @@ test('takeTurnSteps does NOT crash on co-located patrol waypoints', () => { ], }); w.addEntity(drone); - let log; + let log: ReturnType = []; assert.doesNotThrow(() => { log = drone.takeTurn(w, new Rng(1)); }); diff --git a/tests/unit/game/Sniper.test.ts b/tests/unit/game/Sniper.test.ts index a83b5a2..ebdba59 100644 --- a/tests/unit/game/Sniper.test.ts +++ b/tests/unit/game/Sniper.test.ts @@ -22,7 +22,7 @@ import { Rng } from '../../../src/rng.js'; const openWorld = (w = 18, h = 8) => new World(new Grid(w, h), { events: new EventBus() }); -const addPlayer = (world, x, y, maxHp = 5) => { +const addPlayer = (world: World, x: number, y: number, maxHp = 5) => { const player = new Entity({ id: 'player', x, y, faction: FACTION.PLAYER, glyph: '@', maxHp }); world.addEntity(player); return player; @@ -55,7 +55,7 @@ test('Sniper in range commits aim (2 AP, no NOISE) instead of firing immediately const sniper = new Sniper({ id: 'sniper-0', x: 12, y: 3 }); world.addEntity(sniper); const noises = []; - world.events.on(EVENT.NOISE, p => noises.push(p)); + world.events!.on(EVENT.NOISE, p => noises.push(p)); const log = sniper.takeTurn(world, new Rng(1)); assert.ok( @@ -77,7 +77,7 @@ test('Sniper fires the held shot next turn — guaranteed hit, heavy damage, lou sniper.takeTurn(world, new Rng(1)); // aim const noises = []; - world.events.on(EVENT.NOISE, p => noises.push(p)); + world.events!.on(EVENT.NOISE, p => noises.push(p)); sniper.refreshAp(); const log = sniper.takeTurn(world, new Rng(2)); // fire @@ -133,12 +133,12 @@ test('damage during the aim window breaks the held shot', () => { addPlayer(world, 2, 3); const sniper = new Sniper({ id: 'sniper-0', x: 12, y: 3 }); world.addEntity(sniper); - sniper.bindToBus(world.events); + sniper.bindToBus(world.events!); sniper.takeTurn(world, new Rng(1)); // aim assert.equal(sniper.aimTargetId, 'player'); // Focus fire lands on the sniper while it holds aim. - world.events.emit(EVENT.ENTITY_DAMAGED, { target: sniper, damage: 1 }); + world.events!.emit(EVENT.ENTITY_DAMAGED, { target: sniper, damage: 1 }); assert.equal(sniper.aimTargetId, null, 'the held shot is broken'); // Next turn it must re-acquire and re-aim, not fire. diff --git a/tests/unit/game/Tech.test.ts b/tests/unit/game/Tech.test.ts index 13345a3..715dfd6 100644 --- a/tests/unit/game/Tech.test.ts +++ b/tests/unit/game/Tech.test.ts @@ -28,7 +28,15 @@ import { import { ITEM_ID } from '../../../src/game/items.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; -function makeWorld({ techAt = [3, 3], grid, extraEntities = [] } = {}) { +function makeWorld({ + techAt = [3, 3], + grid, + extraEntities = [], +}: { + techAt?: [number, number]; + grid?: Grid; + extraEntities?: Entity[]; +} = {}) { const g = grid ?? new Grid(8, 8); const w = new World(g); const tech = new Tech({ id: 'tech', x: techAt[0], y: techAt[1] }); @@ -169,7 +177,17 @@ test('Tech.deployTurret blocks a second deploy in the same job (no double-drop)' // --- M3: improvised turrets ----------------------------------------------- -function makeWorldWithInventory({ techAt = [3, 3], grid, salvage = 4, extraEntities = [] } = {}) { +function makeWorldWithInventory({ + techAt = [3, 3], + grid, + salvage = 4, + extraEntities = [], +}: { + techAt?: [number, number]; + grid?: Grid; + salvage?: number; + extraEntities?: Entity[]; +} = {}) { const g = grid ?? new Grid(8, 8); const w = new World(g); const tech = new Tech({ id: 'tech', x: techAt[0], y: techAt[1] }); @@ -177,7 +195,7 @@ function makeWorldWithInventory({ techAt = [3, 3], grid, salvage = 4, extraEntit // M4.2: improvised turrets cost scrap specifically. The `salvage` knob in // this helper now drives the scrap bucket so the existing test names ("with // salvage", "no salvage") keep their original meaning. - tech.inventory.salvage = makeSalvage({ scrap: salvage }); + tech.inventory!.salvage = makeSalvage({ scrap: salvage }); w.addEntity(tech); for (const e of extraEntities) w.addEntity(e); return { world: w, tech }; @@ -234,7 +252,7 @@ test('Tech.improviseTurret commits: deducts salvage + AP, places turret', () => const { world, tech } = makeWorldWithInventory({ salvage: 4 }); tech.turretReady = false; const apBefore = tech.ap; - const scrapBefore = tech.inventory.salvage.scrap; + const scrapBefore = tech.inventory!.salvage.scrap; const turret = tech.improviseTurret(world, 1, 0); assert.ok(turret instanceof Turret); assert.equal(turret.x, 4); @@ -242,7 +260,7 @@ test('Tech.improviseTurret commits: deducts salvage + AP, places turret', () => assert.equal(turret.faction, FACTION.PLAYER); assert.equal(tech.ap, apBefore - AP_COST.DEPLOY); assert.equal( - tech.inventory.salvage.scrap, + tech.inventory!.salvage.scrap, scrapBefore - SALVAGE_PER_IMPROVISED_TURRET, 'scrap deducted by improvise cost' ); @@ -256,7 +274,7 @@ test('Tech.improviseTurret throws on illegal pre-conditions without mutating sta assert.throws(() => tech.improviseTurret(world, 1, 0), /Illegal/); assert.equal(tech.ap, apBefore, 'AP not debited on illegal improvise'); assert.equal( - totalSalvage(tech.inventory.salvage), + totalSalvage(tech.inventory!.salvage), 0, 'salvage wallet untouched on illegal improvise' ); diff --git a/tests/unit/game/TurnQueue.test.ts b/tests/unit/game/TurnQueue.test.ts index 8efa4e1..b22b834 100644 --- a/tests/unit/game/TurnQueue.test.ts +++ b/tests/unit/game/TurnQueue.test.ts @@ -11,6 +11,7 @@ import { FACTION, STATUS_EFFECT } from '../../../src/game/constants.js'; test('TurnQueue requires a non-empty faction order', () => { assert.throws(() => new TurnQueue([]), TypeError); + // @ts-expect-error Verify runtime validation of a null faction order. assert.throws(() => new TurnQueue(null), TypeError); }); @@ -95,7 +96,7 @@ test('TurnQueue.endTurn emits berserk:crashed once, on the surge→crash refresh w.addEntity(berserk); berserk.surge(); - const crashes = []; + const crashes: unknown[] = []; bus.on(EVENT.BERSERK_CRASHED, payload => crashes.push(payload)); const q = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); @@ -120,7 +121,7 @@ test('TurnQueue.endTurn emits turn:ended with previous/next/turn when bus attach const bus = new EventBus(); const w = new World(new Grid(3, 3), { events: bus }); const q = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); - const events = []; + const events: unknown[] = []; bus.on(EVENT.TURN_ENDED, payload => events.push(payload)); q.endTurn(w); // PLAYER -> CORP q.endTurn(w); // CORP -> PLAYER (turn 2) diff --git a/tests/unit/game/Turret.test.ts b/tests/unit/game/Turret.test.ts index e288834..f2a0fa4 100644 --- a/tests/unit/game/Turret.test.ts +++ b/tests/unit/game/Turret.test.ts @@ -29,7 +29,7 @@ import { } from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; -function makeWorld({ grid, withBus = false } = {}) { +function makeWorld({ grid, withBus = false }: { grid?: Grid; withBus?: boolean } = {}) { const g = grid ?? new Grid(12, 12); const bus = withBus ? new EventBus() : null; const world = new World(g, bus ? { events: bus } : {}); @@ -174,8 +174,8 @@ test('Turret.autoFire commits a free shot through resolveRanged on a target hit' world.addEntity(turret); world.addEntity(drone); - const events = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => events.push(payload)); + const events: Record[] = []; + bus!.on(EVENT.ENTITY_DAMAGED, payload => events.push(payload as Record)); const rng = new Rng(1); const result = turret.autoFire(world, rng); @@ -195,6 +195,7 @@ test('Turret.autoFire throws without an Rng (crash > silent fallback)', () => { const { world } = makeWorld(); const turret = new Turret({ id: 't1', x: 3, y: 3 }); world.addEntity(turret); + // @ts-expect-error Verify runtime validation of a missing RNG. assert.throws(() => turret.autoFire(world, null), /Rng/i); }); diff --git a/tests/unit/game/Vision.test.ts b/tests/unit/game/Vision.test.ts index d19811b..aed3afe 100644 --- a/tests/unit/game/Vision.test.ts +++ b/tests/unit/game/Vision.test.ts @@ -84,6 +84,7 @@ test('VisionField.memoriseCorpse stores a glyph record at the corpse key', () => v.memoriseCorpse(corpse); assert.ok(v.memorisedCorpses.has('4,3'), 'corpse key should be memorised'); const rec = v.memorisedCorpses.get('4,3'); + assert.ok(rec); assert.equal(rec.x, 4); assert.equal(rec.y, 3); assert.equal(rec.faction, FACTION.CORP); diff --git a/tests/unit/game/World.test.ts b/tests/unit/game/World.test.ts index 791f102..cedefff 100644 --- a/tests/unit/game/World.test.ts +++ b/tests/unit/game/World.test.ts @@ -9,9 +9,13 @@ import { Pickup } from '../../../src/game/entities/Pickup.js'; import { Door } from '../../../src/game/entities/Door.js'; import { TILE, FACTION, AP_COST, moveStepApCost } from '../../../src/game/constants.js'; import { makeSalvage } from '../../../src/game/salvage.js'; +import type { EntityInit } from '../../../src/game/Entity.js'; -const makePlayer = (x, y, overrides = {}) => - new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', ...overrides }); +const makePlayer = ( + x: number, + y: number, + overrides: Partial> = {} +) => new Entity({ id: 'p', x, y, faction: FACTION.PLAYER, glyph: '@', ...overrides }); test('World.addEntity rejects duplicate ids', () => { const w = new World(new Grid(5, 5)); @@ -340,8 +344,8 @@ test('World.moveEntity emits entity:moved with from/to when an event bus is atta const w = new World(new Grid(5, 5), { events: bus }); const p = makePlayer(2, 2); w.addEntity(p); - const events = []; - bus.on(EVENT.ENTITY_MOVED, payload => events.push(payload)); + const events: Record[] = []; + bus.on(EVENT.ENTITY_MOVED, payload => events.push(payload as Record)); w.moveEntity(p, 1, 0); assert.equal(events.length, 1); assert.equal(events[0].entity, p); @@ -365,8 +369,8 @@ test('World.moveEntity emits a noise event with MOVE radius', async () => { const w = new World(new Grid(5, 5), { events: bus }); const p = makePlayer(2, 2); w.addEntity(p); - const noises = []; - bus.on(EVENT.NOISE, payload => noises.push(payload)); + const noises: Record[] = []; + bus.on(EVENT.NOISE, payload => noises.push(payload as Record)); w.moveEntity(p, 1, 0); assert.equal(noises.length, 1); assert.equal(noises[0].kind, 'move'); @@ -386,8 +390,8 @@ test('World.raiseAlarm enters alert phase and emits one alarm event', async () = const { EventBus, EVENT } = await import('../../../src/game/events.js'); const bus = new EventBus(); const w = new World(new Grid(5, 5), { events: bus }); - const alarms = []; - bus.on(EVENT.ALARM, payload => alarms.push(payload)); + const alarms: Record[] = []; + bus.on(EVENT.ALARM, payload => alarms.push(payload as Record)); const raised = w.raiseAlarm({ origin: { x: 2, y: 2 } }); const duplicate = w.raiseAlarm({ origin: { x: 3, y: 3 } }); @@ -406,8 +410,8 @@ test('World.raiseAlarm honors repPenalty: false on the emitted payload', async ( const { EventBus, EVENT } = await import('../../../src/game/events.js'); const bus = new EventBus(); const w = new World(new Grid(5, 5), { events: bus }); - const alarms = []; - bus.on(EVENT.ALARM, payload => alarms.push(payload)); + const alarms: Record[] = []; + bus.on(EVENT.ALARM, payload => alarms.push(payload as Record)); w.raiseAlarm({ origin: { x: 1, y: 1 }, repPenalty: false }); @@ -419,8 +423,8 @@ test('World alarm ticks from alert to cooldown to quiet', async () => { const { EventBus, EVENT } = await import('../../../src/game/events.js'); const bus = new EventBus(); const w = new World(new Grid(5, 5), { events: bus }); - const transitions = []; - bus.on(EVENT.ALARM_CHANGED, payload => transitions.push(payload)); + const transitions: Record[] = []; + bus.on(EVENT.ALARM_CHANGED, payload => transitions.push(payload as Record)); w.raiseAlarm(); w.tickAlarm(); @@ -454,10 +458,10 @@ test('World.moveEntity { silent: true } suppresses noise but still emits entity: const w = new World(new Grid(5, 5), { events: bus }); const p = makePlayer(2, 2); w.addEntity(p); - const moves = []; - const noises = []; - bus.on(EVENT.ENTITY_MOVED, payload => moves.push(payload)); - bus.on(EVENT.NOISE, payload => noises.push(payload)); + const moves: Record[] = []; + const noises: Record[] = []; + bus.on(EVENT.ENTITY_MOVED, payload => moves.push(payload as Record)); + bus.on(EVENT.NOISE, payload => noises.push(payload as Record)); w.moveEntity(p, 1, 0, { silent: true }); assert.equal(moves.length, 1, 'silent does not gag entity:moved (vision still updates)'); assert.deepEqual(noises, [], 'silent suppresses the noise emit'); @@ -483,7 +487,7 @@ test('World.relocateEntity emits entity:moved', async () => { const p = makePlayer(2, 2); w.addEntity(p); const events: unknown[] = []; - bus.on(EVENT.ENTITY_MOVED, payload => events.push(payload)); + bus.on(EVENT.ENTITY_MOVED, payload => events.push(payload as Record)); w.relocateEntity(p, 4, 3); assert.equal(events.length, 1); assert.deepEqual((events[0] as Record).from, { x: 2, y: 2 }); @@ -569,8 +573,8 @@ test('World.unlockDoor emits door:unlocked only when the door was locked', async y: 1, }); w.addEntity(door); - const unlocked = []; - bus.on(EVENT.DOOR_UNLOCKED, payload => unlocked.push(payload)); + const unlocked: Record[] = []; + bus.on(EVENT.DOOR_UNLOCKED, payload => unlocked.push(payload as Record)); w.unlockDoor('door-0'); w.unlockDoor('door-0'); diff --git a/tests/unit/game/archetypes.test.ts b/tests/unit/game/archetypes.test.ts index 7df1a77..f2d3ebc 100644 --- a/tests/unit/game/archetypes.test.ts +++ b/tests/unit/game/archetypes.test.ts @@ -31,7 +31,7 @@ import { CALLSIGNS as RAZOR_CALLSIGNS } from '../../../src/game/archetypes/Razor import { Rng } from '../../../src/rng.js'; test('ARCHETYPES exposes merc, razor, and tech with required metadata', () => { - for (const id of ['merc', 'razor', 'tech']) { + for (const id of ['merc', 'razor', 'tech'] as const) { const a = ARCHETYPES[id]; assert.ok(a, `missing archetype "${id}"`); assert.equal(a.id, id); @@ -106,7 +106,11 @@ test('pickCallsign rejects a non-Set exclude argument', () => { // Easy footgun: pass an Array instead of a Set. `.has` would be undefined // and the filter would silently exclude nothing — exactly the silent- // fallback shape we're avoiding. - assert.throws(() => pickCallsign('merc', new Rng(0), ['Tracer']), /must be a Set/i); + assert.throws( + // @ts-expect-error Verify runtime validation of an invalid collection type. + () => pickCallsign('merc', new Rng(0), ['Tracer']), + /must be a Set/i + ); }); test('buildCrewMember returns the right archetype class with a populated callsign', () => { @@ -117,6 +121,7 @@ test('buildCrewMember returns the right archetype class with a populated callsig assert.equal(m.y, 4); assert.equal(m.faction, FACTION.PLAYER); assert.equal(typeof m.callsign, 'string'); + assert.ok(m.callsign); assert.ok(MERC_CALLSIGNS.includes(m.callsign)); assert.equal(m.flatlined, false); }); @@ -143,13 +148,17 @@ test('buildCrewMember rejects an unknown archetype', () => { }); test('buildCrewMember rejects a malformed spawn', () => { + // @ts-expect-error Verify runtime validation of a null spawn. assert.throws(() => buildCrewMember('merc', null, new Rng(0)), /spawn/i); + // @ts-expect-error Verify runtime validation of an incomplete spawn. assert.throws(() => buildCrewMember('merc', { x: 0 }, new Rng(0)), /spawn/i); assert.throws(() => buildCrewMember('merc', { x: NaN, y: 0 }, new Rng(0)), /spawn/i); }); test('buildCrewMember rejects a missing or invalid rng', () => { + // @ts-expect-error Verify runtime validation of a missing RNG. assert.throws(() => buildCrewMember('merc', { x: 0, y: 0 }), /Rng/i); + // @ts-expect-error Verify runtime validation of a malformed RNG. assert.throws(() => buildCrewMember('merc', { x: 0, y: 0 }, {}), /Rng/i); }); @@ -206,6 +215,7 @@ test('buildCrewMember can still construct a Decker by id (recruitment path)', () const d = buildCrewMember('decker', { x: 1, y: 2 }, new Rng(9)); assert.ok(d instanceof Decker); assert.equal(isArchetypeId('decker'), true); + assert.ok(d.callsign); assert.ok(CALLSIGNS_BY_ARCHETYPE.decker.includes(d.callsign)); }); @@ -219,6 +229,7 @@ test('Berserk is registered, recruitable, and self-targeted', () => { const berserk = buildCrewMember('berserk', { x: 1, y: 2 }, new Rng(10)); assert.ok(berserk instanceof Berserk); assert.equal(isArchetypeId('berserk'), true); + assert.ok(berserk.callsign); assert.ok(BERSERK_CALLSIGNS.includes(berserk.callsign)); }); @@ -232,6 +243,7 @@ test('Adept is registered, recruitable, and directionally aimed', () => { const adept = buildCrewMember('adept', { x: 1, y: 2 }, new Rng(10)); assert.ok(adept instanceof Adept); assert.equal(isArchetypeId('adept'), true); + assert.ok(adept.callsign); assert.ok(ADEPT_CALLSIGNS.includes(adept.callsign)); }); @@ -245,6 +257,7 @@ test('Chimera is registered, recruitable, and self-targeted', () => { const chimera = buildCrewMember('chimera', { x: 1, y: 2 }, new Rng(10)); assert.ok(chimera instanceof Chimera); assert.equal(isArchetypeId('chimera'), true); + assert.ok(chimera.callsign); assert.ok(CHIMERA_CALLSIGNS.includes(chimera.callsign)); }); @@ -252,7 +265,10 @@ test('isArchetypeId is a string-set membership check', () => { assert.equal(isArchetypeId('merc'), true); assert.equal(isArchetypeId('razor'), true); assert.equal(isArchetypeId('wizard'), false); + // @ts-expect-error Verify runtime rejection of non-string values. assert.equal(isArchetypeId(null), false); + // @ts-expect-error Verify runtime rejection of non-string values. assert.equal(isArchetypeId(undefined), false); + // @ts-expect-error Verify runtime rejection of non-string values. assert.equal(isArchetypeId(7), false); }); diff --git a/tests/unit/game/breach.test.ts b/tests/unit/game/breach.test.ts index 181bb0b..391116e 100644 --- a/tests/unit/game/breach.test.ts +++ b/tests/unit/game/breach.test.ts @@ -48,6 +48,8 @@ function makeCrew() { function demolitionContract(overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.DENY, title: 'Breach floodgate', @@ -280,17 +282,17 @@ test('detonation damages blast-vulnerable entities and emits breach-blast events world.addEntity(player); world.addEntity(drone); world.placeBreachingCharge(3, 4); - const damaged: unknown[] = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: Record[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as Record)); const { casualties } = detonateBreachingCharge(world, 3, 4, player); assert.equal(casualties.length, 2); assert.equal(player.hp, 5 - BREACH_BLAST_DAMAGE); assert.equal(drone.hp, drone.maxHp - BREACH_BLAST_DAMAGE); - assert.ok(damaged.some((p: { source?: string }) => p.source === 'breach-blast')); + assert.ok(damaged.some(p => p.source === 'breach-blast')); assert.ok( - damaged.every((p: { attacker?: unknown }) => p.attacker === player), + damaged.every(p => p.attacker === player), 'breach blast should attribute damage to the planter' ); }); @@ -302,17 +304,12 @@ test('runPlayerAftermathSteps passes player to breach detonation', () => { const player = new Merc({ id: 'crew-merc', x: 3, y: 3, maxHp: 5 }); world.addEntity(player); world.placeBreachingCharge(3, 4); - const damaged: unknown[] = []; - bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: Record[] = []; + bus.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload as Record)); [...runPlayerAftermathSteps(world, new Rng(1), { player })]; - assert.ok( - damaged.some( - (p: { attacker?: unknown; source?: string }) => - p.source === 'breach-blast' && p.attacker === player - ) - ); + assert.ok(damaged.some(p => p.source === 'breach-blast' && p.attacker === player)); }); test('armed breaching charge round-trips through run snapshots', () => { diff --git a/tests/unit/game/catalogSplit.test.ts b/tests/unit/game/catalogSplit.test.ts index 8f6c87c..bcfc5b6 100644 --- a/tests/unit/game/catalogSplit.test.ts +++ b/tests/unit/game/catalogSplit.test.ts @@ -40,7 +40,7 @@ function assertWellFormed(item: Item) { assert.ok(Number.isInteger(item.cost) && item.cost > 0, `item "${item.id}" cost must be > 0`); assert.equal(typeof item.needsTarget, 'boolean'); assert.ok( - Object.values(ITEM_SCOPE).includes(item.scope), + new Set(Object.values(ITEM_SCOPE)).has(item.scope), `item "${item.id}" has invalid scope "${item.scope}"` ); } @@ -70,7 +70,7 @@ test('the two catalogs are disjoint (no item is both default and scoreable)', () test('scoreable pool has at least 5 net-new items beyond the original KNOWN gear', () => { // The original rep-gated KNOWN gear that became scoreable. - const ORIGINAL_KNOWN = new Set([ + const ORIGINAL_KNOWN = new Set([ ITEM_ID.BONE_LACING, ITEM_ID.TARGETING_CHIP, ITEM_ID.GHOST_WEAVE, diff --git a/tests/unit/game/clinic.test.ts b/tests/unit/game/clinic.test.ts index 84eaa1f..67a0beb 100644 --- a/tests/unit/game/clinic.test.ts +++ b/tests/unit/game/clinic.test.ts @@ -89,6 +89,8 @@ test('healMember rejects calls when not in HUB', () => { const member = campaign.crew[0]; campaign.deployCrewMember(member.id, { seed: 1, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.REACH_EXIT, title: 'Extract', diff --git a/tests/unit/game/combatTurnPipeline.test.ts b/tests/unit/game/combatTurnPipeline.test.ts index dc62b0d..be31b70 100644 --- a/tests/unit/game/combatTurnPipeline.test.ts +++ b/tests/unit/game/combatTurnPipeline.test.ts @@ -87,14 +87,20 @@ test('drivePlayerAftermath pumps one step per schedule tick', () => { const { world } = makeOpenWorld(); world.addEntity(new Turret({ id: 't1', x: 2, y: 2 })); world.addEntity(new Turret({ id: 't2', x: 4, y: 4 })); - const calls = []; - const scheduleQueue = []; - const animLock = { pushes: [], push: ms => animLock.pushes.push(ms) }; + const calls: string[] = []; + const scheduleQueue: { fn: () => void; ms: number }[] = []; + const animLock = { + pushes: [] as number[], + push: (ms: number) => animLock.pushes.push(ms), + }; drivePlayerAftermath({ world, rng: new Rng(1), - onStep: step => calls.push(step.turret.id), + onStep: step => { + assert.equal(step.type, 'turret-autofire'); + if (step.type === 'turret-autofire') calls.push(step.turret.id); + }, onFinish: () => calls.push('finish'), animLock, stepDelayMs: 50, @@ -107,20 +113,20 @@ test('drivePlayerAftermath pumps one step per schedule tick', () => { assert.equal(scheduleQueue.length, 1); assert.equal(scheduleQueue[0].ms, 50); - scheduleQueue.shift().fn(); + scheduleQueue.shift()!.fn(); assert.deepEqual(calls, ['t1', 't1']); - scheduleQueue.shift().fn(); + scheduleQueue.shift()!.fn(); assert.deepEqual(calls, ['t1', 't1', 't2']); - scheduleQueue.shift().fn(); + scheduleQueue.shift()!.fn(); assert.deepEqual(calls, ['t1', 't1', 't2', 't2']); - scheduleQueue.shift().fn(); + scheduleQueue.shift()!.fn(); assert.deepEqual(calls, ['t1', 't1', 't2', 't2', 'finish']); }); test('advanceFromPlayerTurn orders player aftermath before corp and final player handoff', () => { const { world } = makeOpenWorld(); world.addEntity(new Turret({ id: 't1', x: 2, y: 2 })); - const calls = []; + const calls: string[] = []; const queue = { endTurn: () => calls.push('queue.endTurn'), }; @@ -154,8 +160,8 @@ test('advanceFromPlayerTurn orders player aftermath before corp and final player test('advanceFromPlayerTurn waits for async aftermath before starting corp', () => { const { world } = makeOpenWorld(); - const calls = []; - let finishAftermath; + const calls: string[] = []; + let finishAftermath: (() => void) | undefined; const queue = { endTurn: () => calls.push('queue.endTurn'), }; @@ -176,7 +182,7 @@ test('advanceFromPlayerTurn waits for async aftermath before starting corp', () }); assert.deepEqual(calls, ['queue.endTurn', 'aftermath.drive']); - finishAftermath(); + finishAftermath!(); assert.deepEqual(calls, [ 'queue.endTurn', 'aftermath.drive', @@ -192,7 +198,7 @@ test('advanceFromPlayerTurn does not advance the queue when the run is already t // AP / bumping the turn counter) on a dead run. const { world } = makeOpenWorld(); world.addEntity(new Turret({ id: 't1', x: 2, y: 2 })); - const calls = []; + const calls: string[] = []; const queue = { endTurn: () => calls.push('queue.endTurn'), }; @@ -268,8 +274,8 @@ test('hub operator AP refreshes after PLAYER→CORP→PLAYER queue flip', () => test('advanceFromPlayerTurn lets async corp driver own when the player turn resumes', () => { const { world } = makeOpenWorld(); - const calls = []; - let finishCorpTurn; + const calls: string[] = []; + let finishCorpTurn: (() => void) | undefined; const queue = { endTurn: () => calls.push('queue.endTurn'), }; @@ -287,7 +293,7 @@ test('advanceFromPlayerTurn lets async corp driver own when the player turn resu }); assert.deepEqual(calls, ['queue.endTurn', 'corp.drive']); - finishCorpTurn(); + finishCorpTurn!(); assert.deepEqual(calls, ['queue.endTurn', 'corp.drive', 'queue.endTurn', 'player.ready']); }); @@ -299,12 +305,18 @@ test('advanceFromPlayerTurn rejects malformed ctx', () => { rng: new Rng(1), driveCorpTurn: () => {}, }; + // @ts-expect-error Runtime validation must reject null. assert.throws(() => advanceFromPlayerTurn(null), /ctx/); + // @ts-expect-error Runtime validation must reject a malformed queue. assert.throws(() => advanceFromPlayerTurn({ ...valid, queue: {} }), /queue\.endTurn/); + // @ts-expect-error Runtime validation must reject a malformed world. assert.throws(() => advanceFromPlayerTurn({ ...valid, world: {} }), /world\.entities/); + // @ts-expect-error Runtime validation must reject a malformed RNG. assert.throws(() => advanceFromPlayerTurn({ ...valid, rng: {} }), /Rng-like/); + // @ts-expect-error Runtime validation must reject a missing corp driver. assert.throws(() => advanceFromPlayerTurn({ ...valid, driveCorpTurn: null }), /driveCorpTurn/); assert.throws( + // @ts-expect-error Runtime validation must reject a malformed aftermath driver. () => advanceFromPlayerTurn({ ...valid, drivePlayerAftermath: null }), /drivePlayerAftermath/ ); diff --git a/tests/unit/game/corpTurnDriver.test.ts b/tests/unit/game/corpTurnDriver.test.ts index bf385e4..1fc83c1 100644 --- a/tests/unit/game/corpTurnDriver.test.ts +++ b/tests/unit/game/corpTurnDriver.test.ts @@ -1,7 +1,34 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { isCorpTurnTerminal, runCorpTurn } from '../../../src/game/corpTurnDriver.js'; +import { + isCorpTurnTerminal, + runCorpTurn, + type CorpTurnDriverCtx, +} from '../../../src/game/corpTurnDriver.js'; +import type { TurnActionStep } from '../../../src/types.js'; + +type TestStep = { type: string }; + +type FakeEntity = { + id: string; + alive: boolean; + faction: string; + takeTurnSteps?: () => Generator; + takeTurn?: () => void; +}; + +type FakeRun = { + state: string; + world: { entities: Map }; + rng: { next: () => number }; +}; + +type TestSchedule = ((cb: () => void, ms: number) => number) & { + queue: { cb: () => void; ms: number }[]; + flush: () => void; + step: () => void; +}; // --------------------------------------------------------------------------- // Fixtures — the driver only reads a small slice of Run/world, so we hand- @@ -9,9 +36,12 @@ import { isCorpTurnTerminal, runCorpTurn } from '../../../src/game/corpTurnDrive // --------------------------------------------------------------------------- /** Build a fake run-like object the driver can iterate. */ -function makeRun({ state = 'COMBAT', entities = [] } = {}) { - const map = new Map(); - for (const e of entities) map.set(e.id ?? e, e); +function makeRun({ + state = 'COMBAT', + entities = [], +}: { state?: string; entities?: FakeEntity[] } = {}): FakeRun { + const map = new Map(); + for (const e of entities) map.set(e.id, e); return { state, world: { entities: map }, @@ -21,58 +51,59 @@ function makeRun({ state = 'COMBAT', entities = [] } = {}) { /** Deterministic scheduler: collects callbacks instead of using real timers. */ function makeSchedule() { - const queue = []; - const fn = (cb, ms) => queue.push({ cb, ms }); + const queue: { cb: () => void; ms: number }[] = []; + const fn = ((cb: () => void, ms: number) => queue.push({ cb, ms })) as TestSchedule; fn.queue = queue; fn.flush = () => { while (queue.length > 0) { - const { cb } = queue.shift(); + const { cb } = queue.shift()!; cb(); } }; fn.step = () => { - const { cb } = queue.shift(); + const { cb } = queue.shift()!; cb(); }; return fn; } function makeAnimLock() { - const pushes = []; + const pushes: number[] = []; return { - push: ms => pushes.push(ms), + push: (ms: number) => pushes.push(ms), pushes, }; } /** Minimal corp-entity stub with a configurable action sequence. */ -function makeDrone(id, actions = []) { +function makeDrone(id: string, actions: TestStep[] = []) { return { id, alive: true, faction: 'corp', actionsLeft: [...actions], - actionsTaken: [], - *takeTurnSteps() { + actionsTaken: [] as TestStep[], + *takeTurnSteps(): Generator { while (this.actionsLeft.length > 0) { const next = this.actionsLeft.shift(); - this.actionsTaken.push(next); - yield next; + this.actionsTaken.push(next!); + yield next!; } }, }; } -const baseCtx = overrides => ({ - corpFaction: 'corp', - paint: () => {}, - animLock: makeAnimLock(), - actionDelayMs: 100, - lockMarginMs: 50, - onFinish: () => {}, - schedule: makeSchedule(), - ...overrides, -}); +const baseCtx = (overrides: Record): CorpTurnDriverCtx => + ({ + corpFaction: 'corp', + paint: () => {}, + animLock: makeAnimLock(), + actionDelayMs: 100, + lockMarginMs: 50, + onFinish: () => {}, + schedule: makeSchedule(), + ...overrides, + }) as unknown as CorpTurnDriverCtx; // --------------------------------------------------------------------------- // Terminal-state semantics @@ -189,7 +220,7 @@ test('runCorpTurn batches invisible steps without paint or schedule', () => { { type: 'move-patrol' }, { type: 'fire' }, ]); - const paints = []; + const paints: number[] = []; const lock = makeAnimLock(); const schedule = makeSchedule(); let finished = false; @@ -199,7 +230,7 @@ test('runCorpTurn batches invisible steps without paint or schedule', () => { paint: () => paints.push(drone.actionsTaken.length), animLock: lock, schedule, - shouldAnimateStep: (_id, step) => step.type === 'fire', + shouldAnimateStep: (_id: string, step: TurnActionStep) => step.type === 'fire', onFinish: () => (finished = true), }) ); @@ -215,7 +246,7 @@ test('runCorpTurn batches invisible steps without paint or schedule', () => { test('runCorpTurn pumps one yield per schedule tick, painting between each', () => { const drone = makeDrone('d', [{ type: 'fire' }, { type: 'move-engage' }]); - const paints = []; + const paints: number[] = []; const lock = makeAnimLock(); const schedule = makeSchedule(); let finished = false; @@ -278,7 +309,7 @@ test('runCorpTurn stops pumping if state transitions to RESULT mid-turn', () => // race the result screen with more corp actions. const run = makeRun({ state: 'COMBAT' }); const drone = makeDrone('d', [{ type: 'fire' }, { type: 'fire' }]); - run.world.entities.set(drone.id, drone); + run.world!.entities.set(drone.id, drone); const schedule = makeSchedule(); let finished = false; runCorpTurn( diff --git a/tests/unit/game/corpTurnStatusCopy.test.ts b/tests/unit/game/corpTurnStatusCopy.test.ts index a3e8ff9..52e62fc 100644 --- a/tests/unit/game/corpTurnStatusCopy.test.ts +++ b/tests/unit/game/corpTurnStatusCopy.test.ts @@ -23,7 +23,7 @@ test('countVisibleCorpEntities counts only alive corp on visible tiles', () => { { alive: true, faction: FACTION.CORP, x: 3, y: 3 }, ]; const visible = new Set(['1,1']); - const isTileVisible = (x, y) => visible.has(`${x},${y}`); + const isTileVisible = (x: number, y: number) => visible.has(`${x},${y}`); assert.equal(countVisibleCorpEntities(entities, isTileVisible), 1); }); @@ -34,7 +34,7 @@ test('countVisibleCorpEntities honors an explicit hostile faction (RIVAL)', () = { alive: true, faction: FACTION.RIVAL, x: 3, y: 3 }, ]; const visible = new Set(['1,1', '1,2']); - const isTileVisible = (x, y) => visible.has(`${x},${y}`); + const isTileVisible = (x: number, y: number) => visible.has(`${x},${y}`); assert.equal(countVisibleCorpEntities(entities, isTileVisible, FACTION.RIVAL), 1); }); @@ -173,7 +173,7 @@ test('isCorpTurnStepLogVisibleToPlayer: hit on turret that survives still requir test('isCorpTurnStepVisibleToPlayer: facility alarm repaints even when actor tile is unseen', () => { const { world } = makeDroneWorld(); - const step = { type: 'alarm' as const }; + const step = { type: 'alarm' as const, target: 'p1' }; assert.equal( isCorpTurnStepVisibleToPlayer(world, 'p1', 'd1', step, () => false), true @@ -191,6 +191,7 @@ test('isCorpTurnStepVisibleToPlayer: off-screen patrol matches log visibility', test('formatCorpTurnStep narrates a lookout mark with the target label', () => { const line = formatCorpTurnStep('[Corp]Lookout', { type: 'spot', target: 'p1' }, () => 'you'); + assert.ok(line); assert.match(line, /\[Corp\]Lookout marks you/); assert.match(line, /converging/i); }); @@ -214,6 +215,7 @@ test('formatCorpTurnStep narrates melee knockback when present', () => { }, id => (id === 'crew-merc' ? 'Patch' : id) ); + assert.ok(line); assert.match(line, /Patch is shoved to \(4, 2\)/); }); @@ -241,6 +243,7 @@ test('formatCorpTurnStep surfaces armor and shield mitigation on an incoming hit }, id => (id === 'crew-merc' ? 'Patch' : id) ); + assert.ok(line); assert.match(line, /2 → ARMOR -1 → SHIELD -1 · HP SAFE/); }); diff --git a/tests/unit/game/crewStatRoll.test.ts b/tests/unit/game/crewStatRoll.test.ts index 1c3a1ea..06a022b 100644 --- a/tests/unit/game/crewStatRoll.test.ts +++ b/tests/unit/game/crewStatRoll.test.ts @@ -277,7 +277,9 @@ test('each locked archetype anchor point saturates to a different, unlocked neig }); test('deriveArchetype throws with an anchor table filtered down to nothing (all six gated)', () => { - const allGated = CREW_STAT_ANCHORS.filter(a => !NON_DECKER_ARCHETYPES.includes(a.archetype)); + const allGated = CREW_STAT_ANCHORS.filter( + a => !new Set(NON_DECKER_ARCHETYPES).has(a.archetype) + ); assert.deepEqual(allGated, []); assert.throws( () => deriveArchetype({ hitChance: 0.75, dodgeChance: 0.25 }, allGated), diff --git a/tests/unit/game/cyber/CyberspaceLayer.test.ts b/tests/unit/game/cyber/CyberspaceLayer.test.ts index c942872..e25c276 100644 --- a/tests/unit/game/cyber/CyberspaceLayer.test.ts +++ b/tests/unit/game/cyber/CyberspaceLayer.test.ts @@ -26,12 +26,17 @@ import { DECKER_BASE_INTRUSION, DECKER_BASE_RAM, FACTION, + type ContractDifficulty, } from '../../../../src/game/constants.js'; const makeDecker = (overrides = {}) => new Decker({ id: 'crew-decker', x: 0, y: 0, callsign: 'Phreak', ...overrides }); -const buildLayer = (contractSeed = 12345, decker = makeDecker(), difficulty = 'standard') => +const buildLayer = ( + contractSeed = 12345, + decker = makeDecker(), + difficulty: ContractDifficulty = 'standard' +) => // nodeCount 1 matches the `cyber-data-spike` recipe (P3.M3.4). CyberspaceLayer.build({ contractSeed, difficulty, decker, nodeCount: 1 }); diff --git a/tests/unit/game/cyber/cyberMapBuild.test.ts b/tests/unit/game/cyber/cyberMapBuild.test.ts index 0a41026..df0524b 100644 --- a/tests/unit/game/cyber/cyberMapBuild.test.ts +++ b/tests/unit/game/cyber/cyberMapBuild.test.ts @@ -12,10 +12,14 @@ import assert from 'node:assert/strict'; import { buildCyberMap } from '../../../../src/game/cyber/cyberMapBuild.js'; import { World } from '../../../../src/game/World.js'; import { explorationReachableKeys, coordKey } from '../../../../src/game/mapConnectivity.js'; -import { TILE, CONTRACT_DIFFICULTY } from '../../../../src/game/constants.js'; +import { + TILE, + CONTRACT_DIFFICULTY, + type ContractDifficulty, +} from '../../../../src/game/constants.js'; import { Rng } from '../../../../src/rng.js'; -const build = (seed = 1, difficulty = CONTRACT_DIFFICULTY.STANDARD) => +const build = (seed = 1, difficulty: ContractDifficulty = CONTRACT_DIFFICULTY.STANDARD) => buildCyberMap({ rng: new Rng(seed), difficulty }); test('equal seeds build identical cyber maps', () => { @@ -97,5 +101,6 @@ test('each non-entry node carries a non-empty passable patrol ring', () => { }); test('unknown difficulty throws', () => { + // @ts-expect-error Verify runtime validation of an unknown difficulty. assert.throws(() => buildCyberMap({ rng: new Rng(1), difficulty: 'impossible' }), /difficulty/); }); diff --git a/tests/unit/game/cyber/dualDeploy.test.ts b/tests/unit/game/cyber/dualDeploy.test.ts index b87eb23..5a86b1d 100644 --- a/tests/unit/game/cyber/dualDeploy.test.ts +++ b/tests/unit/game/cyber/dualDeploy.test.ts @@ -11,7 +11,7 @@ import { Run } from '../../../../src/game/Run.js'; import { Campaign } from '../../../../src/game/Campaign.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { OUTCOME } from '../../../../src/game/Run.js'; -import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../../src/game/hub/Curator.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; import { Rng } from '../../../../src/rng.js'; import { @@ -22,20 +22,21 @@ import { } from '../../../../src/game/persistence.js'; import { testContractContext } from '../contractTestUtils.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'meat job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'meat job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; const cyberContract = (overrides = {}) => fakeContract({ diff --git a/tests/unit/game/cyber/operatorTurnConclude.test.ts b/tests/unit/game/cyber/operatorTurnConclude.test.ts index 1fda3c5..7ba00bf 100644 --- a/tests/unit/game/cyber/operatorTurnConclude.test.ts +++ b/tests/unit/game/cyber/operatorTurnConclude.test.ts @@ -120,14 +120,14 @@ test('P3.M4.4: not end-ready when the active operator is spent but the other sti // Control starts on the meat partner; spend it dry, avatar still full. drain(run.activeActor!); assert.equal(run.activeActor!.ap, 0); - assert.ok(run.cyberspace!.phase === 'active' && run.cyberspace.layer.avatar.ap > 0); + assert.ok(run.cyberspace?.phase === 'active' && run.cyberspace.layer.avatar.ap > 0); assert.equal(run.endOfTurnReady(), false); }); test('P3.M4.4: end-ready only once both controllable operators are spent', () => { const run = dualRun(); jackIn(run); - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; drain(run.meatActor!); drain(avatar!); assert.equal(run.endOfTurnReady(), true); @@ -149,7 +149,7 @@ test('P3.M4.4: exhausting one operator auto-flips control to the other (no turn const run = dualRun(); jackIn(run); const partner = run.meatActor!; - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; assert.ok(avatar instanceof CyberAvatar); const turnBefore = run.queue!.turnNumber; @@ -168,7 +168,7 @@ test('P3.M4.4: exhausting one operator auto-flips control to the other (no turn test('P3.M4.4: exhausting the second operator ends the mutual turn', () => { const run = dualRun(); jackIn(run); - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; drain(run.meatActor!); assert.equal(run.concludeActiveOperatorTurn(), 'auto-flip'); // → cyber @@ -183,7 +183,7 @@ test('P3.M4.4: exhausting the second operator ends the mutual turn', () => { test('P3.M4.4: waiting with the other operator holding AP flips and keeps the turn open', () => { const run = dualRun(); jackIn(run); - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; drain(run.activeActor!); // Wait forfeits the partner's AP assert.equal(run.passActiveOperatorTurn(), 'flip'); assert.equal(run.activeLayer, 'cyber'); @@ -196,7 +196,7 @@ test('P3.M4.4: waiting the LAST operator still flips, then ends the mutual turn' const run = dualRun(); jackIn(run); const partner = run.meatActor!; - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; // Both operators spent; control sits on the avatar (cyber). drain(partner); run.flip(); @@ -240,7 +240,7 @@ test('P3.M4.4: a dead partner is not a flip target — exhausting the avatar end const run = dualRun(); jackIn(run); const partner = run.meatActor!; - const avatar = run.cyberspace!.phase === 'active' ? run.cyberspace.layer.avatar : null; + const avatar = run.cyberspace?.phase === 'active' ? run.cyberspace.layer.avatar : null; // Avatar takes control, partner falls on the meat field. run.flip(); assert.equal(run.activeActor, avatar); diff --git a/tests/unit/game/cyber/partnerDeath.test.ts b/tests/unit/game/cyber/partnerDeath.test.ts index 0162069..60c6af0 100644 --- a/tests/unit/game/cyber/partnerDeath.test.ts +++ b/tests/unit/game/cyber/partnerDeath.test.ts @@ -13,7 +13,7 @@ import { Campaign } from '../../../../src/game/Campaign.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { CyberAvatar } from '../../../../src/game/cyber/CyberAvatar.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; -import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../../src/game/hub/Curator.js'; import { EVENT } from '../../../../src/game/events.js'; import { FACTION } from '../../../../src/game/constants.js'; import { Rng } from '../../../../src/rng.js'; @@ -28,8 +28,10 @@ import type { World } from '../../../../src/game/World.js'; import type { Entity } from '../../../../src/game/Entity.js'; import type { Crew } from '../../../../src/game/Crew.js'; -const cyberContract = (overrides = {}) => ({ +const cyberContract = (overrides: Partial = {}): Contract => ({ seed: 12345, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.DATA_NODE_SLICE, title: 'Spike the server farm', diff --git a/tests/unit/game/cyber/runJackIn.test.ts b/tests/unit/game/cyber/runJackIn.test.ts index 9ed791a..80bff35 100644 --- a/tests/unit/game/cyber/runJackIn.test.ts +++ b/tests/unit/game/cyber/runJackIn.test.ts @@ -14,27 +14,28 @@ import { CyberspaceLayer } from '../../../../src/game/cyber/CyberspaceLayer.js'; import { JackInPoint } from '../../../../src/game/entities/JackInPoint.js'; import { EVENT } from '../../../../src/game/events.js'; import { buildCrewMember } from '../../../../src/game/archetypes/index.js'; -import { OBJECTIVES } from '../../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../../src/game/hub/Curator.js'; import { Rng } from '../../../../src/rng.js'; import { testContractContext } from '../contractTestUtils.js'; import type { World } from '../../../../src/game/World.js'; import type { Entity } from '../../../../src/game/Entity.js'; import type { RunResult, RunSnapshot } from '../../../../src/game/Run.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'test job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; const cyberContract = (overrides = {}) => fakeContract({ diff --git a/tests/unit/game/cyber/simstimFlip.test.ts b/tests/unit/game/cyber/simstimFlip.test.ts index b017181..4b53ab5 100644 --- a/tests/unit/game/cyber/simstimFlip.test.ts +++ b/tests/unit/game/cyber/simstimFlip.test.ts @@ -119,7 +119,7 @@ test('P3.M4.3: flip swaps meat partner ↔ cyber avatar while jacked in', () => assert.equal(activeTileset(run), 'cyber'); assert.equal( activeWorldOf(run), - run.cyberspace!.phase === 'active' && run.cyberspace.layer.world + run.cyberspace?.phase === 'active' && run.cyberspace.layer.world ); // Flip back to Meatspace. diff --git a/tests/unit/game/deny.test.ts b/tests/unit/game/deny.test.ts index 4a28297..feb4695 100644 --- a/tests/unit/game/deny.test.ts +++ b/tests/unit/game/deny.test.ts @@ -47,6 +47,8 @@ function makeCrew(archetype = 'razor') { function makeDenyContract(overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.DENY, title: 'Disable shipment', @@ -64,7 +66,7 @@ function makeDenyContract(overrides: Partial = {}): Contract { function denyTargetsIn(run: Run): DenyTarget[] { if (!run.world) throw new Error('run must be in combat'); - return [...run.world.entities.values()].filter( + return [...run.world!.entities.values()].filter( (entity): entity is DenyTarget => entity instanceof DenyTarget ); } @@ -145,7 +147,7 @@ describe('deny runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeDenyContract()); run.enterCombat(); @@ -156,7 +158,7 @@ describe('deny runs', () => { assert.equal(target.label, 'Shipment'); assert.ok(run.exitTile, 'deny run should have an exit tile'); assert.ok( - Math.max(Math.abs(target.x - run.exitTile.x), Math.abs(target.y - run.exitTile.y)) > 1, + Math.max(Math.abs(target.x - run.exitTile!.x), Math.abs(target.y - run.exitTile!.y)) > 1, 'deny target should not spawn adjacent to extraction' ); assert.equal(isObjectiveSatisfied(run.contract!, run.world), false); @@ -165,7 +167,7 @@ describe('deny runs', () => { run.bus!.emit('entity:moved', { entity: run.player, from: { x: run.player!.x, y: run.player!.y }, - to: { x: run.exitTile.x, y: run.exitTile.y }, + to: { x: run.exitTile!.x, y: run.exitTile!.y }, }); assert.equal(run.state, RUN_STATE.RESULT, 'abort extraction ends the run'); const abortResult = results[0] as { @@ -185,7 +187,7 @@ describe('deny runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeDenyContract()); run.enterCombat(); diff --git a/tests/unit/game/describe.test.ts b/tests/unit/game/describe.test.ts index cc7d8d5..cf338f6 100644 --- a/tests/unit/game/describe.test.ts +++ b/tests/unit/game/describe.test.ts @@ -23,7 +23,9 @@ const worldWithPlayer = () => { test('describeTileAt identifies rubble with AP cost', () => { const { grid, world } = worldWithPlayer(); grid.setTile(3, 2, TILE.RUBBLE); - assert.match(describeTileAt(world, 3, 2), /Rubble.+2 AP to enter/); + const line = describeTileAt(world, 3, 2); + assert.ok(line); + assert.match(line, /Rubble.+2 AP to enter/); }); test('describeTileAt returns null for plain floor', () => { @@ -62,6 +64,7 @@ test('describeTileAt identifies salvageable corpses with compact salvage', () => drone.alive = false; drone.loot = { salvage: makeSalvage({ scrap: 2, chips: 1 }) }; const line = describeTileAt(world, 3, 2); + assert.ok(line); assert.match(line, /\[Corp\] Drone corpse — salvageable/); assert.match(line, /S:2 C:1 B:0 D:0/); }); @@ -101,7 +104,9 @@ test('describeTileAt falls through to terrain when memorised corpse is gone', () vision.seen.add('5,4'); vision.memoriseCorpse({ x: 5, y: 4, faction: FACTION.CORP, glyph: '%' }); vision.recompute(world.grid, player, 1); - assert.match(describeTileAt(world, 5, 4, { vision }), /Rubble/); + const line = describeTileAt(world, 5, 4, { vision }); + assert.ok(line); + assert.match(line, /Rubble/); }); test('describeTileAt identifies door and terminal state clauses', () => { diff --git a/tests/unit/game/door.test.ts b/tests/unit/game/door.test.ts index 32f6ec5..ae70b77 100644 --- a/tests/unit/game/door.test.ts +++ b/tests/unit/game/door.test.ts @@ -12,7 +12,7 @@ import { findPath } from '../../../src/game/Pathfinding.js'; import { snapshot, restore } from '../../../src/game/persistence.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { DOOR_LOCKED_GLYPH, DOOR_OPEN_GLYPH, FACTION, TILE } from '../../../src/game/constants.js'; -import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../src/game/hub/Curator.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; @@ -33,7 +33,7 @@ function makeCrew() { return buildCrewMember('razor', { x: 0, y: 0 }, new Rng(100), { id: 'crew-razor' }); } -function fakeContract(overrides = {}) { +function fakeContract(overrides: Partial = {}): Contract { return { seed: 12345, objective: { @@ -47,10 +47,10 @@ function fakeContract(overrides = {}) { context: testContractContext(OBJECTIVES.REACH_EXIT), reward: { credits: 0, repDelta: 0 }, ...overrides, - }; + } as Contract; } -function retrieveDoorContract(seed, overrides = {}) { +function retrieveDoorContract(seed: number, overrides: Record = {}) { return fakeContract({ seed, objective: { @@ -64,16 +64,16 @@ function retrieveDoorContract(seed, overrides = {}) { }); } -function relocateAdjacentTo(run, entity) { +function relocateAdjacentTo(run: Run, entity: Entity) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.entityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.entityAt(x, y)) continue; + run.world!.relocateEntity(run.player!, x, y); return; } } @@ -198,8 +198,8 @@ test('World.unlockDoor emits door:unlocked when terminal slice unlocks a linked world.addEntity(player); world.addEntity(terminal); world.addEntity(door); - const unlocked = []; - bus.on(EVENT.DOOR_UNLOCKED, payload => unlocked.push(payload)); + const unlocked: Record[] = []; + bus.on(EVENT.DOOR_UNLOCKED, payload => unlocked.push(payload as Record)); const result = terminal.interact(world, player); @@ -216,16 +216,16 @@ test('snapshot/restore round-trips locked and unlocked door state', () => { assert.equal(run.state, RUN_STATE.COMBAT); const anchors = []; - for (let y = 1; y < run.world.grid.height - 1 && anchors.length < 2; y++) { - for (let x = 1; x < run.world.grid.width - 1 && anchors.length < 2; x++) { - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; + for (let y = 1; y < run.world!.grid.height - 1 && anchors.length < 2; y++) { + for (let x = 1; x < run.world!.grid.width - 1 && anchors.length < 2; x++) { + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; anchors.push({ x, y }); } } assert.equal(anchors.length, 2); - run.world.addEntity(new Door({ id: 'door-entity-0', doorId: 'door-0', ...anchors[0] })); - run.world.addEntity( + run.world!.addEntity(new Door({ id: 'door-entity-0', doorId: 'door-0', ...anchors[0] })); + run.world!.addEntity( new Door({ id: 'door-entity-1', doorId: 'door-1', locked: false, ...anchors[1] }) ); @@ -250,15 +250,15 @@ test('restore rejects door snapshots whose glyph disagrees with locked state', ( run.enterCombat(); let anchor = null; - for (let y = 1; y < run.world.grid.height - 1 && !anchor; y++) { - for (let x = 1; x < run.world.grid.width - 1 && !anchor; x++) { - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; + for (let y = 1; y < run.world!.grid.height - 1 && !anchor; y++) { + for (let x = 1; x < run.world!.grid.width - 1 && !anchor; x++) { + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; anchor = { x, y }; } } assert.ok(anchor); - run.world.addEntity(new Door({ id: 'door-entity-0', doorId: 'door-0', ...anchor })); + run.world!.addEntity(new Door({ id: 'door-entity-0', doorId: 'door-0', ...anchor })); const rec = snapshot(run); const doorRec = rec.entities.find(entity => entity.archetype === 'door'); assert.ok(doorRec); @@ -281,9 +281,9 @@ test('door-linked retrieve run places objective behind the door and unlock termi } assert.ok(run, 'expected at least one deterministic seed to produce a door-linked layout'); - const door = [...run.world.entities.values()].find(entity => entity instanceof Door); - const terminal = [...run.world.entities.values()].find(entity => entity instanceof Terminal); - const pickup = [...run.world.entities.values()].find(entity => entity instanceof Pickup); + const door = [...run.world!.entities.values()].find(entity => entity instanceof Door); + const terminal = [...run.world!.entities.values()].find(entity => entity instanceof Terminal); + const pickup = [...run.world!.entities.values()].find(entity => entity instanceof Pickup); assert.ok(door instanceof Door); assert.ok(terminal instanceof Terminal); assert.ok(pickup instanceof Pickup); @@ -291,18 +291,18 @@ test('door-linked retrieve run places objective behind the door and unlock termi assert.equal(terminal.unlocksId, 'door-0'); assert.equal(door.locked, true); assert.equal( - findPath(run.world, { x: run.player.x, y: run.player.y }, { x: pickup.x, y: pickup.y }), + findPath(run.world!, { x: run.player!.x, y: run.player!.y }, { x: pickup.x, y: pickup.y }), null, 'pickup starts unreachable while the door is locked' ); relocateAdjacentTo(run, terminal); - const result = terminal.interact(run.world, run.player); + const result = terminal.interact(run.world!, run.player!); assert.equal(result.ok, true); assert.equal(door.locked, false); assert.ok( - findPath(run.world, { x: run.player.x, y: run.player.y }, { x: pickup.x, y: pickup.y }), + findPath(run.world!, { x: run.player!.x, y: run.player!.y }, { x: pickup.x, y: pickup.y }), 'pickup becomes reachable once the door unlocks' ); }); @@ -325,31 +325,31 @@ test('elevated non-routing runs can receive dynamic corridor doors with paired t }) ); candidate.enterCombat(); - const dynamicTerminal = [...candidate.world.entities.values()].find( + const dynamicTerminal = [...candidate.world!.entities.values()].find( entity => entity instanceof Terminal && entity.id.startsWith('terminal-dynamic-door-') ); if (dynamicTerminal) run = candidate; } assert.ok(run, 'expected at least one deterministic seed to produce a dynamic door'); - const dynamicTerminal = [...run.world.entities.values()].find( + const dynamicTerminal = [...run.world!.entities.values()].find( entity => entity instanceof Terminal && entity.id.startsWith('terminal-dynamic-door-') ); assert.ok(dynamicTerminal instanceof Terminal); - const dynamicDoor = [...run.world.entities.values()].find( + const dynamicDoor = [...run.world!.entities.values()].find( entity => entity instanceof Door && entity.doorId === dynamicTerminal.unlocksId ); assert.ok(dynamicDoor instanceof Door); assert.equal(dynamicDoor.locked, true); assert.ok( - findPath(run.world, { x: run.player.x, y: run.player.y }, run.exitTile, { + findPath(run.world!, { x: run.player!.x, y: run.player!.y }, run.exitTile!, { allowOccupiedGoal: false, }), 'dynamic door must not block extraction while locked' ); relocateAdjacentTo(run, dynamicTerminal); - const result = dynamicTerminal.interact(run.world, run.player); + const result = dynamicTerminal.interact(run.world!, run.player!); assert.equal(result.ok, true); assert.equal(dynamicDoor.locked, false); @@ -373,7 +373,7 @@ test('dynamic corridor doors snapshot/restore through existing door and terminal }) ); candidate.enterCombat(); - const hasDynamicTerminal = [...candidate.world.entities.values()].some( + const hasDynamicTerminal = [...candidate.world!.entities.values()].some( entity => entity instanceof Terminal && entity.id.startsWith('terminal-dynamic-door-') ); if (hasDynamicTerminal) run = candidate; @@ -410,19 +410,19 @@ test('dynamic access terminals do not satisfy terminal-slice objectives', () => }) ); candidate.enterCombat(); - const dynamicTerminal = [...candidate.world.entities.values()].find( + const dynamicTerminal = [...candidate.world!.entities.values()].find( entity => entity instanceof Terminal && entity.id.startsWith('terminal-dynamic-door-') ); if (dynamicTerminal) run = candidate; } assert.ok(run); - const dynamicTerminal = [...run.world.entities.values()].find( + const dynamicTerminal = [...run.world!.entities.values()].find( entity => entity instanceof Terminal && entity.id.startsWith('terminal-dynamic-door-') ); assert.ok(dynamicTerminal instanceof Terminal); relocateAdjacentTo(run, dynamicTerminal); - const result = dynamicTerminal.interact(run.world, run.player); + const result = dynamicTerminal.interact(run.world!, run.player!); assert.equal(result.ok, true); assert.equal(run.isObjectiveSatisfied(), false); diff --git a/tests/unit/game/dualSite.test.ts b/tests/unit/game/dualSite.test.ts index 5654e72..2d23ca0 100644 --- a/tests/unit/game/dualSite.test.ts +++ b/tests/unit/game/dualSite.test.ts @@ -47,6 +47,8 @@ function makeCrew(archetype = 'razor') { function makeDualSiteContract(overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.DUAL_SITE, title: 'Sync payroll mirrors', @@ -69,10 +71,10 @@ function relocateAdjacentTo(run: Run, entity: SyncPad): void { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player, x, y); return; } } @@ -81,7 +83,7 @@ function relocateAdjacentTo(run: Run, entity: SyncPad): void { function syncPadsIn(run: Run): SyncPad[] { if (!run.world) throw new Error('run must be in combat'); - return [...run.world.entities.values()].filter( + return [...run.world!.entities.values()].filter( (entity): entity is SyncPad => entity instanceof SyncPad ); } @@ -178,7 +180,7 @@ describe('dual-site runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeDualSiteContract()); run.enterCombat(); @@ -189,7 +191,7 @@ describe('dual-site runs', () => { for (const pad of pads) { assert.equal(pad.glyph, SYNC_PAD_GLYPH); assert.ok( - Math.max(Math.abs(pad.x - run.exitTile.x), Math.abs(pad.y - run.exitTile.y)) > 1, + Math.max(Math.abs(pad.x - run.exitTile!.x), Math.abs(pad.y - run.exitTile!.y)) > 1, 'sync pad should not spawn adjacent to extraction' ); } @@ -199,7 +201,7 @@ describe('dual-site runs', () => { run.bus!.emit('entity:moved', { entity: run.player, from: { x: run.player!.x, y: run.player!.y }, - to: { x: run.exitTile.x, y: run.exitTile.y }, + to: { x: run.exitTile!.x, y: run.exitTile!.y }, }); assert.equal(run.state, RUN_STATE.RESULT, 'abort extraction ends the run'); const abortResult = results[0] as { @@ -219,13 +221,13 @@ describe('dual-site runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeDualSiteContract()); run.enterCombat(); const pads = syncPadsIn(run); - const [first, second] = pads.toReversed(); + const [first, second] = [...pads].reverse(); assert.ok(first && second); relocateAdjacentTo(run, first); assert.equal(first.interact(run.world!, run.player!).ok, true); diff --git a/tests/unit/game/empBlast.test.ts b/tests/unit/game/empBlast.test.ts index 4cf22e4..bfe3f56 100644 --- a/tests/unit/game/empBlast.test.ts +++ b/tests/unit/game/empBlast.test.ts @@ -16,7 +16,17 @@ import { canEmp, isInEmpBlast, detonateEmp } from '../../../src/game/empBlast.js import { EventBus, EVENT } from '../../../src/game/events.js'; import { FACTION, AP_COST, EMP_RADIUS, STATUS_EFFECT } from '../../../src/game/constants.js'; -function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [], bus = null } = {}) { +function makeWorld({ + deckerAt = [5, 5], + grid, + extraEntities = [], + bus = null, +}: { + deckerAt?: [number, number]; + grid?: Grid; + extraEntities?: Entity[]; + bus?: EventBus | null; +} = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g, bus ? { events: bus } : {}); const decker = new Decker({ id: 'decker', x: deckerAt[0], y: deckerAt[1] }); @@ -25,8 +35,10 @@ function makeWorld({ deckerAt = [5, 5], grid, extraEntities = [], bus = null } = return { world: w, decker }; } -const enemy = (id, x, y) => new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); -const ally = (id, x, y) => new Entity({ id, x, y, faction: FACTION.PLAYER, glyph: 'c' }); +const enemy = (id: string, x: number, y: number) => + new Entity({ id, x, y, faction: FACTION.CORP, glyph: 'd' }); +const ally = (id: string, x: number, y: number) => + new Entity({ id, x, y, faction: FACTION.PLAYER, glyph: 'c' }); // --- isInEmpBlast geometry -------------------------------------------------- @@ -121,8 +133,8 @@ test('detonateEmp throws on an illegal attempt without burning AP', () => { test('detonateEmp emits EMP_DETONATED with origin and stun count for the shell flash', () => { const bus = new EventBus(); - const events = []; - bus.on(EVENT.EMP_DETONATED, payload => events.push(payload)); + const events: Record[] = []; + bus.on(EVENT.EMP_DETONATED, payload => events.push(payload as Record)); const { world, decker } = makeWorld({ bus, extraEntities: [enemy('a', 6, 5), enemy('b', 4, 5)] }); detonateEmp(world, decker); assert.equal(events.length, 1, 'exactly one detonation event'); diff --git a/tests/unit/game/encounters.test.ts b/tests/unit/game/encounters.test.ts index a051177..f7e420c 100644 --- a/tests/unit/game/encounters.test.ts +++ b/tests/unit/game/encounters.test.ts @@ -6,11 +6,13 @@ import { composeEncounter, encounterHostileCount, hasDurableMedicPatient, + type EncounterComposition, } from '../../../src/game/encounters.js'; import { CONTRACT_DIFFICULTY, ENEMY_ROLE, ENEMY_TIER } from '../../../src/game/constants.js'; -const roles = composition => composition.entries.map(entry => entry.role); -const archetypes = composition => composition.entries.map(entry => entry.archetype); +const roles = (composition: EncounterComposition) => composition.entries.map(entry => entry.role); +const archetypes = (composition: EncounterComposition) => + composition.entries.map(entry => entry.archetype); test('encounterHostileCount matches composed roster size', () => { assert.equal( diff --git a/tests/unit/game/endFlavor.test.ts b/tests/unit/game/endFlavor.test.ts index 43b6c7b..0fba41f 100644 --- a/tests/unit/game/endFlavor.test.ts +++ b/tests/unit/game/endFlavor.test.ts @@ -15,9 +15,8 @@ import { selectEndFlavor, } from '../../../src/game/endFlavor.js'; import type { CampaignSummary } from '../../../src/game/campaignSummary.js'; -import type { CampaignEndReason } from '../../../src/types.js'; -const PROSE_POOLS = [ +const PROSE_POOLS: readonly (readonly string[])[] = [ WIN_BANNERS, WIN_REASONS, WIN_DETAILS, @@ -112,7 +111,11 @@ test('selectEndFlavor draws partial copy from the partial pools, with its own lo }); test('selectEndFlavor gives an aborted Score its own empty-handed copy', () => { - const pool = LOSS_FLAVOR['score-aborted']; + const pool: { + banners: readonly string[]; + reasons: readonly string[]; + details: readonly string[]; + } = LOSS_FLAVOR['score-aborted']; for (let seed = 0; seed < 40; seed++) { const flavor = selectEndFlavor(summary({ result: 'loss', endReason: 'score-aborted', seed })); assert.ok(pool.banners.includes(flavor.banner), `seed ${seed} banner`); @@ -124,9 +127,17 @@ test('selectEndFlavor gives an aborted Score its own empty-handed copy', () => { test('selectEndFlavor keeps losses cause-aware so banners never cross pools', () => { // A clock-expired loss must not borrow the Decker-death banner, and vice versa. - const lossReasons: CampaignEndReason[] = ['clock-expired', 'decker-flatlined-score', 'crew-wipe']; + const lossReasons: (keyof typeof LOSS_FLAVOR)[] = [ + 'clock-expired', + 'decker-flatlined-score', + 'crew-wipe', + ]; for (const endReason of lossReasons) { - const pool = LOSS_FLAVOR[endReason]; + const pool: { + banners: readonly string[]; + reasons: readonly string[]; + details: readonly string[]; + } = LOSS_FLAVOR[endReason]; // Sweep seeds so we exercise every index, not just the default one. for (let seed = 0; seed < 300; seed += 1) { const flavor = selectEndFlavor(summary({ result: 'loss', endReason, seed })); diff --git a/tests/unit/game/enemyAliasSpawn.test.ts b/tests/unit/game/enemyAliasSpawn.test.ts index 24550ad..2e2ab55 100644 --- a/tests/unit/game/enemyAliasSpawn.test.ts +++ b/tests/unit/game/enemyAliasSpawn.test.ts @@ -48,7 +48,7 @@ function combatRun(seed = 7): Run { } function hostilesIn(run: Run): Hostile[] { - return [...run.world.entities.values()].filter((e): e is Hostile => e instanceof Hostile); + return [...run.world!.entities.values()].filter((e): e is Hostile => e instanceof Hostile); } test('Entity carries displayName/principalTag from init, undefined by default', () => { @@ -85,9 +85,9 @@ test('spawned hostiles carry the contract principal’s curated alias', () => { test('the player is not aliased (no displayName in snapshot)', () => { const run = combatRun(); - assert.equal(run.player.displayName, undefined); + assert.equal(run.player!.displayName, undefined); const rec = snapshot(run); - const playerRec = rec.entities.find(e => e.id === run.player.id); + const playerRec = rec.entities.find(e => e.id === run.player!.id); assert.ok(playerRec); assert.ok(!('displayName' in playerRec), 'un-aliased entities omit displayName from snapshot'); }); diff --git a/tests/unit/game/escortExtract.test.ts b/tests/unit/game/escortExtract.test.ts index ed96a66..431910b 100644 --- a/tests/unit/game/escortExtract.test.ts +++ b/tests/unit/game/escortExtract.test.ts @@ -27,6 +27,8 @@ import type { Contract } from '../../../src/game/hub/Curator.js'; function makeEscortContract(overrides: Partial = {}): Contract { return { seed: 212, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.ESCORT_EXTRACT, title: 'Extract clinic witness', @@ -228,7 +230,7 @@ describe('escort runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 212, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeEscortContract()); run.enterCombat(); @@ -269,7 +271,7 @@ describe('escort runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 216, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.onAbortRequested = () => { abortRequested = true; @@ -308,7 +310,7 @@ describe('escort runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 213, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeEscortContract({ seed: 213 })); run.enterCombat(); @@ -337,7 +339,7 @@ describe('escort runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 215, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeEscortContract({ seed: 215 })); run.enterCombat(); diff --git a/tests/unit/game/events.test.ts b/tests/unit/game/events.test.ts index ddcc8ac..1e7e8b7 100644 --- a/tests/unit/game/events.test.ts +++ b/tests/unit/game/events.test.ts @@ -1,11 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { EventBus, EVENT } from '../../../src/game/events.js'; +import { EventBus, EVENT, type EventListener } from '../../../src/game/events.js'; test('EventBus.on subscribes and emit invokes the listener with payload', () => { const bus = new EventBus(); - const calls = []; + const calls: unknown[] = []; bus.on(EVENT.ENTITY_MOVED, payload => calls.push(payload)); bus.emit(EVENT.ENTITY_MOVED, { id: 'a' }); assert.deepEqual(calls, [{ id: 'a' }]); @@ -19,7 +19,7 @@ test('EventBus.emit with no subscribers is a silent no-op', () => { test('EventBus.on returns an unsubscribe function', () => { const bus = new EventBus(); - const calls = []; + const calls: unknown[] = []; const off = bus.on(EVENT.NOISE, p => calls.push(p)); bus.emit(EVENT.NOISE, { tag: 'first' }); off(); @@ -29,8 +29,8 @@ test('EventBus.on returns an unsubscribe function', () => { test('EventBus.off removes a listener by reference', () => { const bus = new EventBus(); - const calls = []; - const fn = p => calls.push(p); + const calls: unknown[] = []; + const fn: EventListener = p => calls.push(p); bus.on(EVENT.TURN_ENDED, fn); bus.off(EVENT.TURN_ENDED, fn); bus.emit(EVENT.TURN_ENDED, { previous: 'player', next: 'corp', turn: 1 }); @@ -46,13 +46,15 @@ test('EventBus rejects unknown event types in on/off/emit (typo-guard)', () => { test('EventBus.on requires a function listener', () => { const bus = new EventBus(); + // @ts-expect-error Runtime validation must reject null. assert.throws(() => bus.on(EVENT.NOISE, null), TypeError); + // @ts-expect-error Runtime validation must reject a string. assert.throws(() => bus.on(EVENT.NOISE, 'not a fn'), TypeError); }); test('EventBus dispatches in registration order', () => { const bus = new EventBus(); - const order = []; + const order: string[] = []; bus.on(EVENT.NOISE, () => order.push('a')); bus.on(EVENT.NOISE, () => order.push('b')); bus.on(EVENT.NOISE, () => order.push('c')); @@ -70,8 +72,8 @@ test('a listener that throws propagates the error (no silent swallow)', () => { test('a listener can unsubscribe during dispatch without breaking the snapshot', () => { const bus = new EventBus(); - const order = []; - let off; + const order: string[] = []; + let off = () => {}; bus.on(EVENT.NOISE, () => { order.push('first'); off(); diff --git a/tests/unit/game/handoff.test.ts b/tests/unit/game/handoff.test.ts index b152bd6..cba6517 100644 --- a/tests/unit/game/handoff.test.ts +++ b/tests/unit/game/handoff.test.ts @@ -47,6 +47,8 @@ function makeCrew(archetype = 'razor') { function makeHandoffContract(overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.HANDOFF, title: 'Make the handoff', @@ -69,10 +71,10 @@ function relocateAdjacentTo(run: Run, entity: Contact): void { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player, x, y); return; } } @@ -81,7 +83,7 @@ function relocateAdjacentTo(run: Run, entity: Contact): void { function contactsIn(run: Run): Contact[] { if (!run.world) throw new Error('run must be in combat'); - return [...run.world.entities.values()].filter( + return [...run.world!.entities.values()].filter( (entity): entity is Contact => entity instanceof Contact ); } @@ -166,7 +168,7 @@ describe('handoff runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeHandoffContract()); run.enterCombat(); @@ -177,7 +179,7 @@ describe('handoff runs', () => { assert.equal(contact.label, 'Pier 9 fence'); assert.ok(run.exitTile, 'handoff run should have an exit tile'); assert.ok( - Math.max(Math.abs(contact.x - run.exitTile.x), Math.abs(contact.y - run.exitTile.y)) > 1, + Math.max(Math.abs(contact.x - run.exitTile!.x), Math.abs(contact.y - run.exitTile!.y)) > 1, 'contact should not spawn adjacent to extraction' ); assert.equal(isObjectiveSatisfied(run.contract!, run.world), false); @@ -186,7 +188,7 @@ describe('handoff runs', () => { run.bus!.emit('entity:moved', { entity: run.player, from: { x: run.player!.x, y: run.player!.y }, - to: { x: run.exitTile.x, y: run.exitTile.y }, + to: { x: run.exitTile!.x, y: run.exitTile!.y }, }); assert.equal(run.state, RUN_STATE.RESULT, 'abort extraction ends the run'); const abortResult = results[0] as { @@ -206,7 +208,7 @@ describe('handoff runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeHandoffContract()); run.enterCombat(); diff --git a/tests/unit/game/hazard.test.ts b/tests/unit/game/hazard.test.ts index 8aacf48..d78b7b2 100644 --- a/tests/unit/game/hazard.test.ts +++ b/tests/unit/game/hazard.test.ts @@ -22,6 +22,7 @@ import { INCENDIARY_IMPACT_DAMAGE, INCENDIARY_BURN_TURNS, moveStepApCost, + type FactionId, } from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; import { @@ -54,7 +55,7 @@ function makeHazardWorld(width = 8, height = 8) { return { grid, world: new World(grid, { events: bus }), bus }; } -function makeEntity(id: string, x: number, y: number, faction = FACTION.PLAYER, hp = 3) { +function makeEntity(id: string, x: number, y: number, faction: FactionId = FACTION.PLAYER, hp = 3) { return new Entity({ id, x, y, faction, glyph: '@', maxHp: hp }); } diff --git a/tests/unit/game/hub/Curator.test.ts b/tests/unit/game/hub/Curator.test.ts index b3a6c60..925f0b4 100644 --- a/tests/unit/game/hub/Curator.test.ts +++ b/tests/unit/game/hub/Curator.test.ts @@ -20,6 +20,7 @@ import { } from '../../../../src/game/hub/Curator.js'; import { MAP_DIMENSION_BANDS } from '../../../../src/game/procgen/mapDimensions.js'; import { buildHub } from '../../../../src/game/hub/SafeSpace.js'; +import type { LocationSite } from '../../../../src/types.js'; test('Curator constructs with NEUTRAL faction and zero AP', () => { const c = new Curator({ x: 2, y: 3 }); @@ -329,7 +330,7 @@ test('P3.M1.5: campaign Clock heat raises threat counts without changing difficu }); test('P3.M1.6: Act 2 guarantees at least one same-principal casing contract', () => { - const roster = [ + const roster: LocationSite[] = [ { id: 'score', seed: '100', @@ -364,7 +365,7 @@ test('P3.M1.6: Act 2 guarantees at least one same-principal casing contract', () }); test('P3.M1.6: Act 3 board is mostly same-principal prep contracts', () => { - const roster = [ + const roster: LocationSite[] = [ { id: 'score', seed: '100', @@ -417,6 +418,7 @@ test('different Rng states yield different seeds (no constant return)', () => { }); test('generateContract throws on missing rng', () => { + // @ts-expect-error Verify runtime validation of a missing RNG. assert.throws(() => new Curator().generateContract(null), /requires an Rng/); }); @@ -484,7 +486,7 @@ test('KNOWN tier (rep 50-79) shifts pool toward elevated/critical vs UNKNOWN', ( test('tier boundary transitions: 19→20 and 79→80 produce different pools', () => { // Collect difficulty distributions across many seeds to verify the // boundary produces a statistically different mix. - const counts = rep => { + const counts = (rep: number) => { let elevated = 0; for (let seed = 0; seed < 200; seed++) { for (const c of new Curator().generateContracts(new Rng(seed), { rep })) { @@ -547,7 +549,8 @@ test('buildHub returns a terminalSpawn distinct from other interactables', () => // Terminal must occupy a walkable tile so the player can stand adjacent. assert.equal(hub.grid.isPassable(hub.terminalSpawn.x, hub.terminalSpawn.y), true); // …and must not collide with the player spawn, Curator, or door. - const same = (a, b) => a.x === b.x && a.y === b.y; + const same = (a: { x: number; y: number }, b: { x: number; y: number }) => + a.x === b.x && a.y === b.y; assert.ok(!same(hub.terminalSpawn, hub.playerSpawn), 'terminal overlaps player spawn'); assert.ok(!same(hub.terminalSpawn, hub.curatorSpawn), 'terminal overlaps curator'); assert.ok(!same(hub.terminalSpawn, hub.exitTile), 'terminal overlaps exit tile'); @@ -608,14 +611,14 @@ test('generateContracts adds door routing on elevated/critical board slots', () doorRouted++; } else if ( contract.difficulty !== CONTRACT_DIFFICULTY.STANDARD && - [ + new Set([ OBJECTIVES.RETRIEVE, OBJECTIVES.HANDOFF, OBJECTIVES.DENY, OBJECTIVES.DUAL_SITE, OBJECTIVES.RECON, OBJECTIVES.ESCORT_EXTRACT, - ].includes(contract.objective.kind) + ]).has(contract.objective.kind) ) { eligibleNonRouted++; } diff --git a/tests/unit/game/hub/Finn.test.ts b/tests/unit/game/hub/Finn.test.ts index 2542293..dbd3448 100644 --- a/tests/unit/game/hub/Finn.test.ts +++ b/tests/unit/game/hub/Finn.test.ts @@ -141,7 +141,7 @@ test('getItemById throws on unknown id', () => { test('every catalog item has a valid scope', () => { const items = getShopCatalog(); - const validScopes = new Set(Object.values(ITEM_SCOPE)); + const validScopes = new Set(Object.values(ITEM_SCOPE)); for (const item of items) { assert.ok(validScopes.has(item.scope), `item ${item.id} has unknown scope "${item.scope}"`); } @@ -173,7 +173,8 @@ test('buildHub returns a finnSpawn on walkable floor, distinct from other NPCs', assert.equal(typeof hub.finnSpawn.x, 'number'); assert.equal(typeof hub.finnSpawn.y, 'number'); assert.equal(hub.grid.isPassable(hub.finnSpawn.x, hub.finnSpawn.y), true); - const same = (a, b) => a.x === b.x && a.y === b.y; + const same = (a: { x: number; y: number }, b: { x: number; y: number }) => + a.x === b.x && a.y === b.y; assert.ok(!same(hub.finnSpawn, hub.playerSpawn), 'finn overlaps player spawn'); assert.ok(!same(hub.finnSpawn, hub.curatorSpawn), 'finn overlaps curator'); assert.ok(!same(hub.finnSpawn, hub.terminalSpawn), 'finn overlaps terminal'); diff --git a/tests/unit/game/hub/arcSurface.test.ts b/tests/unit/game/hub/arcSurface.test.ts index 56c2d53..5ad9a24 100644 --- a/tests/unit/game/hub/arcSurface.test.ts +++ b/tests/unit/game/hub/arcSurface.test.ts @@ -214,7 +214,10 @@ test('act3 reveal copy points at THE SCORE on the job board', () => { test('Score target helpers reject multiple targets instead of guessing', () => { const sites = [scoreSite({ id: 'a' }), scoreSite({ id: 'b' })]; assert.throws(() => findScoreTargetSite(sites), /multiple Score targets/i); - assert.throws(() => scoreTargetSiteId({ arc: arc(), siteRoster: sites }), /multiple Score/i); + assert.throws( + () => scoreTargetSiteId({ arc: arc(), siteRoster: sites, crew: [] }), + /multiple Score/i + ); }); test('score reveal copy names the target and points at job-board badges', () => { diff --git a/tests/unit/game/hub/hubReveals.test.ts b/tests/unit/game/hub/hubReveals.test.ts index c92277a..86136b7 100644 --- a/tests/unit/game/hub/hubReveals.test.ts +++ b/tests/unit/game/hub/hubReveals.test.ts @@ -374,7 +374,7 @@ test('onJobEnd EXIT increments completedJobs and can introduce Finn on next hub' assert.equal(campaign.completedJobs, 0); const contract = new Curator().generateContract(campaign.rng); const run = campaign.deployCrewMember(campaign.crew[0].id, contract); - run.enterCombat(contract); + run.enterCombat(); campaign.onJobEnd({ outcome: OUTCOME.EXIT, salvage: makeSalvage({ scrap: 1 }) }); assert.equal(campaign.completedJobs, 1); assert.ok(campaign.hubReveals.finnIntroduced); diff --git a/tests/unit/game/incendiary.test.ts b/tests/unit/game/incendiary.test.ts index 3a50359..2997eeb 100644 --- a/tests/unit/game/incendiary.test.ts +++ b/tests/unit/game/incendiary.test.ts @@ -38,7 +38,12 @@ function makeWorld(width = 12, height = 12) { } /** A plain blocking, burnable body — the drone stand-in. */ -function makeBody(id: string, x: number, y: number, faction: string = FACTION.CORP) { +function makeBody( + id: string, + x: number, + y: number, + faction: import('../../../src/game/constants.js').FactionId = FACTION.CORP +) { return new Entity({ id, x, y, faction, glyph: 'd', maxHp: 3 }); } diff --git a/tests/unit/game/items.test.ts b/tests/unit/game/items.test.ts index 480cc5f..b4a86da 100644 --- a/tests/unit/game/items.test.ts +++ b/tests/unit/game/items.test.ts @@ -42,14 +42,14 @@ test('applyGear(BONE_LACING) increases maxHp and hp by 1', () => { crew.applyGear(ITEM_ID.BONE_LACING); assert.equal(crew.maxHp, origMaxHp + 1); assert.equal(crew.hp, origHp + 1); - assert.equal(crew.gear.maxHpBonus, 1); + assert.equal(crew.gear!.maxHpBonus, 1); }); test('applyGear(BONE_LACING) stacks', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(ITEM_ID.BONE_LACING); crew.applyGear(ITEM_ID.BONE_LACING); - assert.equal(crew.gear.maxHpBonus, 2); + assert.equal(crew.gear!.maxHpBonus, 2); assert.equal(crew.maxHp, DEFAULT_HP + 2); }); @@ -60,7 +60,7 @@ test('applyGear(BONE_LACING) stacks', () => { test('applyGear(TARGETING_CHIP) sets hitBonus', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(ITEM_ID.TARGETING_CHIP); - assert.equal(crew.gear.hitBonus, TARGETING_BONUS); + assert.equal(crew.gear!.hitBonus, TARGETING_BONUS); }); test('applyGear throws on unknown item', () => { @@ -75,13 +75,13 @@ test('applyGear throws on unknown item', () => { test('applyGear(GHOST_WEAVE) sets dodgeBonus', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(ITEM_ID.GHOST_WEAVE); - assert.equal(crew.gear.dodgeBonus, DODGE_BONUS); + assert.equal(crew.gear!.dodgeBonus, DODGE_BONUS); }); test('applyGear(RIP_ROUNDS) sets rangedDamageBonus', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.applyGear(ITEM_ID.RIP_ROUNDS); - assert.equal(crew.gear.rangedDamageBonus, RANGED_DAMAGE_BONUS); + assert.equal(crew.gear!.rangedDamageBonus, RANGED_DAMAGE_BONUS); }); // --------------------------------------------------------------------------- @@ -92,11 +92,11 @@ test('applyGear(MONOBLADE) raises meleeAttackDamage by the bonus, capped', () => const razor = new Razor({ id: 'razor', x: 0, y: 0, callsign: 'Cipher' }); const base = razor.meleeAttackDamage(); razor.applyGear(ITEM_ID.MONOBLADE); - assert.equal(razor.gear.meleeDamageBonus, MELEE_DAMAGE_BONUS); + assert.equal(razor.gear!.meleeDamageBonus, MELEE_DAMAGE_BONUS); assert.equal(razor.meleeAttackDamage(), base + MELEE_DAMAGE_BONUS); // Capped: a second install is a harmless no-op (mirrors RiP Rounds). razor.applyGear(ITEM_ID.MONOBLADE); - assert.equal(razor.gear.meleeDamageBonus, razor.maxMeleeDamageBonus); + assert.equal(razor.gear!.meleeDamageBonus, razor.maxMeleeDamageBonus); assert.equal(razor.meleeAttackDamage(), base + razor.maxMeleeDamageBonus); }); @@ -121,12 +121,12 @@ test('applyGear(SUBDERMAL_PLATING) raises damageReduction, capped', () => { const merc = new Merc({ id: 'merc', x: 0, y: 0 }); assert.equal(merc.damageReduction, 0); merc.applyGear(ITEM_ID.SUBDERMAL_PLATING); - assert.equal(merc.gear.armorBonus, ARMOR_BONUS); + assert.equal(merc.gear!.armorBonus, ARMOR_BONUS); assert.equal(merc.damageReduction, ARMOR_BONUS); // Capped at one effective unit — a second install changes nothing. merc.applyGear(ITEM_ID.SUBDERMAL_PLATING); assert.equal(merc.damageReduction, merc.maxArmorBonus); - assert.equal(merc.gear.armorBonus, merc.maxArmorBonus); + assert.equal(merc.gear!.armorBonus, merc.maxArmorBonus); }); test('subdermal plating mitigates incoming melee damage with a min-1 floor', () => { @@ -153,20 +153,20 @@ test('applyGear(ADRENAL_SPIKE) grants +AP immediately, capped at one', () => { merc.applyGear(ITEM_ID.ADRENAL_SPIKE); assert.equal(merc.maxAp, DEFAULT_AP + AP_BONUS); assert.equal(merc.ap, apBefore + AP_BONUS, 'extra AP is usable the same turn'); - assert.equal(merc.gear.apBonus, AP_BONUS); + assert.equal(merc.gear!.apBonus, AP_BONUS); // One per operator — a second install is a no-op, no runaway AP. merc.applyGear(ITEM_ID.ADRENAL_SPIKE); assert.equal(merc.maxAp, DEFAULT_AP + merc.maxApBonus); - assert.equal(merc.gear.apBonus, merc.maxApBonus); + assert.equal(merc.gear!.apBonus, merc.maxApBonus); }); test('applyGear(PHASE_SHIELD) sets shieldRegen; refreshAp re-grants the shield each turn', () => { const merc = new Merc({ id: 'merc', x: 0, y: 0, maxAp: 4 }); merc.applyGear(ITEM_ID.PHASE_SHIELD); - assert.equal(merc.gear.shieldRegen, SHIELD_REGEN); + assert.equal(merc.gear!.shieldRegen, SHIELD_REGEN); // Capped — a second install is a no-op. merc.applyGear(ITEM_ID.PHASE_SHIELD); - assert.equal(merc.gear.shieldRegen, merc.maxShieldRegen); + assert.equal(merc.gear!.shieldRegen, merc.maxShieldRegen); // refreshAp tops the buffer back to the regen value every turn. assert.equal(merc.shieldHp, 0); @@ -192,10 +192,10 @@ test('phase shield buffer absorbs damage before HP', () => { test('applyGear(REGEN_MESH) sets hpRegen; refreshAp heals each turn up to maxHp', () => { const merc = new Merc({ id: 'merc', x: 0, y: 0, maxAp: 4 }); merc.applyGear(ITEM_ID.REGEN_MESH); - assert.equal(merc.gear.hpRegen, HP_REGEN); + assert.equal(merc.gear!.hpRegen, HP_REGEN); // Capped — second install is a no-op. merc.applyGear(ITEM_ID.REGEN_MESH); - assert.equal(merc.gear.hpRegen, merc.maxHpRegen); + assert.equal(merc.gear!.hpRegen, merc.maxHpRegen); merc.hp = 1; // wounded merc.refreshAp(); @@ -282,15 +282,15 @@ test('addConsumable puts an item in inventory.consumables', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.addConsumable(ITEM_ID.STIM); assert.ok(crew.inventory, 'inventory should be initialised'); - assert.equal(crew.inventory.consumables.length, 1); - assert.equal(crew.inventory.consumables[0].id, ITEM_ID.STIM); + assert.equal(crew.inventory!.consumables.length, 1); + assert.equal(crew.inventory!.consumables[0].id, ITEM_ID.STIM); }); test('addConsumable stacks (multiple stims)', () => { const crew = new Merc({ id: 'merc', x: 0, y: 0 }); crew.addConsumable(ITEM_ID.STIM); crew.addConsumable(ITEM_ID.STIM); - assert.equal(crew.inventory.consumables.length, 2); + assert.equal(crew.inventory!.consumables.length, 2); }); test('useConsumable(STIM) heals HP and costs 1 AP', () => { @@ -303,7 +303,7 @@ test('useConsumable(STIM) heals HP and costs 1 AP', () => { assert.equal(result.healed, STIM_HEAL); assert.equal(crew.hp, 1 + STIM_HEAL); assert.equal(crew.ap, apBefore - 1); - assert.equal(crew.inventory.consumables.length, 0); + assert.equal(crew.inventory!.consumables.length, 0); }); test('useConsumable(STIM) does not exceed maxHp', () => { @@ -346,7 +346,7 @@ test('useConsumable(SMOKE_CHARGE) returns smoke descriptor', () => { assert.equal(result.cx, 3); assert.equal(result.cy, 3); assert.equal(result.radius, SMOKE_RADIUS); - assert.equal(crew.inventory.consumables.length, 0); + assert.equal(crew.inventory!.consumables.length, 0); }); // --------------------------------------------------------------------------- @@ -369,7 +369,7 @@ test('useConsumable(MOLOTOV) reports the aim, not a landing tile', () => { false, 'a precomputed centre would be a guess the shell then has to ignore' ); - assert.equal(crew.inventory.consumables.length, 0); + assert.equal(crew.inventory!.consumables.length, 0); }); test('useConsumable(BREACHING_CHARGE) returns adjacent breach descriptor', () => { @@ -379,7 +379,7 @@ test('useConsumable(BREACHING_CHARGE) returns adjacent breach descriptor', () => assert.equal(result.type, 'breach'); assert.equal(result.tx, 3 - BREACHING_CHARGE_RANGE); assert.equal(result.ty, 3); - assert.equal(crew.inventory.consumables.length, 0); + assert.equal(crew.inventory!.consumables.length, 0); }); test('useConsumable enforces aim shape for aimed items only', () => { diff --git a/tests/unit/game/jackInPoint.test.ts b/tests/unit/game/jackInPoint.test.ts index 4614777..1e139a5 100644 --- a/tests/unit/game/jackInPoint.test.ts +++ b/tests/unit/game/jackInPoint.test.ts @@ -16,24 +16,25 @@ import { EventBus, EVENT } from '../../../src/game/events.js'; import { snapshot, restore } from '../../../src/game/persistence.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { AP_COST, TILE } from '../../../src/game/constants.js'; -import { OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract } from '../../../src/game/hub/Curator.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'test job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; const cyberContract = (overrides = {}) => fakeContract({ @@ -61,7 +62,7 @@ function makeMerc(x = 1, y = 1) { return buildCrewMember('merc', { x, y }, new Rng(101), { id: 'crew-merc' }); } -function readyForCombat(member: ReturnType) { +function readyForCombat(member: T): T { member.ap = member.maxAp = 4; return member; } diff --git a/tests/unit/game/keycard.test.ts b/tests/unit/game/keycard.test.ts index c26196f..ad0b285 100644 --- a/tests/unit/game/keycard.test.ts +++ b/tests/unit/game/keycard.test.ts @@ -37,7 +37,7 @@ import { KEYCARD_GLYPH, AP_COST, } from '../../../src/game/constants.js'; -import { OBJECTIVES, type ContractContext } from '../../../src/game/hub/Curator.js'; +import { OBJECTIVES, type Contract, type ContractContext } from '../../../src/game/hub/Curator.js'; import { Rng } from '../../../src/rng.js'; import { TurnQueue } from '../../../src/game/TurnQueue.js'; import { testContractContext } from './contractTestUtils.js'; @@ -63,9 +63,11 @@ function makeCrew() { return buildCrewMember('razor', { x: 0, y: 0 }, new Rng(100), { id: 'crew-razor' }); } -function fakeContract(overrides = {}) { +function fakeContract(overrides: Partial = {}): Contract { return { seed: 12345, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.REACH_EXIT, title: 'Extract clean', @@ -386,7 +388,11 @@ test('onKeycardCollected: run-scoped keycard (no principalId) passes principalId ); assert.ok(collected); - assert.equal(collected!.principalId, null, 'run-scoped keycard should have principalId: null'); + assert.equal( + (collected as unknown as { principalId: string | null }).principalId, + null, + 'run-scoped keycard should have principalId: null' + ); }); test('onKeycardCollected: campaign-scoped keycard (with principalId) passes principalId through', () => { @@ -428,7 +434,7 @@ test('onKeycardCollected: campaign-scoped keycard (with principalId) passes prin assert.ok(collected); assert.equal( - collected!.principalId, + (collected as unknown as { principalId: string | null }).principalId, 'matsuda', 'campaign-scoped keycard should pass principalId' ); @@ -557,8 +563,9 @@ test('collectTileLoot picks up keycard and invokes onKeycardCollected', () => { ); assert.ok(collected, 'onKeycardCollected should fire'); - assert.equal(collected!.id, 'kc-1'); - assert.equal(collected!.doorId, 'door-0'); + const picked = collected as unknown as { id: string; doorId: string }; + assert.equal(picked.id, 'kc-1'); + assert.equal(picked.doorId, 'door-0'); assert.equal(world.keycardAt(3, 1), null, 'keycard removed from world'); assert.ok(log.some(l => l.includes('Access keycard'))); }); diff --git a/tests/unit/game/mindInfluence.test.ts b/tests/unit/game/mindInfluence.test.ts index fb48915..d55956e 100644 --- a/tests/unit/game/mindInfluence.test.ts +++ b/tests/unit/game/mindInfluence.test.ts @@ -26,7 +26,11 @@ import { import { TILE, FACTION, AP_COST, INFLUENCE_DURATION } from '../../../src/game/constants.js'; import { Rng } from '../../../src/rng.js'; -function makeWorld({ operatorAt = [1, 1], grid, extraEntities = [] } = {}) { +function makeWorld({ + operatorAt = [1, 1], + grid, + extraEntities = [], +}: { operatorAt?: [number, number]; grid?: Grid; extraEntities?: Entity[] } = {}) { const g = grid ?? new Grid(12, 12); const w = new World(g); // A generic PLAYER-faction operator — the module only needs alive/canAfford/ @@ -43,7 +47,12 @@ function makeWorld({ operatorAt = [1, 1], grid, extraEntities = [] } = {}) { return { world: w, operator }; } -function makeDrone(id, x, y, faction = FACTION.CORP) { +function makeDrone( + id: string, + x: number, + y: number, + faction: ConstructorParameters[0]['faction'] = FACTION.CORP +) { return new Skirmisher({ id, x, y, faction }); } diff --git a/tests/unit/game/objectiveProgress.test.ts b/tests/unit/game/objectiveProgress.test.ts index df495e4..d5ba6ac 100644 --- a/tests/unit/game/objectiveProgress.test.ts +++ b/tests/unit/game/objectiveProgress.test.ts @@ -28,6 +28,8 @@ function makeWorld(w = 12, h = 12): World { function makeSweepContract(target: string): Contract { return { seed: 42, + mapWidth: 12, + mapHeight: 12, objective: { kind: OBJECTIVES.SWEEP, title: 'Sweep test', @@ -46,6 +48,8 @@ test('objectiveProgress returns null for objectives without a meter', () => { const world = makeWorld(); const contract: Contract = { seed: 1, + mapWidth: 12, + mapHeight: 12, objective: { kind: OBJECTIVES.REACH_EXIT, title: 'Exit', diff --git a/tests/unit/game/persistence.test.ts b/tests/unit/game/persistence.test.ts index eac2d96..18a8f95 100644 --- a/tests/unit/game/persistence.test.ts +++ b/tests/unit/game/persistence.test.ts @@ -2,8 +2,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Campaign, CAMPAIGN_STATE } from '../../../src/game/Campaign.js'; -import { Run, RUN_STATE } from '../../../src/game/Run.js'; -import { buildContractRecipeFixture, OBJECTIVES } from '../../../src/game/hub/Curator.js'; +import { Run, RUN_STATE, type CrewArchetypeId } from '../../../src/game/Run.js'; +import { + buildContractRecipeFixture, + OBJECTIVES, + type Contract, +} from '../../../src/game/hub/Curator.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; import { Door } from '../../../src/game/entities/Door.js'; import { Guard } from '../../../src/game/ai/Guard.js'; @@ -30,6 +34,7 @@ import { placeSmoke } from '../../../src/game/Smoke.js'; import { makeSalvage, totalSalvage } from '../../../src/game/salvage.js'; import { buildCrewMember } from '../../../src/game/archetypes/index.js'; import { Decker } from '../../../src/game/archetypes/Decker.js'; +import { Tech } from '../../../src/game/archetypes/Tech.js'; import { Berserk } from '../../../src/game/archetypes/Berserk.js'; import { Adept } from '../../../src/game/archetypes/Adept.js'; import { Chimera } from '../../../src/game/archetypes/Chimera.js'; @@ -38,26 +43,29 @@ import { DEFAULT_DODGE_CHANCE_BY_ARCHETYPE, } from '../../../src/game/crewStatRoll.js'; import { PatrolHostile } from '../../../src/game/ai/PatrolHostile.js'; +import { Entity } from '../../../src/game/Entity.js'; +import { Turret } from '../../../src/game/Turret.js'; import { applyOverride } from '../../../src/game/mindInfluence.js'; import { Rng } from '../../../src/rng.js'; import { testContractContext } from './contractTestUtils.js'; -const fakeContract = (overrides = {}) => ({ - seed: 12345, - objective: { - kind: OBJECTIVES.REACH_EXIT, - title: 'Extract clean', - briefing: 'Reach the exit.', - }, - difficulty: 'standard', - threatCount: 1, - label: 'test job', - context: testContractContext(OBJECTIVES.REACH_EXIT), - reward: { credits: 0, repDelta: 0 }, - ...overrides, -}); +const fakeContract = (overrides: Partial = {}): Contract => + ({ + seed: 12345, + objective: { + kind: OBJECTIVES.REACH_EXIT, + title: 'Extract clean', + briefing: 'Reach the exit.', + }, + difficulty: 'standard', + threatCount: 1, + label: 'test job', + context: testContractContext(OBJECTIVES.REACH_EXIT), + reward: { credits: 0, repDelta: 0 }, + ...overrides, + }) as Contract; -const terminalSliceContract = (overrides = {}) => +const terminalSliceContract = (overrides: Partial = {}) => fakeContract({ objective: { kind: OBJECTIVES.TERMINAL_SLICE, @@ -70,40 +78,40 @@ const terminalSliceContract = (overrides = {}) => ...overrides, }); -function makeCrew(archetype = 'razor') { +function makeCrew(archetype: CrewArchetypeId = 'razor') { return buildCrewMember(archetype, { x: 0, y: 0 }, new Rng(100), { id: `crew-${archetype}`, }); } -function relocateAdjacentTo(run, entity) { +function relocateAdjacentTo(run: Run, entity: Entity) { for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player!, x, y); return; } } throw new Error(`No adjacent passable tile for ${entity.id}`); } -function freeCombatTile(run, after = { x: 0, y: 0 }) { - for (let y = after.y; y < run.world.grid.height; y++) { - for (let x = y === after.y ? after.x : 0; x < run.world.grid.width; x++) { - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; +function freeCombatTile(run: Run, after = { x: 0, y: 0 }) { + for (let y = after.y; y < run.world!.grid.height; y++) { + for (let x = y === after.y ? after.x : 0; x < run.world!.grid.width; x++) { + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; return { x, y }; } } throw new Error('No free passable tile in fixture run'); } -function freshCombatRun(seed = 1, archetype = 'razor') { +function freshCombatRun(seed = 1, archetype: CrewArchetypeId = 'razor') { const run = new Run({ crewMember: makeCrew(archetype), seed }); run.enterBriefing(fakeContract()); run.enterCombat(); @@ -120,7 +128,7 @@ test('run snapshot → restore → snapshot is byte-for-byte stable', () => { test('snapshot/restore preserves an unlooted hostile corpse and its exact salvage', () => { const run = freshCombatRun(0xdecafbad); - const corpse = [...run.world.entities.values()].find(entity => entity instanceof PatrolHostile); + const corpse = [...run.world!.entities.values()].find(entity => entity instanceof PatrolHostile); assert.ok(corpse, 'fixture should contain a patrol hostile'); corpse.hp = 0; corpse.alive = false; @@ -134,12 +142,12 @@ test('snapshot/restore preserves an unlooted hostile corpse and its exact salvag const restoredCorpse = restoredWorld.lootableCorpseAt(corpse.x, corpse.y); assert.ok(restoredCorpse, 'restored corpse should remain lootable'); assert.equal(restoredCorpse.id, corpse.id); - assert.deepEqual(restoredCorpse.loot.salvage, makeSalvage({ scrap: 2, chips: 1 })); + assert.deepEqual(restoredCorpse.loot!.salvage, makeSalvage({ scrap: 2, chips: 1 })); }); test('restore rejects malformed corpse loot instead of discarding it', () => { const run = freshCombatRun(0xdecafbad); - const corpse = [...run.world.entities.values()].find(entity => entity instanceof PatrolHostile); + const corpse = [...run.world!.entities.values()].find(entity => entity instanceof PatrolHostile); assert.ok(corpse, 'fixture should contain a patrol hostile'); corpse.hp = 0; corpse.alive = false; @@ -158,7 +166,7 @@ test('snapshot/restore round-trips a Decker deploy (P3.M2)', () => { const rec = snapshot(run); const { run: restoredRun } = restore(rec); assert.ok(restoredRun.player instanceof Decker, 'Decker should restore as a Decker'); - assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.equal(restoredRun.player!.callsign, run.player!.callsign); // Stable round-trip through a second snapshot. assert.deepEqual(snapshot(restoredRun), rec); }); @@ -169,7 +177,7 @@ test('snapshot/restore round-trips a Berserk deploy (P3.5.M3)', () => { const rec = snapshot(run); const { run: restoredRun } = restore(rec); assert.ok(restoredRun.player instanceof Berserk, 'Berserk should restore as a Berserk'); - assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.equal(restoredRun.player!.callsign, run.player!.callsign); assert.deepEqual(snapshot(restoredRun), rec); }); @@ -179,7 +187,7 @@ test('snapshot/restore round-trips an Adept deploy (P3.5.M4)', () => { const rec = snapshot(run); const { run: restoredRun } = restore(rec); assert.ok(restoredRun.player instanceof Adept, 'Adept should restore as an Adept'); - assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.equal(restoredRun.player!.callsign, run.player!.callsign); assert.deepEqual(snapshot(restoredRun), rec); }); @@ -189,7 +197,7 @@ test('snapshot/restore round-trips a Chimera deploy (P3.5.M5)', () => { const rec = snapshot(run); const { run: restoredRun } = restore(rec); assert.ok(restoredRun.player instanceof Chimera, 'Chimera should restore as a Chimera'); - assert.equal(restoredRun.player.callsign, run.player.callsign); + assert.equal(restoredRun.player!.callsign, run.player!.callsign); assert.deepEqual(snapshot(restoredRun), rec); }); @@ -245,14 +253,16 @@ test('snapshot/restore preserves Berserk Crash and its derived hit penalty', () test('snapshot/restore preserves a live drone-override (P3.M2)', () => { const run = freshCombatRun(0xbadbeef, 'decker'); - const drone = [...run.world.entities.values()].find(e => e instanceof PatrolHostile); + const drone = [...run.world!.entities.values()].find(e => e instanceof PatrolHostile); assert.ok(drone, 'fixture run should contain at least one patrol hostile'); const originalFaction = drone.faction; applyOverride(drone, FACTION.PLAYER); const rec = snapshot(run); const { world: restoredWorld } = restore(rec); - const restoredDrone = [...restoredWorld.entities.values()].find(e => e.id === drone.id); + const restoredDrone = [...restoredWorld.entities.values()].find( + (e): e is PatrolHostile => e.id === drone.id && e instanceof PatrolHostile + ); assert.ok(restoredDrone, 'overridden drone should survive the round-trip'); assert.equal(restoredDrone.faction, FACTION.PLAYER, 'flipped faction preserved'); assert.equal(restoredDrone.factionBeforeOverride, originalFaction, 'prior faction preserved'); @@ -265,19 +275,19 @@ test('a never-overridden drone snapshot omits override fields (no shape drift)', const rec = snapshot(run); const droneRec = rec.entities.find(e => e.archetype === 'drone' || e.archetype === 'guard'); assert.ok(droneRec, 'expected a patrol hostile in the snapshot'); - assert.equal(droneRec.extra.overrideTurnsRemaining, undefined); - assert.equal(droneRec.extra.factionBeforeOverride, undefined); + assert.equal(droneRec.extra!.overrideTurnsRemaining, undefined); + assert.equal(droneRec.extra!.factionBeforeOverride, undefined); }); test('snapshot/restore round-trips a Tech with a placed turret (M1)', () => { const run = freshCombatRun(0xc0ffee, 'tech'); - const tech = run.player; - let placed = null; + const tech = run.player as Tech; + let placed: Turret | null = null; for (let dy = -1; dy <= 1 && !placed; dy++) { for (let dx = -1; dx <= 1 && !placed; dx++) { if (dx === 0 && dy === 0) continue; - if (tech.canDeploy(run.world, dx, dy).ok) { - placed = tech.deployTurret(run.world, dx, dy); + if (tech.canDeploy(run.world!, dx, dy).ok) { + placed = tech.deployTurret(run.world!, dx, dy); } } } @@ -286,8 +296,10 @@ test('snapshot/restore round-trips a Tech with a placed turret (M1)', () => { const rec = snapshot(run); const { run: restoredRun, world: restoredWorld } = restore(rec); - assert.equal(restoredRun.player.turretReady, false); - const restoredTurret = [...restoredWorld.entities.values()].find(e => e.id === placed.id); + assert.equal((restoredRun.player as Tech).turretReady, false); + const restoredTurret = [...restoredWorld.entities.values()].find( + (e): e is Turret => e.id === placed.id && e instanceof Turret + ); assert.ok(restoredTurret, 'restored world should contain the deployed turret'); assert.equal(restoredTurret.faction, FACTION.PLAYER); assert.equal(restoredTurret.hp, 2); @@ -312,29 +324,34 @@ test('restored Rng produces the same next 5 numbers as the live one', () => { test('restore reconstructs world entities with their HP / AP / stealth state and callsign', () => { const run = freshCombatRun(42); - run.player.hp = 1; - run.player.ap = 2; - run.player.stealthed = true; - run.player.damageReduction = 1; + run.player!.hp = 1; + run.player!.ap = 2; + run.player!.stealthed = true; + run.player!.damageReduction = 1; const rec = snapshot(run); - const { player } = restore(rec); + const player = restore(rec).player!; assert.equal(player.hp, 1); assert.equal(player.ap, 2); assert.equal(player.stealthed, true); assert.equal(player.damageReduction, 1); - assert.equal(player.callsign, run.player.callsign); + assert.equal(player.callsign, run.player!.callsign); }); test('restore preserves drone AI state (mode + lastKnownTarget + patrol index)', () => { const run = freshCombatRun(0xdead); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find( + (e): e is PatrolHostile => e.faction === FACTION.CORP && e instanceof PatrolHostile + ); assert.ok(drone, 'expected at least one drone for threatCount=1'); drone.state = 'investigate'; drone.lastKnownTarget = { x: 5, y: 7 }; drone.patrolIndex = 1; const rec = snapshot(run); const { world: restoredWorld } = restore(rec); - const restoredDrone = [...restoredWorld.entities.values()].find(e => e.faction === FACTION.CORP); + const restoredDrone = [...restoredWorld.entities.values()].find( + (e): e is PatrolHostile => e.faction === FACTION.CORP && e instanceof PatrolHostile + ); + assert.ok(restoredDrone); assert.equal(restoredDrone.state, 'investigate'); assert.deepEqual(restoredDrone.lastKnownTarget, { x: 5, y: 7 }); assert.equal(restoredDrone.patrolIndex, 1); @@ -345,7 +362,9 @@ test('restore throws on a drone patrolIndex past the waypoint list', () => { // without a guard. A corrupt / stale index must fail loudly at restore, // not crash mid-turn. const run = freshCombatRun(0xbad1); - const fodder = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const fodder = [...run.world!.entities.values()].find( + (e): e is PatrolHostile => e.faction === FACTION.CORP && e instanceof PatrolHostile + ); assert.ok(fodder, 'expected at least one fodder hostile for threatCount=1'); const rec = snapshot(run); const fodderRec = rec.entities.find(e => e.id === fodder.id); @@ -361,7 +380,7 @@ test('restore throws on a drone patrolIndex past the waypoint list', () => { test('Guard round-trips through snapshot/restore as archetype "guard"', () => { // fakeContract seed 12345 / fodderCount 1 deterministically rolls a guard. const run = freshCombatRun(7); - const guard = [...run.world.entities.values()].find(e => e instanceof Guard); + const guard = [...run.world!.entities.values()].find(e => e instanceof Guard); assert.ok(guard, 'expected a Guard from this contract seed'); guard.state = 'engage'; guard.lastKnownTarget = { x: 4, y: 9 }; @@ -386,7 +405,7 @@ test('Bruiser round-trips through snapshot/restore as archetype "bruiser"', () = // CRITICAL elite pool to include Flanker. run.enterBriefing(fakeContract({ seed: 0, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const bruiser = [...run.world.entities.values()].find(e => e instanceof Bruiser); + const bruiser = [...run.world!.entities.values()].find(e => e instanceof Bruiser); assert.ok(bruiser instanceof Bruiser, 'expected a Bruiser from this critical contract'); bruiser.state = 'engage'; bruiser.lastKnownTarget = { x: 3, y: 8 }; @@ -409,7 +428,7 @@ test('Juggernaut round-trips through snapshot/restore as archetype "juggernaut"' // contract seed 1 deterministically rolls a Juggernaut elite. run.enterBriefing(fakeContract({ seed: 1, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const juggernaut = [...run.world.entities.values()].find(e => e instanceof Juggernaut); + const juggernaut = [...run.world!.entities.values()].find(e => e instanceof Juggernaut); assert.ok(juggernaut instanceof Juggernaut, 'expected a Juggernaut from this critical contract'); juggernaut.state = 'engage'; juggernaut.lastKnownTarget = { x: 5, y: 4 }; @@ -433,7 +452,7 @@ test('Flanker round-trips through snapshot/restore as archetype "flanker"', () = // contract seed 2 deterministically rolls a Flanker elite. run.enterBriefing(fakeContract({ seed: 2, difficulty: 'critical', threatCount: 4 })); run.enterCombat(); - const flanker = [...run.world.entities.values()].find(e => e instanceof Flanker); + const flanker = [...run.world!.entities.values()].find(e => e instanceof Flanker); assert.ok(flanker instanceof Flanker, 'expected a Flanker from this critical contract'); flanker.state = 'engage'; flanker.lastKnownTarget = { x: 5, y: 4 }; @@ -460,15 +479,15 @@ test('Medic and temporary shield round-trip through snapshot/restore', () => { const patientAnchor = freeCombatTile(run, { x: medicAnchor.x + 1, y: medicAnchor.y }); const medic = new Medic({ id: 'medic-0', x: medicAnchor.x, y: medicAnchor.y, maxAp: 1 }); medic.state = 'engage'; - medic.lastKnownTarget = { x: run.player.x, y: run.player.y }; + medic.lastKnownTarget = { x: run.player!.x, y: run.player!.y }; const patient = new Juggernaut({ id: 'juggernaut-fixture', x: patientAnchor.x, y: patientAnchor.y, }); patient.addShield(2); - run.world.addEntity(medic); - run.world.addEntity(patient); + run.world!.addEntity(medic); + run.world!.addEntity(patient); const rec = snapshot(run); const medicRec = rec.entities.find(e => e.id === medic.id); @@ -483,30 +502,30 @@ test('Medic and temporary shield round-trip through snapshot/restore', () => { assert.ok(restoredMedic instanceof Medic, 'restored as a Medic'); assert.equal(restoredMedic.glyph, 'm'); assert.equal(restoredMedic.state, 'engage'); - assert.deepEqual(restoredMedic.lastKnownTarget, { x: run.player.x, y: run.player.y }); + assert.deepEqual(restoredMedic.lastKnownTarget, { x: run.player!.x, y: run.player!.y }); assert.ok(restoredPatient instanceof Juggernaut); assert.equal(restoredPatient.shieldHp, 2); }); test('restore preserves turnNumber and currentFaction', () => { const run = freshCombatRun(11); - run.queue.endTurn(run.world); - run.queue.endTurn(run.world); + run.queue!.endTurn(run.world!); + run.queue!.endTurn(run.world!); const rec = snapshot(run); const { queue } = restore(rec); - assert.equal(queue.turnNumber, run.queue.turnNumber); - assert.equal(queue.currentFaction, run.queue.currentFaction); + assert.equal(queue.turnNumber, run.queue!.turnNumber); + assert.equal(queue.currentFaction, run.queue!.currentFaction); }); test('snapshot/restore round-trips terminal interactable state', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 0x51ced }); run.enterBriefing(terminalSliceContract()); run.enterCombat(); - const terminal = [...run.world.entities.values()].find(e => e instanceof Terminal); + const terminal = [...run.world!.entities.values()].find(e => e instanceof Terminal); assert.ok(terminal, 'expected a terminal interactable'); relocateAdjacentTo(run, terminal); - terminal.interact(run.world, run.player); + terminal.interact(run.world!, run.player!); const rec = snapshot(run); const terminalRec = rec.entities.find(e => e.id === terminal.id); @@ -522,15 +541,15 @@ test('snapshot/restore round-trips terminal interactable state', () => { test('snapshot/restore round-trips alarm cadence state', () => { const run = freshCombatRun(0xa1a12); - run.world.raiseAlarm({ origin: { x: run.player.x, y: run.player.y } }); - run.world.tickAlarm(); + run.world!.raiseAlarm({ origin: { x: run.player!.x, y: run.player!.y } }); + run.world!.tickAlarm(); const rec = snapshot(run); - assert.equal(rec.alarm.phase, 'alert'); - assert.equal(rec.alarm.holdTurnsRemaining, 1); + assert.equal(rec.alarm!.phase, 'alert'); + assert.equal(rec.alarm!.holdTurnsRemaining, 1); const { world: restoredWorld } = restore(rec); - assert.deepEqual(restoredWorld.alarm, run.world.alarm); + assert.deepEqual(restoredWorld.alarm, run.world!.alarm); assert.equal(restoredWorld.alarmActive, true); }); @@ -548,6 +567,7 @@ test('restore migrates legacy alarmActive run snapshots into alarm state', () => test('restore throws on corrupt run records', () => { const run = freshCombatRun(1); const missingRng = snapshot(run); + // @ts-expect-error Simulate a legacy/corrupt record missing a required field. delete missingRng.rng; assert.throws(() => restore(missingRng), /rng/); @@ -560,6 +580,7 @@ test('restore throws on corrupt run records', () => { assert.throws(() => restore(oob), /out of bounds/); const unknownArchetype = snapshot(run); + // @ts-expect-error Runtime validation must reject an unknown archetype. unknownArchetype.entities[0].archetype = 'wizard'; assert.throws(() => restore(unknownArchetype), /unknown archetype/); @@ -573,10 +594,12 @@ test('restore throws on corrupt run records', () => { assert.throws(() => restore(badAlive)); const badState = snapshot(run); + // @ts-expect-error Runtime validation must reject an unknown run state. badState.state = 'WIBBLE'; assert.throws(() => restore(badState), /unknown run state/); const badType = snapshot(run); + // @ts-expect-error Runtime validation must reject an unknown record type. badType.type = 'other'; assert.throws(() => restore(badType), /type/); }); @@ -587,7 +610,9 @@ test('snapshot before any Run state set throws', () => { }); test('snapshot without a Run instance throws TypeError', () => { + // @ts-expect-error Runtime validation must reject a non-Run object. assert.throws(() => snapshot({}), TypeError); + // @ts-expect-error Runtime validation must reject null. assert.throws(() => snapshot(null), TypeError); }); @@ -634,14 +659,14 @@ test('campaign snapshot captures an active briefing job', () => { campaign.deployCrewMember(campaign.crew[2].id, fakeContract({ label: 'briefing job' })); const rec = snapshotCampaign(campaign); assert.equal(rec.type, 'campaign'); - assert.equal(rec.activeRun.state, 'BRIEFING'); - assert.equal(rec.activeRun.contract.label, 'briefing job'); - assert.equal(rec.activeRun.contract.reward.credits, 0); + assert.equal(rec.activeRun!.state, 'BRIEFING'); + assert.equal(rec.activeRun!.contract!.label, 'briefing job'); + assert.equal(rec.activeRun!.contract!.reward.credits, 0); const restored = restoreCampaign(rec); - assert.equal(restored.activeRun.state, 'BRIEFING'); - assert.equal(restored.activeRun.contract.label, 'briefing job'); - assert.equal(restored.activeRun.crewMember.id, campaign.crew[2].id); + assert.equal(restored.activeRun!.state, 'BRIEFING'); + assert.equal(restored.activeRun!.contract!.label, 'briefing job'); + assert.equal(restored.activeRun!.crewMember.id, campaign.crew[2].id); }); test('resumed briefing run enters combat without redeploying', () => { @@ -705,8 +730,8 @@ test('restoreCampaign migrates legacy active-run salvage rewards to Creds', () = const restored = restoreCampaign(rec); - assert.equal(restored.activeRun!.contract.reward.credits, 6 * SALVAGE_TO_CRED_RATE); - assert.equal(restored.activeRun!.contract.reward.repDelta, 2); + assert.equal(restored.activeRun!.contract!.reward.credits, 6 * SALVAGE_TO_CRED_RATE); + assert.equal(restored.activeRun!.contract!.reward.repDelta, 2); }); test('restoreCampaign migrates legacy string objectives to objective records', () => { @@ -718,8 +743,8 @@ test('restoreCampaign migrates legacy string objectives to objective records', ( const restored = restoreCampaign(rec); - assert.equal(restored.activeRun!.contract.objective.kind, OBJECTIVES.REACH_EXIT); - assert.equal(typeof restored.activeRun!.contract.objective.briefing, 'string'); + assert.equal(restored.activeRun!.contract!.objective.kind, OBJECTIVES.REACH_EXIT); + assert.equal(typeof restored.activeRun!.contract!.objective.briefing, 'string'); }); test('restoreCampaign throws on corrupt campaign records', () => { @@ -748,7 +773,12 @@ test('restoreCampaign normalizes over-capped hitBonus in crew gear', () => { const campaign = new Campaign({ seed: 0xfade }); const rec = snapshotCampaign(campaign); // Inject corrupted gear — 0.5 hitBonus exceeds any archetype's cap. - rec.crew[0].gear = { maxHpBonus: 0, hitBonus: 0.5 }; + rec.crew[0].gear = { + maxHpBonus: 0, + hitBonus: 0.5, + dodgeBonus: 0, + rangedDamageBonus: 0, + }; const restored = restoreCampaign(rec); const member = restored.crew[0]; assert.ok( @@ -761,7 +791,12 @@ test('restoreCampaign normalizes over-capped hitBonus in crew gear', () => { test('restoreCampaign normalizes over-capped dodgeBonus in crew gear', () => { const campaign = new Campaign({ seed: 42 }); const rec = snapshotCampaign(campaign); - rec.crew[0].gear = { maxHpBonus: 0, hitBonus: 0, dodgeBonus: 0.9 }; + rec.crew[0].gear = { + maxHpBonus: 0, + hitBonus: 0, + dodgeBonus: 0.9, + rangedDamageBonus: 0, + }; const restored = restoreCampaign(rec); const member = restored.crew[0]; assert.ok(member.gear!.dodgeBonus <= member.maxDodgeBonus); @@ -771,7 +806,12 @@ test('restoreCampaign normalizes over-capped dodgeBonus in crew gear', () => { test('restoreCampaign preserves valid hitBonus below cap', () => { const campaign = new Campaign({ seed: 0xfade }); const rec = snapshotCampaign(campaign); - rec.crew[0].gear = { maxHpBonus: 0, hitBonus: 0.1 }; + rec.crew[0].gear = { + maxHpBonus: 0, + hitBonus: 0.1, + dodgeBonus: 0, + rangedDamageBonus: 0, + }; const restored = restoreCampaign(rec); assert.equal(restored.crew[0].gear!.hitBonus, 0.1); }); @@ -835,10 +875,10 @@ test('CampaignCrewSnapshot persists the pristine baseHitChance, not a live Berse test('restore normalizes over-capped hitBonus in run entity gear', () => { const run = freshCombatRun(0xc0de, 'merc'); - run.player.initGear(); - run.player.gear!.hitBonus = 0.5; // corrupt: exceeds Merc's 0.2 cap + run.player!.initGear(); + run.player!.gear!.hitBonus = 0.5; // corrupt: exceeds Merc's 0.2 cap const rec = snapshot(run); - const { player } = restore(rec); + const player = restore(rec).player!; assert.ok( player.gear!.hitBonus <= player.maxHitBonus, `hitBonus ${player.gear!.hitBonus} should be ≤ maxHitBonus ${player.maxHitBonus}` @@ -851,12 +891,12 @@ test('restore normalizes over-capped hitBonus in run entity gear', () => { test('restore nudges a colliding entity to an adjacent tile instead of throwing', () => { const run = freshCombatRun(0xbeef); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone, 'expected at least one corp entity'); const rec = snapshot(run); // Corrupt the snapshot: move the drone onto the player's tile. - const playerRec = rec.entities.find(e => e.id === run.player.id); + const playerRec = rec.entities.find(e => e.id === run.player!.id); const droneRec = rec.entities.find(e => e.id === drone.id); assert.ok(playerRec && droneRec); droneRec.x = playerRec.x; @@ -879,11 +919,11 @@ test('restore nudges a colliding entity to an adjacent tile instead of throwing' test('restore throws when tile is occupied and no free neighbour exists', () => { const run = freshCombatRun(0xcafe); - const drone = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const drone = [...run.world!.entities.values()].find(e => e.faction === FACTION.CORP); assert.ok(drone, 'expected at least one corp entity'); const rec = snapshot(run); - const playerRec = rec.entities.find(e => e.id === run.player.id); + const playerRec = rec.entities.find(e => e.id === run.player!.id); const droneRec = rec.entities.find(e => e.id === drone.id); assert.ok(playerRec && droneRec); @@ -913,7 +953,9 @@ test('restore throws when tile is occupied and no free neighbour exists', () => test('M6.2: a fodder snapshot uses the slim extra bag, not a named sub-block', () => { const run = freshCombatRun(0xa5a5); - const fodder = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const fodder = [...run.world!.entities.values()].find( + (e): e is PatrolHostile => e.faction === FACTION.CORP && e instanceof PatrolHostile + ); assert.ok(fodder, 'expected a corp fodder hostile'); const rec = snapshot(run); const fodderRec = rec.entities.find(e => e.id === fodder.id); @@ -937,7 +979,7 @@ test('M6.2: restore throws when a terminal record carries no state', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 0x51ced }); run.enterBriefing(terminalSliceContract()); run.enterCombat(); - const terminal = [...run.world.entities.values()].find(e => e instanceof Terminal); + const terminal = [...run.world!.entities.values()].find(e => e instanceof Terminal); assert.ok(terminal, 'expected a terminal'); const rec = snapshot(run); const tRec = rec.entities.find(e => e.id === terminal.id); @@ -950,7 +992,7 @@ test('M6.2: restore throws on a door glyph that disagrees with locked', () => { const run = freshCombatRun(0xd00d); const tile = freeCombatTile(run); const door = new Door({ id: 'door-0', x: tile.x, y: tile.y, doorId: 'd-1', locked: true }); - run.world.addEntity(door); + run.world!.addEntity(door); const rec = snapshot(run); const doorRec = rec.entities.find(e => e.id === 'door-0'); assert.ok(doorRec?.extra, 'door state lives in the extra bag'); @@ -962,7 +1004,9 @@ test('M6.2: restore normalizes a legacy patrol sub-block into extra', () => { // Back-compat: pre-M6.2 saves keyed the patrol block under the archetype id // (`drone` = Skirmisher) at the top level instead of `extra`. const run = freshCombatRun(0xfeed); - const fodder = [...run.world.entities.values()].find(e => e.faction === FACTION.CORP); + const fodder = [...run.world!.entities.values()].find( + (e): e is PatrolHostile => e.faction === FACTION.CORP && e instanceof PatrolHostile + ); assert.ok(fodder); fodder.state = 'investigate'; fodder.lastKnownTarget = { x: 3, y: 4 }; @@ -974,7 +1018,7 @@ test('M6.2: restore normalizes a legacy patrol sub-block into extra', () => { delete legacy.extra; const { world } = restore(rec); - const restored = world.entities.get(fodder.id); + const restored = world.entities.get(fodder.id) as PatrolHostile | undefined; assert.ok(restored, 'legacy-shaped fodder still restores'); assert.equal(restored.state, 'investigate'); assert.deepEqual(restored.lastKnownTarget, { x: 3, y: 4 }); @@ -984,7 +1028,7 @@ test('M6.2: restore normalizes a legacy terminal sub-block into extra', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 0x51ced }); run.enterBriefing(terminalSliceContract()); run.enterCombat(); - const terminal = [...run.world.entities.values()].find(e => e instanceof Terminal); + const terminal = [...run.world!.entities.values()].find(e => e instanceof Terminal); assert.ok(terminal); const rec = snapshot(run); const tRec = rec.entities.find(e => e.id === terminal.id); @@ -1003,15 +1047,15 @@ test('M6.2: restore normalizes a legacy terminal sub-block into extra', () => { test('M6.2: restore normalizes legacy top-level crew fields into extra', () => { // Pre-M6.2 saves stored callsign/flatlined/inventory/gear at the top level. const run = freshCombatRun(0xc0b0, 'razor'); - run.player.callsign = 'Ghost'; + run.player!.callsign = 'Ghost'; const rec = snapshot(run); - const playerRec = rec.entities.find(e => e.id === run.player.id); + const playerRec = rec.entities.find(e => e.id === run.player!.id); assert.ok(playerRec); const legacy = playerRec as Record; Object.assign(legacy, legacy.extra); delete legacy.extra; - const { player } = restore(rec); + const player = restore(rec).player!; assert.equal(player.callsign, 'Ghost'); }); @@ -1028,7 +1072,7 @@ test('M6.2: restore normalizes legacy top-level crew fields into extra', () => { test('P3.6: thrown-fire burn timers survive a snapshot round-trip', () => { const run = freshCombatRun(0xf1e5, 'razor'); const tile = freeCombatTile(run); - run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + run.world!.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); const rec = snapshot(run); assert.deepEqual( @@ -1052,17 +1096,17 @@ test('P3.6: thrown-fire burn timers survive a snapshot round-trip', () => { test('P3.6: fire reloaded mid-burn still goes out on schedule', () => { const run = freshCombatRun(0xf1e6, 'razor'); const tile = freeCombatTile(run); - run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + run.world!.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); const { run: restored } = restore(snapshot(run)); - assert.equal(restored.world.grid.tileAt(tile.x, tile.y), TILE.HAZARD, 'still alight on reload'); + assert.equal(restored.world!.grid.tileAt(tile.x, tile.y), TILE.HAZARD, 'still alight on reload'); for (let i = 0; i < INCENDIARY_BURN_TURNS; i++) { - restored.world.tickTileEffects(); + restored.world!.tickTileEffects(); } assert.equal( - restored.world.grid.tileAt(tile.x, tile.y), + restored.world!.grid.tileAt(tile.x, tile.y), TILE.FLOOR, 'reloaded fire must still burn out — not become a permanent scar' ); @@ -1072,17 +1116,17 @@ test('P3.6: fire reloaded mid-burn still goes out on schedule', () => { test('P3.6: smoke saved mid-cloud does NOT become a permanent LOS wall on reload', () => { const run = freshCombatRun(0xf1e9, 'razor'); const tile = freeCombatTile(run); - placeSmoke(run.world, tile.x, tile.y, 0); - assert.equal(run.world.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud is on the grid'); + placeSmoke(run.world!, tile.x, tile.y, 0); + assert.equal(run.world!.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud is on the grid'); // Autosave fires at the player→corp hand-off — i.e. right here, with smoke up. const { run: restored } = restore(snapshot(run)); - assert.equal(restored.world.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud survives reload'); + assert.equal(restored.world!.grid.tileAt(tile.x, tile.y), TILE.SMOKE, 'cloud survives reload'); - restored.world.tickTileEffects(); + restored.world!.tickTileEffects(); assert.equal( - restored.world.grid.tileAt(tile.x, tile.y), + restored.world!.grid.tileAt(tile.x, tile.y), TILE.FLOOR, 'reloaded smoke must still clear — it used to sit there blocking LOS forever' ); @@ -1092,14 +1136,14 @@ test('P3.6: smoke over the EXIT tile restores the EXIT, not FLOOR', () => { const run = freshCombatRun(0xf1ea, 'razor'); const exit = run.exitTile; assert.ok(exit, 'fixture has an exit tile'); - assert.equal(run.world.grid.tileAt(exit.x, exit.y), TILE.EXIT); + assert.equal(run.world!.grid.tileAt(exit.x, exit.y), TILE.EXIT); - placeSmoke(run.world, exit.x, exit.y, 0); + placeSmoke(run.world!, exit.x, exit.y, 0); const { run: restored } = restore(snapshot(run)); - restored.world.tickTileEffects(); + restored.world!.tickTileEffects(); assert.equal( - restored.world.grid.tileAt(exit.x, exit.y), + restored.world!.grid.tileAt(exit.x, exit.y), TILE.EXIT, 'a cloud over the exit must not quietly delete the exit' ); @@ -1108,18 +1152,18 @@ test('P3.6: smoke over the EXIT tile restores the EXIT, not FLOOR', () => { test('P3.6: a pre-3.6 save with no tileEffects field restores as permanent fire', () => { const run = freshCombatRun(0xf1e7, 'razor'); const tile = freeCombatTile(run); - run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + run.world!.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); const rec = snapshot(run); delete (rec as Record).tileEffects; // as an old save would be const { run: restored } = restore(rec); for (let i = 0; i < INCENDIARY_BURN_TURNS + 3; i++) { - restored.world.tickTileEffects(); + restored.world!.tickTileEffects(); } assert.equal( - restored.world.grid.tileAt(tile.x, tile.y), + restored.world!.grid.tileAt(tile.x, tile.y), TILE.HAZARD, 'an old save keeps the behaviour it was played under' ); @@ -1128,7 +1172,7 @@ test('P3.6: a pre-3.6 save with no tileEffects field restores as permanent fire' test('P3.6: restore rejects a malformed tileEffects entry rather than guessing', () => { const run = freshCombatRun(0xf1e8, 'razor'); const tile = freeCombatTile(run); - run.world.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); + run.world!.applyTileEffect(tile.x, tile.y, TILE.HAZARD, INCENDIARY_BURN_TURNS); const rec = snapshot(run); const withEffect = (patch: Record) => { diff --git a/tests/unit/game/procgen/bsp.test.ts b/tests/unit/game/procgen/bsp.test.ts index cdc3e25..fd382e7 100644 --- a/tests/unit/game/procgen/bsp.test.ts +++ b/tests/unit/game/procgen/bsp.test.ts @@ -9,7 +9,7 @@ import { BSP_TUNABLES, } from '../../../../src/game/procgen/bsp.js'; -const fullRegion = (width, height) => ({ x: 0, y: 0, width, height }); +const fullRegion = (width: number, height: number) => ({ x: 0, y: 0, width, height }); test('splitRegion is deterministic for the same seed', () => { const a = splitRegion(new Rng(42), fullRegion(24, 16)); @@ -75,6 +75,7 @@ test('non-integer region throws (data-corruption guard)', () => { }); test('rng-less call throws TypeError', () => { + // @ts-expect-error Verify runtime validation of a missing RNG. assert.throws(() => splitRegion(null, fullRegion(20, 16)), TypeError); }); diff --git a/tests/unit/game/procgen/mapBuild.test.ts b/tests/unit/game/procgen/mapBuild.test.ts index ded41bb..0b9f77e 100644 --- a/tests/unit/game/procgen/mapBuild.test.ts +++ b/tests/unit/game/procgen/mapBuild.test.ts @@ -475,6 +475,7 @@ test('non-integer dimensions throw', () => { }); test('rng-less call throws TypeError', () => { + // @ts-expect-error Verify runtime validation of a missing RNG. assert.throws(() => buildMap({ rng: null, width: W, height: H, threatCount: 1 }), TypeError); }); @@ -611,6 +612,7 @@ test('unknown contract difficulty throws', () => { width: 24, height: 16, threatCount: 1, + // @ts-expect-error Verify runtime validation of an unknown difficulty. difficulty: 'black-ice', }), /unknown difficulty/ diff --git a/tests/unit/game/procgen/prefabs.test.ts b/tests/unit/game/procgen/prefabs.test.ts index 81503a5..a0120b3 100644 --- a/tests/unit/game/procgen/prefabs.test.ts +++ b/tests/unit/game/procgen/prefabs.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { TILE } from '../../../../src/game/constants.js'; import { PREFABS, parsePrefab } from '../../../../src/game/procgen/prefabs/index.js'; -const KNOWN_TILES = new Set(Object.values(TILE)); +const KNOWN_TILES = new Set(Object.values(TILE)); test('every registered prefab parses with consistent dimensions', () => { for (const [name, prefab] of Object.entries(PREFABS)) { diff --git a/tests/unit/game/recon.test.ts b/tests/unit/game/recon.test.ts index 0005397..04c1544 100644 --- a/tests/unit/game/recon.test.ts +++ b/tests/unit/game/recon.test.ts @@ -32,6 +32,8 @@ import type { Contract } from '../../../src/game/hub/Curator.js'; function makeReconContract(overrides: Partial = {}): Contract { return { seed: 211, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.RECON, title: 'Map site layout', @@ -198,7 +200,7 @@ describe('recon runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 211, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeReconContract()); run.enterCombat(); @@ -234,7 +236,7 @@ describe('recon runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 211, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeReconContract()); run.enterCombat(); diff --git a/tests/unit/game/retrieve.test.ts b/tests/unit/game/retrieve.test.ts index 5fba266..3d6911b 100644 --- a/tests/unit/game/retrieve.test.ts +++ b/tests/unit/game/retrieve.test.ts @@ -47,6 +47,8 @@ function makeCrew(archetype = 'razor') { function makeRetrieveContract(overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.RETRIEVE, title: 'Secure cache', @@ -69,10 +71,10 @@ function relocateAdjacentTo(run: Run, entity: Pickup): void { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player, x, y); return; } } @@ -81,7 +83,7 @@ function relocateAdjacentTo(run: Run, entity: Pickup): void { function pickupsIn(run: Run): Pickup[] { if (!run.world) throw new Error('run must be in combat'); - return [...run.world.entities.values()].filter( + return [...run.world!.entities.values()].filter( (entity): entity is Pickup => entity instanceof Pickup ); } @@ -188,7 +190,7 @@ describe('retrieve runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeRetrieveContract()); run.enterCombat(); @@ -198,7 +200,7 @@ describe('retrieve runs', () => { assert.equal(pickup.glyph, PICKUP_GLYPH); assert.ok(run.exitTile, 'retrieve run should have an exit tile'); assert.ok( - Math.max(Math.abs(pickup.x - run.exitTile.x), Math.abs(pickup.y - run.exitTile.y)) > 1, + Math.max(Math.abs(pickup.x - run.exitTile!.x), Math.abs(pickup.y - run.exitTile!.y)) > 1, 'pickup should not spawn adjacent to extraction' ); assert.equal(isObjectiveSatisfied(run.contract!, run.world), false); @@ -207,7 +209,7 @@ describe('retrieve runs', () => { run.bus!.emit('entity:moved', { entity: run.player, from: { x: run.player!.x, y: run.player!.y }, - to: { x: run.exitTile.x, y: run.exitTile.y }, + to: { x: run.exitTile!.x, y: run.exitTile!.y }, }); assert.equal(run.state, RUN_STATE.RESULT, 'abort extraction ends the run'); const abortResult = results[0] as { @@ -227,7 +229,7 @@ describe('retrieve runs', () => { const run = new Run({ crewMember: makeCrew('razor'), seed: 42, - onResult: result => results.push(result), + onResult: (result: unknown) => results.push(result), }); run.enterBriefing(makeRetrieveContract()); run.enterCombat(); diff --git a/tests/unit/game/sweep.test.ts b/tests/unit/game/sweep.test.ts index 45e38a0..a68169e 100644 --- a/tests/unit/game/sweep.test.ts +++ b/tests/unit/game/sweep.test.ts @@ -52,6 +52,8 @@ function makeSweepContract( ): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.SWEEP, title: 'Sweep test', diff --git a/tests/unit/game/turnLimit.test.ts b/tests/unit/game/turnLimit.test.ts index 3ddb795..7f64eb4 100644 --- a/tests/unit/game/turnLimit.test.ts +++ b/tests/unit/game/turnLimit.test.ts @@ -34,6 +34,8 @@ function makeCrew(archetype = 'razor') { function makeTimedTerminalContract(turnLimit = 2, overrides: Partial = {}): Contract { return { seed: 42, + mapWidth: 24, + mapHeight: 16, objective: { kind: OBJECTIVES.TERMINAL_SLICE, title: 'Slice sentinel terminal', @@ -51,7 +53,7 @@ function makeTimedTerminalContract(turnLimit = 2, overrides: Partial = function terminalIn(run: Run): Terminal { if (!run.world) throw new Error('run must be in combat'); - const terminal = [...run.world.entities.values()].find( + const terminal = [...run.world!.entities.values()].find( (entity): entity is Terminal => entity instanceof Terminal ); if (!terminal) throw new Error('expected a terminal'); @@ -65,10 +67,10 @@ function relocateAdjacentTo(run: Run, entity: Terminal): void { if (dx === 0 && dy === 0) continue; const x = entity.x + dx; const y = entity.y + dy; - if (!run.world.grid.inBounds(x, y)) continue; - if (!run.world.grid.isPassable(x, y)) continue; - if (run.world.liveEntityAt(x, y)) continue; - run.world.relocateEntity(run.player, x, y); + if (!run.world!.grid.inBounds(x, y)) continue; + if (!run.world!.grid.isPassable(x, y)) continue; + if (run.world!.liveEntityAt(x, y)) continue; + run.world!.relocateEntity(run.player, x, y); return; } } @@ -78,8 +80,8 @@ function relocateAdjacentTo(run: Run, entity: Terminal): void { function advanceFullRounds(run: Run, count: number): void { if (!run.world || !run.queue) throw new Error('run must be in combat'); for (let i = 0; i < count; i++) { - run.queue.endTurn(run.world); - run.queue.endTurn(run.world); + run.queue!.endTurn(run.world); + run.queue!.endTurn(run.world); } } diff --git a/tests/unit/input/KeyboardController.test.ts b/tests/unit/input/KeyboardController.test.ts index 3d7613e..5c14cc7 100644 --- a/tests/unit/input/KeyboardController.test.ts +++ b/tests/unit/input/KeyboardController.test.ts @@ -3,12 +3,31 @@ import assert from 'node:assert/strict'; import { KeyboardController } from '../../../src/input/KeyboardController.js'; import { MODE } from '../../../src/input/keymap.js'; +import type { Intent } from '../../../src/input/applyIntent.js'; +import type { Mode } from '../../../src/input/keymap.js'; + +type TestKeyEvent = Pick< + KeyboardEvent, + 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'preventDefault' +> & { + prevented: boolean; +}; +type TestTarget = { + addEventListener(type: 'keydown', fn: (evt: KeyboardEvent) => void): void; + removeEventListener(type: 'keydown', fn: (evt: KeyboardEvent) => void): void; + keydown( + key: string, + mods?: Partial> + ): TestKeyEvent; +}; /** Bare event-target stub good enough for `addEventListener` / `dispatchEvent`. */ -function makeTarget() { - const listeners = []; +function makeTarget(): TestTarget { + const listeners: ((evt: KeyboardEvent) => void)[] = []; return { - addEventListener: (_type, fn) => listeners.push(fn), + addEventListener: (_type, fn) => { + listeners.push(fn); + }, removeEventListener: (_type, fn) => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); @@ -25,7 +44,7 @@ function makeTarget() { this.prevented = true; }, }; - for (const fn of listeners) fn(evt); + for (const fn of listeners) fn(evt as unknown as KeyboardEvent); return evt; }, }; @@ -33,12 +52,14 @@ function makeTarget() { test('KeyboardController requires an onIntent callback', () => { const target = makeTarget(); + // @ts-expect-error Runtime validation must reject a missing callback. assert.throws(() => new KeyboardController({ target }), /onIntent/); }); test('KeyboardController rejects a non-function isBlocked', () => { const target = makeTarget(); assert.throws( + // @ts-expect-error Runtime validation must reject a non-function predicate. () => new KeyboardController({ target, onIntent: () => {}, isBlocked: 'nope' }), /isBlocked/ ); @@ -46,7 +67,7 @@ test('KeyboardController rejects a non-function isBlocked', () => { test('keydown produces an intent through the default (unblocked) path', () => { const target = makeTarget(); - const intents = []; + const intents: Intent[] = []; const ctrl = new KeyboardController({ target, onIntent: i => intents.push(i), @@ -58,8 +79,8 @@ test('keydown produces an intent through the default (unblocked) path', () => { test('isBlocked() === true short-circuits keydown — no intent, no mode change', () => { const target = makeTarget(); - const intents = []; - const modeChanges = []; + const intents: Intent[] = []; + const modeChanges: Mode[] = []; let blocked = true; const ctrl = new KeyboardController({ target, @@ -84,7 +105,7 @@ test('isBlocked() === true short-circuits keydown — no intent, no mode change' test('modifier-key presses are still ignored independently of isBlocked', () => { const target = makeTarget(); - const intents = []; + const intents: Intent[] = []; const ctrl = new KeyboardController({ target, onIntent: i => intents.push(i), diff --git a/tests/unit/input/applyIntent.test.ts b/tests/unit/input/applyIntent.test.ts index de73646..8d269c4 100644 --- a/tests/unit/input/applyIntent.test.ts +++ b/tests/unit/input/applyIntent.test.ts @@ -33,7 +33,12 @@ import { Door } from '../../../src/game/entities/Door.js'; import { Terminal } from '../../../src/game/entities/Terminal.js'; import { ITEM_ID } from '../../../src/game/items.js'; import { Rng } from '../../../src/rng.js'; -import { applyIntent, pickFireTarget, PLAYER_ACTIONS } from '../../../src/input/applyIntent.js'; +import { + applyIntent, + pickFireTarget, + PLAYER_ACTIONS, + type ApplyIntentContext, +} from '../../../src/input/applyIntent.js'; function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { const grid = new Grid(10, 6); @@ -76,7 +81,7 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); const rng = new Rng(1); - const log = []; + const log: string[] = []; const calls = { advanceTurn: 0, resetInputModes: 0, @@ -87,12 +92,14 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { corpseSalvaged: 0, securedInteract: 0, }; - const ctx = { + const ctx: ApplyIntentContext = { world, player, queue, rng, - log: line => log.push(line), + log: (line: string) => { + log.push(line); + }, advanceTurn: () => { calls.advanceTurn++; queue.endTurn(world); @@ -104,7 +111,7 @@ function buildCtx({ archetype = 'merc', placeDrone = true } = {}) { calls.securedInteract++; if (apExhausted) calls.advanceTurn++; }, - onPlayerAction: actionName => { + onPlayerAction: (actionName: string) => { switch (actionName) { case PLAYER_ACTIONS.REACHED_EXIT: calls.reachedExit++; @@ -209,8 +216,8 @@ test('move onto a lootable corpse auto-salvages (M4.1)', () => { assert.equal(player.x, 2); assert.equal(player.y, 3, 'player stepped onto the corpse tile'); - assert.equal(player.inventory.salvage.scrap, 4, 'scrap transferred on step'); - assert.equal(totalSalvage(player.inventory.salvage), 4, 'total wallet matches pickup'); + assert.equal(player.inventory!.salvage.scrap, 4, 'scrap transferred on step'); + assert.equal(totalSalvage(player.inventory!.salvage), 4, 'total wallet matches pickup'); assert.equal(world.entities.has('corpse'), false, 'corpse removed from world (M4.1)'); assert.ok( log.some(l => l.includes('salvages +4')), @@ -235,7 +242,7 @@ test('move onto a corpse with 1 AP still salvages after the move spends AP', () assert.equal(player.x, 2); assert.equal(player.y, 3, 'move still committed'); - assert.equal(totalSalvage(player.inventory.salvage), 2, 'salvage taken after movement'); + assert.equal(totalSalvage(player.inventory!.salvage), 2, 'salvage taken after movement'); assert.equal(world.entities.has('corpse'), false, 'corpse removed from world'); assert.ok( log.some(l => l.includes('salvages +2')), @@ -341,10 +348,9 @@ test('move onto consumable plus low-AP corpse collects both pickups', () => { assert.ok(log.some(l => l.includes('salvages +2'))); }); -test('move onto exit reaches exit when canExit allows it', () => { +test('move onto exit reaches exit', () => { const { ctx, log, calls, world } = buildCtx({ placeDrone: false }); world.grid.setTile(2, 3, TILE.EXIT); - ctx.canExit = () => true; applyIntent({ type: 'move', dx: 0, dy: 1 }, ctx); @@ -363,8 +369,10 @@ test('special intent routes to Vault on a Merc and lands two tiles away', () => test('deploying a turret emits TURRET_DEPLOYED for the audio/presentation layer', () => { const { ctx, world } = buildCtx({ archetype: 'tech', placeDrone: false }); - const deploys = []; - world.events.on(EVENT.TURRET_DEPLOYED, payload => deploys.push(payload)); + const deploys: Record[] = []; + world.events!.on(EVENT.TURRET_DEPLOYED, payload => + deploys.push(payload as Record) + ); // Tech at (2,2); deploy down into the empty floor at (2,3). applyIntent({ type: 'special', dx: 0, dy: 1 }, ctx); assert.equal(deploys.length, 1, 'presentation hook fires once on a successful deploy'); @@ -461,13 +469,13 @@ test('special intent routes to Deploy on a Tech and places a Turret adjacent', ( const placed = world.entityAt(2, 3); assert.ok(placed instanceof Turret, 'expected a Turret placed south of the Tech'); assert.equal(placed.faction, FACTION.PLAYER); - assert.equal(player.turretReady, false, 'Tech.turretReady consumed on commit'); + assert.equal((player as Tech).turretReady, false, 'Tech.turretReady consumed on commit'); }); test('special intent routes to Slide on a Razor (moves 2 tiles, engages stealth)', () => { const { ctx, player, world } = buildCtx({ archetype: 'razor' }); - const cloaks = []; - world.events.on(EVENT.RAZOR_CLOAKED, payload => cloaks.push(payload)); + const cloaks: Record[] = []; + world.events!.on(EVENT.RAZOR_CLOAKED, payload => cloaks.push(payload as Record)); // Player at (2,2). Special dy=1 wants to land at (2,4) — but (3,2) is cover // so dy=1 (down) avoids it: step (2,3), land (2,4). Both should be FLOOR. applyIntent({ type: 'special', dx: 0, dy: 1 }, ctx); @@ -515,13 +523,13 @@ test('special intent routes to EMP on a Decker and stuns a same-faction ally in corp.bindToBus(bus); const queue = new TurnQueue([FACTION.PLAYER, FACTION.CORP]); - const log = []; + const log: string[] = []; const ctx = { world, player: decker, queue, rng: new Rng(1), - log: line => log.push(line), + log: (line: string) => log.push(line), advanceTurn: () => queue.endTurn(world), resetInputModes: () => {}, onPlayerAction: () => {}, @@ -542,8 +550,10 @@ test('special intent routes to EMP on a Decker and stuns a same-faction ally in test('special intent routes to Surge on a Berserk without entering directional movement', () => { const { ctx, log, player, world } = buildCtx({ archetype: 'berserk', placeDrone: false }); const positionBefore = { x: player.x, y: player.y }; - const surges = []; - world.events.on(EVENT.BERSERK_SURGED, payload => surges.push(payload)); + const surges: Record[] = []; + world.events!.on(EVENT.BERSERK_SURGED, payload => + surges.push(payload as Record) + ); applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); assert.deepEqual({ x: player.x, y: player.y }, positionBefore); assert.equal(player.hasEffect(STATUS_EFFECT.SURGE), true); @@ -581,8 +591,10 @@ test('special intent routes CyberAvatar Override against Probe ICE', () => { onPlayerAction: () => {}, }; - const influenced = []; - world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); + const influenced: Record[] = []; + world.events!.on(EVENT.MIND_INFLUENCED, payload => + influenced.push(payload as Record) + ); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); @@ -637,20 +649,22 @@ test('special intent routes to Influence on an Adept and dominates the aimed hos world.addEntity(adept); world.addEntity(drone); drone.bindToBus(bus); - const log = []; + const log: string[] = []; const ctx = { world, player: adept, queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), rng: { next: () => 0 }, // deterministic success - log: line => log.push(line), + log: (line: string) => log.push(line), advanceTurn: () => {}, resetInputModes: () => {}, onPlayerAction: () => {}, }; const apBefore = adept.ap; - const influenced = []; - world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); + const influenced: Record[] = []; + world.events!.on(EVENT.MIND_INFLUENCED, payload => + influenced.push(payload as Record) + ); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); @@ -670,19 +684,21 @@ test('a failed Influence roll still pulses the target tile — log copy carries world.addEntity(adept); world.addEntity(drone); drone.bindToBus(bus); - const log = []; + const log: string[] = []; const ctx = { world, player: adept, queue: new TurnQueue([FACTION.PLAYER, FACTION.CORP]), rng: { next: () => 0.99 }, // deterministic failure (>= INFLUENCE_SUCCESS_CHANCE) - log: line => log.push(line), + log: (line: string) => log.push(line), advanceTurn: () => {}, resetInputModes: () => {}, onPlayerAction: () => {}, }; - const influenced = []; - world.events.on(EVENT.MIND_INFLUENCED, payload => influenced.push(payload)); + const influenced: Record[] = []; + world.events!.on(EVENT.MIND_INFLUENCED, payload => + influenced.push(payload as Record) + ); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); @@ -750,6 +766,7 @@ test('non-player turn refuses everything except cancel', () => { test('unknown intent type throws (closed enum guard)', () => { const { ctx } = buildCtx(); assert.throws(() => applyIntent({ type: 'teleport' }, ctx), /unknown intent/); + // @ts-expect-error Runtime validation must reject null. assert.throws(() => applyIntent(null, ctx), /unknown intent/); }); @@ -773,6 +790,7 @@ test('interact intent fires the shell-supplied onPlayerAction callback once', () test('interact intent crashes when ctx.onPlayerAction is missing (no silent no-op)', () => { const { ctx } = buildCtx(); + // @ts-expect-error Simulate a miswired runtime context. delete ctx.onPlayerAction; assert.throws(() => applyIntent({ type: 'interact' }, ctx), /onPlayerAction is missing/); }); @@ -785,13 +803,14 @@ test('jack-out intent fires the shell-supplied onPlayerAction callback once', () test('jack-out intent crashes when ctx.onPlayerAction is missing (no silent no-op)', () => { const { ctx } = buildCtx(); + // @ts-expect-error Simulate a miswired runtime context. delete ctx.onPlayerAction; assert.throws(() => applyIntent({ type: 'jack-out' }, ctx), /onPlayerAction is missing/); }); test('use-item intent forwards a validated aim direction to the shell', () => { const { ctx } = buildCtx(); - const aims = []; + const aims: { dx: number; dy: number }[] = []; ctx.onUseItem = aim => aims.push(aim); applyIntent({ type: 'use-item', dx: 1, dy: -1 }, ctx); @@ -810,6 +829,7 @@ test('use-item intent crashes on missing handler or invalid aim', () => { test('melee intent still resolves adjacent strikes (for AI / replay, not player keymap)', () => { const { ctx, log, drone } = buildCtx({ placeDrone: true }); + assert.ok(drone); // Adjacent east of player at (2,2): park drone at (3,2). drone.x = 3; drone.y = 2; @@ -830,8 +850,10 @@ test('vault body-check deals VAULT_DAMAGE and knocks hostile back', () => { const drone = new Skirmisher({ id: 'd1', x: 4, y: 2, maxAp: 3 }); world.addEntity(drone); const hpBefore = drone.hp; - const damaged = []; - world.events.on(EVENT.ENTITY_DAMAGED, payload => damaged.push(payload)); + const damaged: Record[] = []; + world.events!.on(EVENT.ENTITY_DAMAGED, payload => + damaged.push(payload as Record) + ); applyIntent({ type: 'special', dx: 1, dy: 0 }, ctx); assert.equal(player.x, 4, 'Merc lands where the hostile was'); assert.equal(drone.x, 5, 'hostile knocked back 1 tile east'); @@ -913,23 +935,23 @@ test('AP exhaustion triggers auto-end-turn during a move', () => { test('special on a Tech routes to improviseTurret when turretReady is false and salvage is available', () => { const { ctx, world, player } = buildCtx({ archetype: 'tech', placeDrone: false }); player.initInventory(); - player.inventory.salvage = makeSalvage({ scrap: SALVAGE_PER_IMPROVISED_TURRET }); + player.inventory!.salvage = makeSalvage({ scrap: SALVAGE_PER_IMPROVISED_TURRET }); // Deploy the pre-built turret south — (2, 3) is plain floor. - player.deployTurret(world, 0, 1); + (player as Tech).deployTurret(world, 0, 1); player.refreshAp(); // Now special deploy west — (1, 2) is plain floor, not the cover at (3, 2). applyIntent({ type: 'special', dx: -1, dy: 0 }, ctx); const placed = world.entityAt(1, 2); assert.ok(placed instanceof Turret, 'expected an improvised turret placed'); - assert.equal(player.inventory.salvage.scrap, 0, 'scrap deducted for improvised turret'); - assert.equal(totalSalvage(player.inventory.salvage), 0, 'no other typed buckets touched'); + assert.equal(player.inventory!.salvage.scrap, 0, 'scrap deducted for improvised turret'); + assert.equal(totalSalvage(player.inventory!.salvage), 0, 'no other typed buckets touched'); }); test('special on a Tech with no turret and no salvage logs a denial', () => { const { ctx, player, log } = buildCtx({ archetype: 'tech', placeDrone: false }); player.initInventory(); // Default emptySalvage wallet — no scrap, can't improvise. - player.turretReady = false; + (player as Tech).turretReady = false; applyIntent({ type: 'special', dx: 0, dy: 1 }, ctx); assert.ok( log.some(l => l.includes('DEPLOY DENIED')), @@ -942,18 +964,18 @@ test('special on a Tech with no turret and no salvage logs a denial', () => { test('special intent routes to Nanite Repair on a Chimera without entering directional movement', () => { const { ctx, log, player, world } = buildCtx({ archetype: 'chimera', placeDrone: false }); player.initInventory(); - player.inventory.salvage = makeSalvage({ scrap: SALVAGE_PER_NANITE_HEAL }); + player.inventory!.salvage = makeSalvage({ scrap: SALVAGE_PER_NANITE_HEAL }); player.damage(1); const positionBefore = { x: player.x, y: player.y }; const apBefore = player.ap; - const scrapBefore = player.inventory.salvage.scrap; + const scrapBefore = player.inventory!.salvage.scrap; const hpBefore = player.hp; - const heals = []; - world.events.on(EVENT.NANITE_HEALED, payload => heals.push(payload)); + const heals: Record[] = []; + world.events!.on(EVENT.NANITE_HEALED, payload => heals.push(payload as Record)); applyIntent({ type: 'special', dx: 0, dy: 0 }, ctx); assert.deepEqual({ x: player.x, y: player.y }, positionBefore, 'self-targeted, no movement'); assert.equal(player.ap, apBefore - AP_COST.NANITE_HEAL); - assert.equal(player.inventory.salvage.scrap, scrapBefore - SALVAGE_PER_NANITE_HEAL); + assert.equal(player.inventory!.salvage.scrap, scrapBefore - SALVAGE_PER_NANITE_HEAL); assert.equal(player.hp, hpBefore + NANITE_HEAL_AMOUNT); assert.ok(log.some(line => line.includes('scrap into tissue'))); // Presentation hook fires for the shell's nanite-heal pulse. diff --git a/tests/unit/input/keymap.test.ts b/tests/unit/input/keymap.test.ts index 822ed10..6e1f489 100644 --- a/tests/unit/input/keymap.test.ts +++ b/tests/unit/input/keymap.test.ts @@ -13,7 +13,7 @@ test('IDLE + arrow keys produce move intents in the right direction', () => { ['ArrowDown', 0, 1], ['ArrowLeft', -1, 0], ['ArrowRight', 1, 0], - ]; + ] as const; for (const [key, dx, dy] of cases) { const r = dispatch(key, MODE.IDLE); assert.deepEqual(r.intent, { type: 'move', dx, dy }, `${key} should emit move(${dx}, ${dy})`); @@ -28,7 +28,7 @@ test('IDLE + diagonal keys (q/e/z/c) produce move intents', () => { ['e', 1, -1], ['z', -1, 1], ['c', 1, 1], - ]; + ] as const; for (const [key, dx, dy] of cases) { const r = dispatch(key, MODE.IDLE); assert.deepEqual(r.intent, { type: 'move', dx, dy }); diff --git a/tests/unit/input/touchpad.test.ts b/tests/unit/input/touchpad.test.ts index a1d1480..74bfa70 100644 --- a/tests/unit/input/touchpad.test.ts +++ b/tests/unit/input/touchpad.test.ts @@ -75,6 +75,7 @@ test('AIM (fire) + quit-campaign touch button stays in AIM', () => { test('syntheticKeyFor throws on an unknown button (crash > silent fallback)', () => { assert.throws(() => syntheticKeyFor('jump'), /unknown button/i); assert.throws(() => syntheticKeyFor(''), /unknown button/i); + // @ts-expect-error Verify runtime validation of a non-string button. assert.throws(() => syntheticKeyFor(null), /unknown button/i); }); @@ -88,7 +89,7 @@ test('IDLE + direction button emits a move intent in that direction', () => { ['NE', 1, -1], ['SW', -1, 1], ['SE', 1, 1], - ]; + ] as const; for (const [btn, dx, dy] of cases) { const r = dispatchTouchAction(btn, MODE.IDLE); assert.deepEqual(r.intent, { type: 'move', dx, dy }, `${btn} should move (${dx}, ${dy})`); diff --git a/tests/unit/render/AsciiRenderer.test.ts b/tests/unit/render/AsciiRenderer.test.ts index 42c8c26..53cc451 100644 --- a/tests/unit/render/AsciiRenderer.test.ts +++ b/tests/unit/render/AsciiRenderer.test.ts @@ -7,14 +7,30 @@ import { World } from '../../../src/game/World.js'; import { Entity } from '../../../src/game/Entity.js'; import { FACTION } from '../../../src/game/constants.js'; +type DrawCall = { + op: 'rect' | 'text'; + x?: number; + y?: number; + w?: number; + h?: number; + char?: string; + px?: number; + py?: number; + fillStyle: string; + shadowColor?: string; + font?: string; + textAlign?: string; +}; +type TestCanvas = HTMLCanvasElement & { _drawCalls: DrawCall[] }; + /** * Minimal canvas + 2D-context stub. Records every text draw so tests can * inspect (a) how many full frames have been drawn and (b) which flash * overlays got painted on top. We don't need pixel fidelity — only the * sequence of calls. */ -function makeCanvas() { - const drawCalls = []; +function makeCanvas(): TestCanvas { + const drawCalls: DrawCall[] = []; const ctx = { fillStyle: '', shadowBlur: 0, @@ -22,10 +38,10 @@ function makeCanvas() { font: '', textAlign: '', textBaseline: '', - fillRect(x, y, w, h) { + fillRect(x: number, y: number, w: number, h: number) { drawCalls.push({ op: 'rect', x, y, w, h, fillStyle: ctx.fillStyle }); }, - fillText(char, px, py) { + fillText(char: string, px: number, py: number) { drawCalls.push({ op: 'text', char, @@ -37,7 +53,7 @@ function makeCanvas() { textAlign: ctx.textAlign, }); }, - measureText: text => ({ width: String(text).length * 7 }), + measureText: (text: string) => ({ width: String(text).length * 7 }), save: () => {}, restore: () => {}, }; @@ -46,7 +62,7 @@ function makeCanvas() { height: 400, getContext: () => ctx, _drawCalls: drawCalls, - }; + } as unknown as TestCanvas; } /** A tiny world: 32×20 grid with a player at (16, 10) so the camera centers. */ @@ -61,6 +77,7 @@ function makeWorld() { test('flashCell rejects non-integer coords', () => { const r = new AsciiRenderer(makeCanvas(), { now: () => 0 }); assert.throws(() => r.flashCell(1.5, 0), /integers/); + // @ts-expect-error Runtime validation must reject a non-numeric coordinate. assert.throws(() => r.flashCell(0, 'x'), /integers/); }); @@ -95,6 +112,7 @@ test('draw() paints registered flashes on top of the regular frame', () => { assert.equal(textOps.length, baselineTextOps + 1, 'one extra text op for the flash overlay'); // The last text op should be the flash overlay (drawn after the frame). const flashOp = textOps.at(-1); + assert.ok(flashOp); assert.equal(flashOp.char, '*'); assert.equal(flashOp.fillStyle, '#abcdef'); }); @@ -126,6 +144,7 @@ test('draw() paints the location chip (uppercased) on top when a label is given' r.draw(world, player, { locationLabel: 'Vuong Holdings server farm' }); const textOps = canvas._drawCalls.filter(c => c.op === 'text'); const chip = textOps.at(-1); + assert.ok(chip); assert.equal(chip.char, 'VUONG HOLDINGS SERVER FARM', 'chip painted last, uppercased'); assert.equal(chip.px, 6, 'chip sits in the top-left padding'); }); @@ -138,7 +157,7 @@ test('draw() omits the location chip when no label is supplied', () => { r.draw(world, player); const chip = canvas._drawCalls .filter(c => c.op === 'text') - .find(c => c.char === c.char?.toUpperCase?.() && c.char.length > 1); + .find(c => c.char === c.char?.toUpperCase?.() && String(c.char).length > 1); assert.equal(chip, undefined, 'no multi-char label text op without a locationLabel'); }); @@ -212,6 +231,7 @@ test('draw() preserves recon progress tags when the objective title is long', () r.draw(world, player, { combatHud: { + cyber: false, objective: { title: 'Map the full district water board facility layout', done: true, @@ -238,6 +258,7 @@ test('draw() paints structured combat HUD rows in the planned canvas corners', ( r.draw(world, player, { locationLabel: 'Vuong Holdings server farm', combatHud: { + cyber: false, objective: { title: 'Sentinel window', done: false, turnsRemaining: 4 }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: true }, hp: { hp: 2, maxHp: 3 }, @@ -276,6 +297,7 @@ test('draw() paints combat HUD HP and AP glyphs with per-state colors', () => { r.draw(world, player, { combatHud: { + cyber: false, objective: { title: 'Sentinel window', done: false }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: false }, hp: { hp: 1, maxHp: 3 }, diff --git a/tests/unit/render/animations.test.ts b/tests/unit/render/animations.test.ts index 4509cd7..d61eda2 100644 --- a/tests/unit/render/animations.test.ts +++ b/tests/unit/render/animations.test.ts @@ -28,6 +28,10 @@ import { STUNNED_FG, SURGE_FLASH_FG, } from '../../../src/render/palette.js'; +import type { AsciiRenderer } from '../../../src/render/AsciiRenderer.js'; + +type FlashRenderer = Pick; +type FlashCall = [string, ...unknown[]]; /** * Minimal DOM-element stub — enough surface for restartCssAnimation to @@ -36,20 +40,20 @@ import { * tests can assert state without inspecting the internals. */ function makeElement() { - const classes = new Set(); - const properties = new Map(); + const classes = new Set(); + const properties = new Map(); let offsetWidthReads = 0; return { classList: { - add: cls => classes.add(cls), - remove: cls => classes.delete(cls), - contains: cls => classes.has(cls), + add: (cls: string) => classes.add(cls), + remove: (cls: string) => classes.delete(cls), + contains: (cls: string) => classes.has(cls), toString: () => Array.from(classes).join(' '), }, style: { - setProperty: (name, value) => properties.set(name, value), - removeProperty: name => properties.delete(name), - getPropertyValue: name => properties.get(name) ?? '', + setProperty: (name: string, value: string) => properties.set(name, value), + removeProperty: (name: string) => properties.delete(name), + getPropertyValue: (name: string) => properties.get(name) ?? '', }, get offsetWidth() { offsetWidthReads += 1; @@ -63,20 +67,19 @@ function makeElement() { /** Fake timer pair: deterministic `now()` and a manually-pumped queue. */ function makeTimers() { let nowMs = 0; - /** @type {{at: number, fn: () => void}[]} */ - const queue = []; + const queue: { at: number; fn: () => void }[] = []; return { now: () => nowMs, - setTimeout: (fn, ms) => { + setTimeout: (fn: () => void, ms: number) => { queue.push({ at: nowMs + ms, fn }); return queue.length; }, - advance(ms) { + advance(ms: number) { nowMs += ms; // Stable sort by time; pop everything due. queue.sort((a, b) => a.at - b.at); while (queue.length && queue[0].at <= nowMs) { - const due = queue.shift(); + const due = queue.shift()!; due.fn(); } }, @@ -126,7 +129,9 @@ test('restartCssAnimation retriggers cleanly on a back-to-back call', () => { test('restartCssAnimation tolerates a null target', () => { const timers = makeTimers(); + // @ts-expect-error Runtime validation deliberately accepts and rejects a null target. assert.equal(restartCssAnimation(null, 'flash', 50, timers), false); + // @ts-expect-error Runtime validation deliberately accepts and rejects malformed targets. assert.equal(restartCssAnimation({}, 'flash', 50, timers), false, 'no classList → false'); }); @@ -284,8 +289,8 @@ test('createAnimationLock: rejects non-finite durations', () => { test('runMuzzleFlash: paints the cell and schedules the repaint', () => { const timers = makeTimers(); - const calls = []; - const renderer = { + const calls: FlashCall[] = []; + const renderer: FlashRenderer = { flashCell: (wx, wy, opts) => { calls.push(['flash', wx, wy, opts]); return true; @@ -307,10 +312,10 @@ test('runMuzzleFlash: paints the cell and schedules the repaint', () => { test('runMuzzleFlash: custom duration overrides default and is forwarded to flashCell', () => { const timers = makeTimers(); - const calls = []; - const renderer = { + const calls: FlashCall[] = []; + const renderer: FlashRenderer = { flashCell: (wx, wy, opts) => { - calls.push(['flash', opts.duration]); + calls.push(['flash', opts!.duration]); return true; }, }; @@ -334,14 +339,16 @@ test('runMuzzleFlash: when the renderer cannot paint, returns false and does not }); test('runMuzzleFlash: rejects malformed arguments', () => { + // @ts-expect-error Runtime guard must reject a renderer without flashCell. assert.throws(() => runMuzzleFlash({}, () => {}, 0, 0), /flashCell/); + // @ts-expect-error Runtime guard must reject a non-function repaint callback. assert.throws(() => runMuzzleFlash({ flashCell: () => true }, null, 0, 0), /repaint/); }); test('runInteractSecuredFlash: paints the prop glyph in white and schedules repaint', () => { const timers = makeTimers(); - const calls = []; - const renderer = { + const calls: FlashCall[] = []; + const renderer: FlashRenderer = { flashCell: (wx, wy, opts) => { calls.push(['flash', wx, wy, opts]); return true; @@ -371,8 +378,8 @@ test('runInteractSecuredFlash: paints the prop glyph in white and schedules repa test('runIncendiaryImpactFlash: bursts on the impact tile and schedules repaint', () => { const timers = makeTimers(); - const calls = []; - const renderer = { + const calls: FlashCall[] = []; + const renderer: FlashRenderer = { flashCell: (wx, wy, opts) => { calls.push(['flash', wx, wy, opts]); return true; @@ -400,10 +407,10 @@ test('runIncendiaryImpactFlash: does not overpaint with the HAZARD glyph', () => // The fire cluster is stamped onto this same cell in the same frame, so a // burst drawn as `▓` would be invisible — the whole point of the effect is // that the throw reads as a distinct beat before the fire settles. - const calls = []; - const renderer = { + const calls: string[] = []; + const renderer: FlashRenderer = { flashCell: (_wx, _wy, opts) => { - calls.push(opts.char); + calls.push(opts!.char!); return true; }, }; @@ -415,8 +422,8 @@ test('runBurnFlash: tints the burning body’s own glyph rather than overpaintin // Which body is burning is the information the player needs when several are // alight at once — a generic fire glyph would erase exactly that. const timers = makeTimers(); - const calls = []; - const renderer = { + const calls: FlashCall[] = []; + const renderer: FlashRenderer = { flashCell: (wx, wy, opts) => { calls.push(['flash', wx, wy, opts]); return true; diff --git a/tests/unit/render/combatHud.test.ts b/tests/unit/render/combatHud.test.ts index b66da03..8d83ed6 100644 --- a/tests/unit/render/combatHud.test.ts +++ b/tests/unit/render/combatHud.test.ts @@ -178,6 +178,7 @@ test('formatTurnLabel distinguishes player and hostile phases', () => { test('formatCombatHudA11ySummary preserves moved HUD facts in readable text', () => { assert.equal( formatCombatHudA11ySummary({ + cyber: false, objective: { title: 'Sentinel window', done: false, turnsRemaining: 4 }, identity: { callsign: 'Patch', archetype: 'tech', stealthed: false }, hp: { hp: 2, maxHp: 3 }, diff --git a/tests/unit/render/frame.test.ts b/tests/unit/render/frame.test.ts index cc1ff19..0023939 100644 --- a/tests/unit/render/frame.test.ts +++ b/tests/unit/render/frame.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { buildFrame, cameraFor } from '../../../src/render/frame.js'; +import { buildFrame, cameraFor, type Frame } from '../../../src/render/frame.js'; import { Grid } from '../../../src/game/Grid.js'; import { Entity } from '../../../src/game/Entity.js'; import { World } from '../../../src/game/World.js'; @@ -42,7 +42,7 @@ const fixture = () => { return { world: w, player, drone }; }; -const cellAt = (frame, x, y) => frame.cells[y * frame.width + x]; +const cellAt = (frame: Frame, x: number, y: number) => frame.cells[y * frame.width + x]!; test('buildFrame returns a frame matching the viewport dimensions', () => { const { world } = fixture(); diff --git a/tests/unit/render/palette.test.ts b/tests/unit/render/palette.test.ts index 06e5ca3..1db78d5 100644 --- a/tests/unit/render/palette.test.ts +++ b/tests/unit/render/palette.test.ts @@ -31,6 +31,7 @@ test('glyphForTile maps every defined tile to a glyph', () => { }); test('glyphForTile throws on an unknown tile id (crash over silent fallback)', () => { + // @ts-expect-error Verify runtime validation of an unknown tile id. assert.throws(() => glyphForTile(99), /unknown tile/i); }); @@ -96,6 +97,7 @@ test('glyphForCorpse preserves the rival allegiance hue (dimmed)', () => { }); test('glyphForEntity throws on an unknown faction', () => { + // @ts-expect-error Verify runtime validation of an unknown faction. const ghost = new Entity({ id: 'g', x: 0, y: 0, faction: 'unknown-faction', glyph: '?' }); assert.throws(() => glyphForEntity(ghost), /unknown faction/i); }); @@ -151,6 +153,7 @@ test('glyphForCorpse uses the corpse char even when the entity glyph differs', ( }); test('glyphForCorpse throws on an unknown faction', () => { + // @ts-expect-error Verify runtime validation of an unknown faction. const ghost = new Entity({ id: 'g', x: 0, y: 0, faction: 'mystery', glyph: '?' }); assert.throws(() => glyphForCorpse(ghost), /unknown faction/i); }); diff --git a/tests/unit/render/pip.test.ts b/tests/unit/render/pip.test.ts index de26494..9aea63e 100644 --- a/tests/unit/render/pip.test.ts +++ b/tests/unit/render/pip.test.ts @@ -20,7 +20,8 @@ import { function makeEntity(id: string, x: number, y: number, hp = 5, maxHp = 8) { const grid = new Grid(28, 18); const world = new World(grid); - const ent = new Entity({ id, x, y, faction: FACTION.PLAYER, glyph: '@', maxHp, hp }); + const ent = new Entity({ id, x, y, faction: FACTION.PLAYER, glyph: '@', maxHp }); + ent.hp = hp; world.addEntity(ent); return { world, ent }; } @@ -36,7 +37,6 @@ function viewingCyber() { faction: FACTION.PLAYER, glyph: 'R', maxHp: 6, - hp: 6, }); meatWorld.addEntity(partner); return { diff --git a/tests/unit/rng.test.ts b/tests/unit/rng.test.ts index f42d8a3..ba5deb1 100644 --- a/tests/unit/rng.test.ts +++ b/tests/unit/rng.test.ts @@ -28,6 +28,7 @@ test('mulberry32 always returns a float in [0, 1)', () => { test('Rng requires a finite seed', () => { assert.throws(() => new Rng(NaN), TypeError); + // @ts-expect-error Verify runtime validation of a missing seed. assert.throws(() => new Rng(undefined), TypeError); }); diff --git a/tsconfig.test-build.json b/tsconfig.test-build.json deleted file mode 100644 index e6c61a1..0000000 --- a/tsconfig.test-build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.tests.json", - "compilerOptions": { - "noEmit": false, - "noCheck": true, - "outDir": "./dist", - "sourceMap": true - }, - "include": ["src/**/*", "components/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"] -}