Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions src/game/Combat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 14 additions & 5 deletions src/game/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down Expand Up @@ -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<Pick<LabelableEntity, 'maxAp' | 'hp' | 'maxHp' | 'shieldHp'>>;

/**
* Player-facing label for an entity, in priority order:
Expand All @@ -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})` : ''}`;
Expand Down
6 changes: 5 additions & 1 deletion src/game/Turret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -36,6 +37,7 @@ type TurretAutoFireFireResult = {
type: 'fire';
target: Entity;
result: ReturnType<typeof resolveRanged>;
reason?: never;
};
type TurretAutoFireIdleResult = {
type: 'idle';
Expand All @@ -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<EntityInit, 'faction' | 'glyph' | 'maxAp'> {
/** Accepted for snapshot/factory compatibility; turrets are always player-aligned. */
faction?: FactionId;
range?: number;
attackDamage?: number;
ownerId?: string | null;
Expand Down
5 changes: 3 additions & 2 deletions src/game/Vision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<Entity, 'x' | 'y' | 'faction' | 'glyph'>) {
const k = coordKey(entity.x, entity.y);
this.memorisedCorpses.set(k, {
x: entity.x,
Expand Down Expand Up @@ -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}`);
}
Expand Down
4 changes: 2 additions & 2 deletions src/game/archetypes/Adept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/game/archetypes/Merc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand Down
12 changes: 12 additions & 0 deletions src/game/archetypes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ export type BuildCrewMemberSpawn = {
maxHp?: number;
faction?: FactionId;
};
export function buildCrewMember<K extends keyof typeof BUILDERS>(
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,
Expand Down
4 changes: 2 additions & 2 deletions src/game/corpTurnStatusCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const GENERIC_STATUS_MESSAGES = [
* @returns {number}
*/
export function countVisibleCorpEntities(
entities: Iterable<Entity>,
entities: Iterable<Pick<Entity, 'alive' | 'faction' | 'x' | 'y'>>,
isTileVisible: IsVisibleFn,
hostileFaction: FactionId = FACTION.CORP
): number {
Expand All @@ -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.';
}
Expand Down
4 changes: 2 additions & 2 deletions src/game/cyber/CyberAvatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
4 changes: 3 additions & 1 deletion src/game/empBlast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/game/knockback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions src/game/mindInfluence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/game/nanoRepair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/game/procgen/prefabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type PrefabMetadata = {
id: string;
w?: number;
h?: number;
anchors: PrefabAnchorsSpec;
anchors?: Partial<PrefabAnchorsSpec>;
/** Patrol waypoint lists, assigned to nearest fodder anchor. */
patrolPaths?: PrefabAnchor[][];
};
Expand Down
2 changes: 1 addition & 1 deletion src/game/slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/game/surge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 8 additions & 3 deletions src/input/KeyboardController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading
Loading