Files
fog/docs/adr/0004-stateless-seeded-encounter-resolver.md
Maurycy 65af268b86 Add Zod dependency and update API interfaces
- Added Zod as a dependency in package.json.
- Updated pnpm-lock.yaml to include Zod.
- Refactored API interfaces: exported new modules for perk, survivor, mission, and encounter.
- Removed obsolete api-interfaces.ts file.
- Enhanced tests for new schemas in api-interfaces.spec.ts, covering various validation scenarios.
2026-05-07 00:46:03 +00:00

4.1 KiB
Executable File

0004 — Encounter resolver as pure library with seeded PRNG

  • Status: Accepted
  • Date: 2026-05-06

Context and problem statement

The mission engine resolves an encounter every 60 seconds for each active mission. The resolver takes survivor stats, perk modifiers, group context, and difficulty, then produces an outcome (success, failure, injury, sacrifice) with associated log text.

Where in the architecture should this logic live, and how should randomness be handled?

The choice has cascading implications: testability, replayability for debugging, the feasibility of automated balance testing, and how easily the logic can be exercised in CI without spinning up the full Nest application.

Decision drivers

  • Testability. The encounter resolver is the most game-critical and balance-sensitive code in the project. It must be exhaustively testable.
  • Determinism. When a viewer reports "my mission desynced at tick 7," we need to reproduce the exact roll that occurred. Without determinism, the bug report is useless.
  • Balance validation. AI-assisted development tends to invent plausible-looking probability numbers that produce degenerate gameplay. We need automated checks that catch this.
  • Pluggability. Future iterations may want to substitute the resolver (different game modes, A/B tests, content versions).
  • Performance. A 60-second tick is forgiving, but the resolver shouldn't allocate database connections or perform I/O.

Considered options

  1. Resolver as a NestJS service. Lives in the API, dependency-injected, can call other services.
  2. Resolver as a static method on a domain class. Pure but tightly coupled to specific entity types.
  3. Resolver as a pure function in a shared library, with Math.random() for randomness. Simple, but non-deterministic.
  4. Resolver as a pure function in a shared library, with a seeded PRNG injected as input. Pure and deterministic.

Decision outcome

Chosen: Pure function in libs/mission-logic, accepting a seed as input and using a seeded PRNG (seedrandom).

Signature:

function resolveEncounter(
  survivor: SurvivorStats,
  perks: PerkModifier[],
  difficulty: Difficulty,
  groupContext: GroupContext | null,
  seed: string
): EncounterResult

The seed is generated at the API layer (via cryptographic randomness), passed to the resolver, and persisted in mission_logs alongside the result. To replay any tick, fetch its seed and modifiers from the database and call the resolver with identical inputs.

Math.random() is forbidden anywhere in libs/mission-logic. This is enforced via lint rule and called out as a hard rule in CLAUDE.md.

Consequences

Positive

  • Property-based testing with fast-check exercises the full input space — empty perks, modifier overflow, negative modifiers, extreme difficulty — in a way that wasn't feasible with stateful or non-pure designs.
  • Monte Carlo balance harness can simulate 10,000 missions across perk loadouts in CI as a snapshot test, catching balance regressions before merge.
  • Replayable debugging. Any reported mission desync can be reproduced exactly by replaying the seed.
  • No database or HTTP dependencies in the resolver means tests run in milliseconds, not seconds.
  • Future SWF (group) logic layers cleanly on top — the group context is just another input to the same pure function.

Negative

  • Seed management discipline required. Every tick must persist its seed, or replay capability is lost. Discoverable, but easy to forget.
  • Slightly more boilerplate at the call site — the API has to generate and pass a seed, where with Math.random() it would be implicit.
  • Math.random() in dependencies can sneak in transitively. The lint rule and code review must guard against this.

Neutral

  • This design also enables deterministic frontend preview/demo modes in the future — the overlay could replay a canned mission with a known seed for documentation or marketing.
  • Pure-library placement means the resolver can be reused if we ever build a CLI tool, a Discord bot variant, or an offline simulator without Nest.