diff --git a/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md b/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md new file mode 100644 index 0000000..e039e13 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md @@ -0,0 +1,3261 @@ +# Playable Slice 0.10 — Abandoned Watchpost Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the Abandoned Watchpost as a full playable location with its own encounter pool, trade goods and equipment progression, plus the first non-level world gate: an Ash Pit route the player has to discover. + +**Architecture:** Three genuinely new mechanisms — a per-character discovery table gating individual travel connections, two content-driven combat abilities (`guard`, `enrage`) added to the ability set the engine already reads, and a re-runnable PowerShell script that derives per-monster web assets from the committed art. Everything else is seed content that flows through systems that already exist. + +**Tech Stack:** NestJS 11, TypeORM, PostgreSQL, Jest (API); Angular 20 standalone components, Karma/Jasmine (web); PowerShell + System.Drawing for asset derivation. + +**Spec:** `docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md` + +## Global Constraints + +- All player-facing content is **English** (slice §12, AGENTS §33). +- Server is authoritative: every gate enforced in the API, never only in the UI (AGENTS §5). +- No normal enemy grants Silver, XP or reputation directly (slice §6). Silver reaches the player only through Borin's exchange. +- No `requiredLevel` gating anywhere in this slice (slice §2, §9). +- Combat stays deterministic: fixed cadences, no hidden rolls (AGENTS §10). +- Monster behaviour is content, never an engine branch on a monster key (AGENTS §9). +- Seeds are idempotent — re-running must never duplicate rows (AGENTS §8). +- Do not retune existing Burned Road content. This slice adds; it does not rebalance (AGENTS §39). +- API tests: `npm test --workspace=@ashen-realms/api`. Web tests: `npm test --workspace=@ashen-realms/web`. +- Every task ends with a commit. Commit messages follow the repo's Conventional Commits style (`feat(api):`, `test(seed):`, `docs:`). + +### Stable IDs used throughout this plan + +```text +ABANDONED_WATCHPOST_ID 20000000-0000-4000-8000-000000000003 +ASH_PIT_ID 20000000-0000-4000-8000-000000000004 +RAIDER_SCOUT_MONSTER_ID 30000000-0000-4000-8000-000000000005 +BURNED_HOUND_MONSTER_ID 30000000-0000-4000-8000-000000000006 +RAIDER_VETERAN_MONSTER_ID 30000000-0000-4000-8000-000000000007 +RAIDER_CAPTAIN_MONSTER_ID 30000000-0000-4000-8000-000000000008 +scorched-hide item 50000000-0000-4000-8000-000000000010 +raider-warband-mark item 50000000-0000-4000-8000-000000000011 +RAIDER_SCOUT_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000005 +BURNED_HOUND_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000006 +RAIDER_VETERAN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000007 +RAIDER_CAPTAIN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000008 +``` + +--- + +### Task 1: Migration — discovery table, connection gate, combat event types + +**Files:** +- Create: `apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts` +- Test: `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: table `character_location_discoveries`, column `location_connections.requires_discovery`, enum values `GUARD_RAISED` / `GUARD_ENDED` / `ENRAGED` on `combat_event_type_enum`. Class name `CreateAbandonedWatchpost1798000000000`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts`: + +```ts +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { CreateAbandonedWatchpost1798000000000 } from './1798000000000-CreateAbandonedWatchpost'; + +/** + * The migration writes multi-line SQL, so every assertion below reads it with + * runs of whitespace collapsed -- the same harness the other migration specs + * use, so reindenting a statement never breaks a test that still describes + * the right schema. + */ +function collapse(statements: string[]): string { + return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); +} + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new CreateAbandonedWatchpost1798000000000().up(queryRunner); + return collapse(query.mock.calls.map(([sql]) => sql as string)); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateAbandonedWatchpost1798000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); +} + +describe('CreateAbandonedWatchpost1798000000000', () => { + it('creates the discovery table', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TABLE "character_location_discoveries"'); + }); + + it('lets a character discover a location only once', async () => { + const joined = await runUp(); + + // The unique index, not a disabled button, is what makes a repeated + // investigation harmless (AGENTS.md §30). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")', + ); + }); + + it('cascades discoveries away with their character and location', async () => { + const joined = await runUp(); + + expect(joined).toContain( + 'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE', + ); + expect(joined).toContain( + 'FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE', + ); + }); + + it('adds an ungated-by-default discovery flag to connections', async () => { + const joined = await runUp(); + + // Default false: every route that exists today stays walkable. + expect(joined).toContain( + 'ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false', + ); + }); + + it('extends the combat event enum with the two new mechanics', async () => { + const joined = await runUp(); + + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + }); + + it('reverses the table and the column', async () => { + const joined = await runDown(); + + expect(joined).toContain('DROP TABLE "character_location_discoveries"'); + expect(joined).toContain( + 'ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"', + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- create-abandoned-watchpost` +Expected: FAIL — `Cannot find module './1798000000000-CreateAbandonedWatchpost'` + +- [ ] **Step 3: Write the migration** + +Create `apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts`: + +```ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Schema for the Abandoned Watchpost (Playable Slice 0.10 §9, §5). + * + * Two unrelated-looking things in one migration because they arrive with one + * slice: the world gate that hides the Ash Pit route until it is found, and + * the two combat event types the Raider Veteran's new mechanics emit. + * + * `character_location_discoveries` is player state and nothing else -- which + * location is gated at all is content, and lives on the connection + * (AGENTS.md §7). A connection carrying its own gate means a place can be + * reachable by one road and hidden behind another. + */ +export class CreateAbandonedWatchpost1798000000000 + implements MigrationInterface +{ + name = 'CreateAbandonedWatchpost1798000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "character_location_discoveries" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "location_id" uuid NOT NULL, + "discovered_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_location_discoveries" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_location_discoveries_character" + FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE, + CONSTRAINT "FK_character_location_discoveries_location" + FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")`, + ); + + await queryRunner.query( + `ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false`, + ); + + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_character_location_discoveries_pair"`, + ); + await queryRunner.query(`DROP TABLE "character_location_discoveries"`); + await queryRunner.query( + `ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"`, + ); + + // Postgres cannot drop an enum value, so the type is rebuilt -- the same + // tradeoff migration 1790 already makes. Fails if any row uses one of the + // new values, which is the expected shape of a dev rollback. + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`, + ); + await queryRunner.query(`DROP TYPE "combat_event_type_enum"`); + await queryRunner.query( + `CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED', 'COMBAT_WON', 'COMBAT_LOST')`, + ); + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`, + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- create-abandoned-watchpost` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts +git commit -m "feat(api): add schema for watchpost discovery and guard events" +``` + +--- + +### Task 2: Combat engine — the `guard` ability + +**Files:** +- Modify: `apps/api/src/monsters/monster-abilities.ts` +- Modify: `apps/api/src/combat/combat-event-type.enum.ts` +- Modify: `apps/api/src/combat/combat-engine.types.ts` +- Modify: `apps/api/src/combat/combat-engine.service.ts` +- Test: `apps/api/src/combat/combat-engine.service.spec.ts` + +**Interfaces:** +- Consumes: `calculateDamage(attacker, targetArmor, multiplier)` from `combat-damage.ts`. +- Produces: `MonsterGuardAbility { roundInterval, armorBonus, durationRounds }` on `MonsterAbilities.guard`; `CombatEngineCombatantStats.activeGuard?: { remainingRounds: number; armorBonus: number }`; event types `GUARD_RAISED`, `GUARD_ENDED`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/combat/combat-engine.service.spec.ts`, inside the top-level `describe`: + +```ts +const VETERAN_ABILITIES: MonsterAbilities = { + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, +}; + +describe('guard', () => { + it('raises its guard instead of attacking on the interval round', () => { + const state = baseState({ + round: 4, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { attack: 14, armor: 10, abilities: VETERAN_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.player.currentHp).toBe(100); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.GUARD_RAISED, + ); + expect(result.state.monster.stats.activeGuard).toEqual({ + remainingRounds: 2, + armorBonus: 10, + }); + }); + + it('adds the guard bonus to the armor the player has to cut through', () => { + const guarded = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(guarded, { + action: CombatAction.ATTACK, + }); + + // 14 raw damage against armor 20 instead of 10: 14*60/80 = 10.5 -> 11. + const damage = result.events.find( + (event) => event.type === CombatEventType.DAMAGE, + ); + expect(damage?.amount).toBe(11); + }); + + it('drops the guard when its rounds run out', () => { + const state = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 1, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.GUARD_ENDED, + ); + }); + + it('lets Shield Bash break an active guard', () => { + const state = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.SHIELD_BASH, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + const types = result.events.map((event) => event.type); + expect(types).toContain(CombatEventType.INTERRUPT); + expect(types).toContain(CombatEventType.GUARD_ENDED); + }); + + it('lets a telegraph win when both are due in the same round', () => { + const collidingAbilities: MonsterAbilities = { + telegraph: { roundInterval: 2, damageMultiplier: 1.6 }, + guard: { roundInterval: 2, armorBonus: 10, durationRounds: 2 }, + }; + const state = baseState({ + round: 2, + monster: withAbilities(collidingAbilities, { + currentHp: 120, + maxHp: 120, + stats: { attack: 14, armor: 10, abilities: collidingAbilities }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + }); + + it('leaves a monster without the ability exactly as it was', () => { + const state = baseState({ round: 4 }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.GUARD_RAISED, + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: FAIL — `Property 'guard' does not exist on type 'MonsterAbilities'` and `GUARD_RAISED` missing from `CombatEventType` + +- [ ] **Step 3: Add the ability type** + +In `apps/api/src/monsters/monster-abilities.ts`, add before `MonsterAbilities`: + +```ts +export interface MonsterGuardAbility { + /** + * The monster forgoes its attack on every round divisible by this and + * covers instead, raising its armor for `durationRounds`. SHIELD_BASH + * breaks it, the same answer the telegraph already taught (Playable Slice + * 0.10 §5). + */ + roundInterval: number; + armorBonus: number; + durationRounds: number; +} +``` + +and extend the interface: + +```ts +export interface MonsterAbilities { + telegraph?: MonsterTelegraphAbility; + bleed?: MonsterBleedAbility; + guard?: MonsterGuardAbility; +} +``` + +- [ ] **Step 4: Add the event types** + +In `apps/api/src/combat/combat-event-type.enum.ts`, add to the enum after `INTERRUPT`: + +```ts + GUARD_RAISED = 'GUARD_RAISED', + GUARD_ENDED = 'GUARD_ENDED', +``` + +- [ ] **Step 5: Add the engine state field** + +In `apps/api/src/combat/combat-engine.types.ts`, add to `CombatEngineCombatantStats` after `pendingAction`: + +```ts + // Monster-only: set when it covers, cleared when the rounds run out or a + // Shield Bash breaks it. Its bonus is added to the monster's armor while + // it lasts (Playable Slice 0.10 §7). + activeGuard?: { remainingRounds: number; armorBonus: number }; +``` + +- [ ] **Step 6: Implement the engine behaviour** + +In `apps/api/src/combat/combat-engine.service.ts`: + +Add the import: + +```ts +import type { + MonsterBleedAbility, + MonsterGuardAbility, + MonsterTelegraphAbility, +} from '../monsters/monster-abilities'; +``` + +Add a helper that reads the monster's effective armor, and use it in both places +the player's blow is calculated (`resolvePlayerStrike` and `resolveShieldBash`), +replacing `monster.stats.armor`: + +```ts + /** Armor the monster actually presents this round, guard included. */ + private effectiveArmor(combatant: CombatEngineCombatant): number { + return combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0); + } +``` + +```ts + const damage = calculateDamage( + player.stats, + this.effectiveArmor(monster), + multiplier, + ); +``` + +In `resolveShieldBash`, extend the interrupt block so it also breaks a guard: + +```ts + let interrupted = false; + if (monster.stats.pendingAction) { + monster.stats.pendingAction = undefined; + interrupted = true; + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }); + } + + // A guard is a prepared stance like a wind-up, and the bash answers both. + // Only one INTERRUPT is emitted even when the bash breaks both at once -- + // the player made one interruption, not two. + if (monster.stats.activeGuard) { + monster.stats.activeGuard = undefined; + if (!interrupted) { + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }); + } + interrupted = true; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_ENDED, + }); + } +``` + +In `resolveMonsterTurn`, insert the guard branch after the telegraph branch and +before the normal strike: + +```ts + if (this.shouldTrigger(abilities.guard, round)) { + const guard = abilities.guard as MonsterGuardAbility; + monster.stats.activeGuard = { + remainingRounds: guard.durationRounds, + armorBonus: guard.armorBonus, + }; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_RAISED, + amount: guard.durationRounds, + }); + return; + } +``` + +Widen the `shouldTrigger` signature to accept the new ability: + +```ts + private shouldTrigger( + ability: + | MonsterTelegraphAbility + | MonsterBleedAbility + | MonsterGuardAbility + | undefined, + round: number, + ): boolean { +``` + +Age the guard **before** the monster acts. In `finishRound`, immediately before +the `if (!interrupted)` block, add: + +```ts + this.ageGuard(monster, events); +``` + +Order matters here. The player's blow for this round was already calculated +against the guard by the time `finishRound` runs, and the guard is raised inside +`resolveMonsterTurn` — which comes after this line. So a guard raised on round N +is first aged on round N+1, and `durationRounds: 2` means it turns aside the +player's next two attacks. Ageing after `resolveMonsterTurn` instead would eat a +round off the guard the moment it went up. + +Placing it before the `if (!interrupted)` guard also means the counter runs down +even on a round the monster was interrupted out of, so a Shield Bash on a +telegraph never accidentally extends an unrelated guard. + +Add the method: + +```ts + /** + * Counts one round off an active guard, and drops it when it runs out. + * + * Called before the monster acts, so the guard it raises this round is not + * immediately aged: `durationRounds: 2` turns aside the player's next two + * attacks. + */ + private ageGuard( + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + ): void { + const guard = monster.stats.activeGuard; + if (!guard) { + return; + } + + const remainingRounds = guard.remainingRounds - 1; + if (remainingRounds > 0) { + monster.stats.activeGuard = { ...guard, remainingRounds }; + return; + } + + monster.stats.activeGuard = undefined; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_ENDED, + }); + } +``` + +Finally, clear the guard on death alongside `pendingAction` in `finishRound`: + +```ts + const defeatedMonster = { + ...monster, + stats: { + ...monster.stats, + pendingAction: undefined, + activeGuard: undefined, + }, + }; +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: PASS — all previously existing tests plus the six new `guard` tests + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts +git commit -m "feat(api): let monsters raise a breakable defensive guard" +``` + +--- + +### Task 3: Combat engine — the `enrage` ability + +**Files:** +- Modify: `apps/api/src/monsters/monster-abilities.ts` +- Modify: `apps/api/src/combat/combat-event-type.enum.ts` +- Modify: `apps/api/src/combat/combat-engine.types.ts` +- Modify: `apps/api/src/combat/combat-engine.service.ts` +- Test: `apps/api/src/combat/combat-engine.service.spec.ts` + +**Interfaces:** +- Consumes: `MonsterAbilities` from Task 2. +- Produces: `MonsterEnrageAbility { hpThresholdPercent, damageMultiplier }` on `MonsterAbilities.enrage`; `CombatEngineCombatantStats.enraged?: boolean`; event type `ENRAGED`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/combat/combat-engine.service.spec.ts`: + +```ts +const HOUND_ABILITIES: MonsterAbilities = { + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, +}; + +describe('enrage', () => { + it('turns aggressive once its HP crosses the threshold', () => { + // 80 max HP, 40 left; a 14-damage blow lands it on 26, below 35%. + const state = baseState({ + round: 1, + player: { + currentHp: 100, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 0 }, + }, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 40, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBe(true); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.ENRAGED, + ); + // The same round's reply already hits harder: 12 * 1.4 = 16.8 -> 17. + const monsterHit = result.events.find( + (event) => + event.type === CombatEventType.DAMAGE && + event.source === Combatant.MONSTER, + ); + expect(monsterHit?.amount).toBe(17); + }); + + it('stays quiet above the threshold', () => { + const state = baseState({ + round: 1, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 80, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBeUndefined(); + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + }); + + it('announces the change only once', () => { + const state = baseState({ + round: 2, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 20, + maxHp: 80, + stats: { + attack: 12, + armor: 0, + abilities: HOUND_ABILITIES, + enraged: true, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.DEFEND, + }); + + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + expect(result.state.monster.stats.enraged).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: FAIL — `Property 'enrage' does not exist on type 'MonsterAbilities'` + +- [ ] **Step 3: Add the ability type** + +In `apps/api/src/monsters/monster-abilities.ts`: + +```ts +export interface MonsterEnrageAbility { + /** + * Once its HP first falls to or below this share of maximum, the monster + * hits harder for the rest of the fight. One deterministic state change, + * never reversed and never rolled (Playable Slice 0.10 §5). + */ + hpThresholdPercent: number; + damageMultiplier: number; +} +``` + +and add `enrage?: MonsterEnrageAbility;` to `MonsterAbilities`. + +- [ ] **Step 4: Add the event type and state field** + +In `apps/api/src/combat/combat-event-type.enum.ts` add `ENRAGED = 'ENRAGED',` after `GUARD_ENDED`. + +In `apps/api/src/combat/combat-engine.types.ts` add to `CombatEngineCombatantStats`: + +```ts + // Monster-only: latched the first time its HP crosses the enrage threshold. + enraged?: boolean; +``` + +- [ ] **Step 5: Implement the engine behaviour** + +In `apps/api/src/combat/combat-engine.service.ts`, add the check at the top of +`resolveMonsterTurn`, before the pending-action branch — the monster notices its +wounds before it decides what to do: + +```ts + this.checkEnrage(monster, events); +``` + +Add the method: + +```ts + /** + * Latches the enraged state the first time the monster's HP crosses its + * threshold. Checked before it acts, so the blow that wounded it is already + * answered in kind. + */ + private checkEnrage( + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + ): void { + const enrage = monster.stats.abilities?.enrage; + if (!enrage || monster.stats.enraged) { + return; + } + + const threshold = (monster.maxHp * enrage.hpThresholdPercent) / 100; + if (monster.currentHp > threshold) { + return; + } + + monster.stats.enraged = true; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.ENRAGED, + }); + } +``` + +Apply the multiplier where the monster strikes. In `strikePlayer`, fold it into +the multiplier passed in: + +```ts + private strikePlayer( + monster: CombatEngineCombatant, + player: CombatEngineCombatant, + multiplier: number, + events: CombatEngineEvent[], + ): void { + const enrageMultiplier = monster.stats.enraged + ? (monster.stats.abilities?.enrage?.damageMultiplier ?? 1) + : 1; + const damage = calculateDamage( + monster.stats, + player.stats.armor, + multiplier * enrageMultiplier, + ); + player.currentHp = Math.max(0, player.currentHp - damage); + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts +git commit -m "feat(api): let wounded monsters turn aggressive" +``` + +--- + +### Task 4: Expose guard and enrage through the combat API + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Test: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CombatEngineCombatantStats.activeGuard` / `.enraged` from Tasks 2 and 3. +- Produces: `CombatMonsterDto.guardRemainingRounds: number | null` and `CombatMonsterDto.enraged: boolean`. + +Note: `combat.monsterState` is assigned straight from `result.state.monster.stats`, +so both fields already persist. Only the DTO needs work. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/api/src/combat/combat.service.spec.ts`, in the describe block that +covers the combat DTO: + +```ts + it('reports an active guard and an enraged monster to the client', async () => { + const { service, combat } = await startCombatFixture(); + combat.monsterState = { + ...combat.monsterState, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + enraged: true, + }; + + const dto = await service.getCombat(CHARACTER_ID, combat.id); + + expect(dto.monster.guardRemainingRounds).toBe(2); + expect(dto.monster.enraged).toBe(true); + }); + + it('reports no guard when the monster is not covering', async () => { + const { service, combat } = await startCombatFixture(); + + const dto = await service.getCombat(CHARACTER_ID, combat.id); + + expect(dto.monster.guardRemainingRounds).toBeNull(); + expect(dto.monster.enraged).toBe(false); + }); +``` + +If the existing spec has no `startCombatFixture` helper, use whatever fixture +factory the file already provides to obtain a started combat and its `Combat` +row; the two assertions are the point, not the helper's name. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat.service` +Expected: FAIL — `Property 'guardRemainingRounds' does not exist on type 'CombatMonsterDto'` + +- [ ] **Step 3: Extend the DTO** + +In `apps/api/src/combat/combat.service.ts`, add to `CombatMonsterDto`: + +```ts + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; +``` + +and in `toCombatDto`'s `monster` block, after `pendingIntent`: + +```ts + guardRemainingRounds: + combat.monsterState.activeGuard?.remainingRounds ?? null, + enraged: combat.monsterState.enraged ?? false, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(api): report monster guard and enrage state in the combat DTO" +``` + +--- + +### Task 5: `WorldDiscoveryService` and its entity + +**Files:** +- Create: `apps/api/src/world/discovery/character-location-discovery.entity.ts` +- Create: `apps/api/src/world/discovery/world-discovery.service.ts` +- Create: `apps/api/src/world/discovery/world-discovery.module.ts` +- Test: `apps/api/src/world/discovery/world-discovery.service.spec.ts` +- Modify: `apps/api/src/world/entities/location-connection.entity.ts` + +**Interfaces:** +- Consumes: `LocationConnection`, `LocationDefinition`. +- Produces: + - `LocationConnection.requiresDiscovery: boolean` + - `class CharacterLocationDiscovery { id, characterId, locationId, discoveredAt }` + - `WorldDiscoveryService.getDiscoveredLocationIds(characterId): Promise>` + - `WorldDiscoveryService.discover(characterId, locationKey, manager?): Promise` where `DiscoveredLocation = { key: string; name: string }` + - `WorldDiscoveryService.isTravelAllowed(characterId, connection, manager?): Promise` + - `WorldDiscoveryModule` exporting the service + +Its own module, not a provider inside `WorldModule`: `WorldModule` already +imports `TravelModule`, and `TravelService` needs this service too. A shared +leaf module is what keeps that from becoming a cycle. + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/world/discovery/world-discovery.service.spec.ts`: + +```ts +import { DataSource, Repository } from 'typeorm'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { WorldDiscoveryService } from './world-discovery.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const WATCHPOST_ID = '20000000-0000-4000-8000-000000000003'; +const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; + +interface InsertCall { + values: Record; + orIgnore: boolean; +} + +function buildService(options: { + discoveries?: Array<{ locationId: string }>; + locations?: Array>; + insertCalls?: InsertCall[]; +}): WorldDiscoveryService { + const discoveries = options.discoveries ?? []; + const locations = options.locations ?? []; + const insertCalls = options.insertCalls ?? []; + + const discoveryRepository = { + find: jest.fn().mockResolvedValue(discoveries), + createQueryBuilder: jest.fn(() => { + const builder = { + insert: () => builder, + into: () => builder, + values: (values: Record) => { + insertCalls.push({ values, orIgnore: false }); + return builder; + }, + orIgnore: () => { + insertCalls[insertCalls.length - 1].orIgnore = true; + return builder; + }, + execute: jest + .fn() + .mockResolvedValue({ identifiers: [{ id: 'new-row' }] }), + }; + return builder; + }), + } as unknown as Repository; + + const locationRepository = { + findOneBy: jest.fn(({ key }: { key: string }) => + Promise.resolve(locations.find((location) => location.key === key) ?? null), + ), + } as unknown as Repository; + + const dataSource = { + getRepository: (target: unknown) => + target === CharacterLocationDiscovery + ? discoveryRepository + : locationRepository, + } as unknown as DataSource; + + return new WorldDiscoveryService(dataSource); +} + +describe('WorldDiscoveryService', () => { + it('returns the ids the character has already discovered', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + }); + + const discovered = await service.getDiscoveredLocationIds(CHARACTER_ID); + + expect(discovered.has(ASH_PIT_ID)).toBe(true); + expect(discovered.has(WATCHPOST_ID)).toBe(false); + }); + + it('allows travel down a route that carries no gate', async () => { + const service = buildService({}); + const connection = { + toLocationId: WATCHPOST_ID, + requiresDiscovery: false, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('refuses a gated route the character has not discovered', async () => { + const service = buildService({}); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(false); + }); + + it('allows a gated route once it has been discovered', async () => { + const service = buildService({ discoveries: [{ locationId: ASH_PIT_ID }] }); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('returns the location the first time it is discovered', async () => { + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('returns null when the location was already known', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toBeNull(); + }); + + it('writes the row so a duplicate is ignored rather than thrown', async () => { + const insertCalls: InsertCall[] = []; + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + insertCalls, + }); + + await service.discover(CHARACTER_ID, 'ash-pit'); + + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0].orIgnore).toBe(true); + expect(insertCalls[0].values).toEqual({ + characterId: CHARACTER_ID, + locationId: ASH_PIT_ID, + }); + }); + + it('ignores an unknown location key', async () => { + const service = buildService({ locations: [] }); + + await expect(service.discover(CHARACTER_ID, 'nowhere')).resolves.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- world-discovery.service` +Expected: FAIL — `Cannot find module './character-location-discovery.entity'` + +- [ ] **Step 3: Create the entity** + +Create `apps/api/src/world/discovery/character-location-discovery.entity.ts`: + +```ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; + +/** + * A place this character knows about (Playable Slice 0.10 §9). + * + * Player state, not content: which routes are gated at all lives on the + * connection. A row here is written once and never updated, so the unique + * pair is the whole concurrency story (AGENTS.md §30). + */ +@Entity({ name: 'character_location_discoveries' }) +@Index( + 'IDX_character_location_discoveries_pair', + ['characterId', 'locationId'], + { unique: true }, +) +export class CharacterLocationDiscovery { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'location_id', type: 'uuid' }) + locationId!: string; + + @CreateDateColumn({ name: 'discovered_at', type: 'timestamptz' }) + discoveredAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => LocationDefinition, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'location_id' }) + location!: LocationDefinition; +} +``` + +- [ ] **Step 4: Add the connection column** + +In `apps/api/src/world/entities/location-connection.entity.ts`, after `enabled`: + +```ts + /** + * When true this route only exists for a character who has discovered its + * target (Playable Slice 0.10 §9). Default false: every route that existed + * before this slice stays open. + */ + @Column({ name: 'requires_discovery', type: 'boolean', default: false }) + requiresDiscovery!: boolean; +``` + +- [ ] **Step 5: Create the service** + +Create `apps/api/src/world/discovery/world-discovery.service.ts`: + +```ts +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; + +export interface DiscoveredLocation { + key: string; + name: string; +} + +/** + * Which places a character knows about, and whether a gated route is open to + * them yet (Playable Slice 0.10 §9). + * + * One service rather than a check inlined in `WorldService` and + * `TravelService`: the map must hide exactly what travel refuses, and two + * copies of that rule would drift the moment Slice 0.11 adds a second gate. + */ +@Injectable() +export class WorldDiscoveryService { + constructor(private readonly dataSource: DataSource) {} + + async getDiscoveredLocationIds( + characterId: string, + manager?: EntityManager, + ): Promise> { + const repository = this.discoveries(manager); + const rows = await repository.find({ + where: { characterId }, + select: { locationId: true }, + }); + return new Set(rows.map((row) => row.locationId)); + } + + /** + * Records that the character now knows this place. + * + * Returns the location the first time and `null` afterwards, so a caller can + * tell a fresh reveal from a repeated click without a second query. The + * insert ignores a conflict rather than throwing: the same interaction run + * twice is a normal thing for a player to do (AGENTS.md §30). + */ + async discover( + characterId: string, + locationKey: string, + manager?: EntityManager, + ): Promise { + const locations = manager + ? manager.getRepository(LocationDefinition) + : this.dataSource.getRepository(LocationDefinition); + const location = await locations.findOneBy({ key: locationKey }); + if (!location) { + return null; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + if (known.has(location.id)) { + return null; + } + + await this.discoveries(manager) + .createQueryBuilder() + .insert() + .into(CharacterLocationDiscovery) + .values({ characterId, locationId: location.id }) + .orIgnore() + .execute(); + + return { key: location.key, name: location.name }; + } + + async isTravelAllowed( + characterId: string, + connection: Pick, + manager?: EntityManager, + ): Promise { + if (!connection.requiresDiscovery) { + return true; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + return known.has(connection.toLocationId); + } + + private discoveries(manager?: EntityManager) { + return manager + ? manager.getRepository(CharacterLocationDiscovery) + : this.dataSource.getRepository(CharacterLocationDiscovery); + } +} +``` + +- [ ] **Step 6: Create the module** + +Create `apps/api/src/world/discovery/world-discovery.module.ts`: + +```ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; +import { WorldDiscoveryService } from './world-discovery.service'; + +/** + * A leaf module on purpose. `WorldModule` already imports `TravelModule`, and + * both need this service; giving it its own module is what keeps that from + * becoming a circular import. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([CharacterLocationDiscovery, LocationDefinition]), + ], + providers: [WorldDiscoveryService], + exports: [WorldDiscoveryService], +}) +export class WorldDiscoveryModule {} +``` + +- [ ] **Step 7: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- world-discovery.service` +Expected: PASS (8 tests) + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/world/discovery apps/api/src/world/entities/location-connection.entity.ts +git commit -m "feat(api): track which locations a character has discovered" +``` + +--- + +### Task 6: Enforce the gate in travel + +**Files:** +- Modify: `apps/api/src/travel/travel.service.ts` +- Modify: `apps/api/src/travel/travel.module.ts` +- Test: `apps/api/src/travel/travel.service.spec.ts` + +**Interfaces:** +- Consumes: `WorldDiscoveryService.isTravelAllowed` from Task 5. +- Produces: `TravelService` constructor gains a third parameter `worldDiscovery: WorldDiscoveryService`. Every existing test that constructs it directly must be updated. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/api/src/travel/travel.service.spec.ts`: + +```ts + it('refuses a gated route the character has not discovered', async () => { + const { service, state } = buildService(); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).rejects.toBeInstanceOf(TravelDomainError); + expect(state.travels).toHaveLength(0); + }); + + it('allows a gated route once it has been discovered', async () => { + const { service, state } = buildService({ discovered: [BURNED_ROAD_ID] }); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).resolves.toMatchObject({ status: TravelStatus.TRAVELLING }); + }); +``` + +Extend the file's existing `buildService` helper to accept +`{ discovered?: string[] }` and pass a stub as the new third constructor +argument: + +```ts + const worldDiscovery = { + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ), + } as unknown as WorldDiscoveryService; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- travel.service` +Expected: FAIL — the gated route still starts a travel + +- [ ] **Step 3: Inject the service and enforce the rule** + +In `apps/api/src/travel/travel.service.ts`, add the import and the constructor +parameter: + +```ts +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; +``` + +```ts + constructor( + private readonly dataSource: DataSource, + @Inject(CLOCK) private readonly clock: Clock, + private readonly worldDiscovery: WorldDiscoveryService, + ) {} +``` + +In `startTravel`, immediately after the existing `if (!connection) { throw +invalidTravelTarget(); }` block: + +```ts + // The map already hides an undiscovered route, but the map is not what + // decides. A gated target is refused here too, inside the same + // transaction that locks the character (AGENTS.md §5). + const allowed = await this.worldDiscovery.isTravelAllowed( + characterId, + connection, + manager, + ); + if (!allowed) { + throw invalidTravelTarget(); + } +``` + +- [ ] **Step 4: Register the module** + +In `apps/api/src/travel/travel.module.ts`, add `WorldDiscoveryModule` to +`imports`: + +```ts +import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module'; +``` + +```ts + imports: [ + TypeOrmModule.forFeature([ + Character, + LocationDefinition, + LocationConnection, + Travel, + ]), + WorldDiscoveryModule, + ], +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- travel` +Expected: PASS — including every pre-existing travel test + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/travel/travel.service.ts apps/api/src/travel/travel.module.ts apps/api/src/travel/travel.service.spec.ts +git commit -m "feat(api): refuse travel down an undiscovered route" +``` + +--- + +### Task 7: Hide the route on the map and discover it from a hotspot + +**Files:** +- Modify: `apps/api/src/world/local-location.types.ts` +- Modify: `apps/api/src/world/world.service.ts` +- Modify: `apps/api/src/world/world.module.ts` +- Test: `apps/api/src/world/world.service.spec.ts` +- Test: `apps/api/src/world/local-location-interaction.spec.ts` + +**Interfaces:** +- Consumes: `WorldDiscoveryService` from Task 5. +- Produces: + - `LocationPointOfInterestContent.discoversLocationKey?: string` + - `LocationInteractionResultDto.discoveredLocation: DiscoveredLocation | null` + - `WorldService` constructor gains `worldDiscovery: WorldDiscoveryService` as its final parameter. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/world/world.service.spec.ts`: + +```ts + it('hides a gated connection until the character has discovered it', async () => { + const { service } = buildService({ + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(0); + }); + + it('shows a gated connection once it has been discovered', async () => { + const { service } = buildService({ + discovered: [ASH_PIT_ID], + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(1); + expect(location.connections[0].targetLocation.key).toBe('ash-pit'); + }); +``` + +Add to `apps/api/src/world/local-location-interaction.spec.ts`: + +```ts + it('discovers the route the hotspot points at', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + actionLabel: 'Inspect', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultTitle: 'The Watchpost', + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(discover).toHaveBeenCalledWith(CHARACTER_ID, 'ash-pit'); + expect(result.discoveredLocation).toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('reports no discovery the second time the hotspot is used', async () => { + const { service } = buildService({ + alreadyDiscovered: true, + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(result.discoveredLocation).toBeNull(); + expect(result.text).toBe('Fresh tracks lead east.'); + }); + + it('reports no discovery for a hotspot that reveals nothing', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'search-quarters', + title: 'Guard Quarters', + type: 'SEARCH', + iconKey: 'search', + xPercent: 20, + yPercent: 60, + enabled: true, + resultText: 'Nothing but ash.', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'search-quarters', + ); + + expect(discover).not.toHaveBeenCalled(); + expect(result.discoveredLocation).toBeNull(); + }); +``` + +In both spec files, extend the existing service factory to build and pass a +`WorldDiscoveryService` stub as the final constructor argument: + +```ts + const discover = jest.fn().mockResolvedValue( + options.alreadyDiscovered ? null : { key: 'ash-pit', name: 'Ash Pit' }, + ); + const worldDiscovery = { + discover, + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ), + } as unknown as WorldDiscoveryService; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- world` +Expected: FAIL — `Object literal may only specify known properties, and 'discoversLocationKey' does not exist` + +- [ ] **Step 3: Extend the content and transport types** + +In `apps/api/src/world/local-location.types.ts`, add to +`LocationPointOfInterestContent`: + +```ts + /** + * Names a location this hotspot reveals (Playable Slice 0.10 §9). Setting it + * turns a read-only reveal into a piece of world progress, which is why the + * interaction endpoint writes as well as reads. + */ + discoversLocationKey?: string; +``` + +and to `LocationInteractionResultDto`: + +```ts + /** + * Set only on the interaction that reveals a route for the first time, so + * the UI can say so once instead of on every repeat. + */ + discoveredLocation: { key: string; name: string } | null; +``` + +`toPointOfInterestDto` needs no change: `discoversLocationKey` is server-only, +exactly like `resultText`. + +- [ ] **Step 4: Wire the service** + +In `apps/api/src/world/world.service.ts`: + +```ts +import { WorldDiscoveryService } from './discovery/world-discovery.service'; +``` + +Add the constructor parameter after `locationMonsters`: + +```ts + private readonly worldDiscovery: WorldDiscoveryService, +``` + +Replace the `connections` mapping in `getCurrentLocation` so the gate is applied +before the map ever sees the route. Load the connections, then: + +```ts + const visibleConnections: LocationConnection[] = []; + for (const connection of connections) { + if (await this.worldDiscovery.isTravelAllowed(characterId, connection)) { + visibleConnections.push(connection); + } + } +``` + +and map `visibleConnections` instead of `connections.filter(...)` in the +response. + +In `runLocalInteraction`, after the `poi` guard and before the return: + +```ts + // A hotspot that reveals a route writes before it speaks. Idempotent by + // the unique pair, so a second click simply reports nothing new. + const discoveredLocation = poi.discoversLocationKey + ? await this.worldDiscovery.discover( + characterId, + poi.discoversLocationKey, + ) + : null; +``` + +and add `discoveredLocation,` to the returned object. + +- [ ] **Step 5: Register the module** + +In `apps/api/src/world/world.module.ts` add `WorldDiscoveryModule` to `imports`: + +```ts +import { WorldDiscoveryModule } from './discovery/world-discovery.module'; +``` + +```ts + TravelModule, + WorldDiscoveryModule, +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- world` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/world/local-location.types.ts apps/api/src/world/world.service.ts apps/api/src/world/world.module.ts apps/api/src/world/world.service.spec.ts apps/api/src/world/local-location-interaction.spec.ts +git commit -m "feat(api): reveal the ash pit route from the watchpost hotspot" +``` + +--- + +### Task 8: Trade goods, loot tables and exchange rules + +**Files:** +- Modify: `apps/api/src/database/seeds/item.constants.ts` +- Modify: `apps/api/src/database/seeds/item-content.ts` +- Modify: `apps/api/src/database/seeds/npc-content.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: `item()` and `entry()` helpers in `item-content.ts`, `SeedExchangeRule` in `npc-content.ts`. +- Produces: item keys `scorched-hide`, `raider-warband-mark`; loot-table constants `RAIDER_SCOUT_LOOT_TABLE_ID`, `BURNED_HOUND_LOOT_TABLE_ID`, `RAIDER_VETERAN_LOOT_TABLE_ID`, `RAIDER_CAPTAIN_LOOT_TABLE_ID`; four new `LOOT_TABLES` rows and their entries; two new `EXCHANGE_RULES`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`: + +```ts + it('gives each new trade good the carrying category it belongs to', async () => { + const { repositories } = await runSeed(); + const items = repositories.get(ItemDefinition)!.rows; + + const hide = items.find((row) => row.key === 'scorched-hide'); + const mark = items.find((row) => row.key === 'raider-warband-mark'); + + expect(hide?.lootCategory).toBe('HIDE'); + expect(mark?.lootCategory).toBe('RAIDER_TROPHY'); + }); + + it('lets Borin buy both watchpost trade goods', async () => { + const { repositories } = await runSeed(); + const rules = repositories.get(ExchangeRule)!.rows; + + const hideRule = rules.find( + (row) => row.inputItemId === ITEM_IDS['scorched-hide'], + ); + const markRule = rules.find( + (row) => row.inputItemId === ITEM_IDS['raider-warband-mark'], + ); + + expect(hideRule).toMatchObject({ silverReward: 12, regionReputationReward: 4 }); + expect(markRule).toMatchObject({ silverReward: 20, regionReputationReward: 7 }); + }); + + it('pays more for watchpost goods than for road goods', async () => { + const { repositories } = await runSeed(); + const rules = repositories.get(ExchangeRule)!.rows; + const silverFor = (key: keyof typeof ITEM_IDS) => + rules.find((row) => row.inputItemId === ITEM_IDS[key])?.silverReward; + + // The longer trip has to pay, or §10's loop has no pull (slice §6). + expect(silverFor('scorched-hide')).toBeGreaterThan(silverFor('tough-hide')!); + expect(silverFor('raider-warband-mark')).toBeGreaterThan( + silverFor('bandit-insignia')!, + ); + }); + + it('guarantees the captain trophy and offers its focused drop', async () => { + const { repositories } = await runSeed(); + const entries = repositories + .get(LootTableEntry)! + .rows.filter((row) => row.lootTableId === RAIDER_CAPTAIN_LOOT_TABLE_ID); + + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['raider-warband-mark']) + ?.dropChance, + ).toBe('1.0000'); + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['borderwatch-sigil']) + ?.dropChance, + ).toBe('0.2000'); + }); + + it('leaves the road bandit loot table untouched', async () => { + const { repositories } = await runSeed(); + const entries = repositories + .get(LootTableEntry)! + .rows.filter((row) => row.lootTableId === ROAD_BANDIT_LOOT_TABLE_ID); + + // This slice adds a pool entry for the bandit, not a rebalance + // (AGENTS.md §39). + expect(entries).toHaveLength(4); + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['bandit-blade']) + ?.dropChance, + ).toBe('0.1800'); + }); +``` + +Use whatever `runSeed()` / repository-lookup helper the file already defines; +the assertions are the point. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `'scorched-hide' is not assignable to ItemKey` + +- [ ] **Step 3: Add the item and loot-table ids** + +In `apps/api/src/database/seeds/item.constants.ts`, add to `ITEM_IDS` after +`'charred-raider-insignia'`: + +```ts + 'scorched-hide': '50000000-0000-4000-8000-000000000010', + 'raider-warband-mark': '50000000-0000-4000-8000-000000000011', +``` + +and append the four table ids: + +```ts +// Playable Slice 0.10 §6: one table per Watchpost enemy, same rule as the +// Burned Road -- each enemy owns its guaranteed trade good. +export const RAIDER_SCOUT_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000005'; +export const BURNED_HOUND_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000006'; +export const RAIDER_VETERAN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000007'; +export const RAIDER_CAPTAIN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000008'; +``` + +- [ ] **Step 4: Add the trade goods** + +In `apps/api/src/database/seeds/item-content.ts`, import the four new table ids +and append to `ITEM_DEFINITIONS`: + +```ts + // Watchpost trade goods (Playable Slice 0.10 §6). One per carrying + // category, so both the Hide Bag and the Trophy Pouch matter at this + // location and a player who owns neither runs into the capacity of 1. + item( + 'scorched-hide', + 'Scorched Hide', + 'Hound hide burned hard as bark, the cracks in it still warm.', + ItemType.TRADE_GOOD, + null, + ItemRarity.COMMON, + {}, + LootCategory.HIDE, + ), + item( + 'raider-warband-mark', + 'Raider Warband Mark', + 'A watchpost tally-token, re-stamped with the mark of the band that took it.', + ItemType.TROPHY, + null, + ItemRarity.COMMON, + {}, + LootCategory.RAIDER_TROPHY, + ), +``` + +- [ ] **Step 5: Add the loot tables** + +Append to `LOOT_TABLES` in the same file: + +```ts + { + id: RAIDER_SCOUT_LOOT_TABLE_ID, + key: 'raider-scout-loot', + name: 'Raider Scout Loot', + }, + { + id: BURNED_HOUND_LOOT_TABLE_ID, + key: 'burned-hound-loot', + name: 'Burned Hound Loot', + }, + { + id: RAIDER_VETERAN_LOOT_TABLE_ID, + key: 'raider-veteran-loot', + name: 'Raider Veteran Loot', + }, + { + id: RAIDER_CAPTAIN_LOOT_TABLE_ID, + key: 'raider-captain-loot', + name: 'Raider Captain Loot', + }, +``` + +and to `LOOT_TABLE_ENTRIES`: + +```ts + // Watchpost (Playable Slice 0.10 §7). The Tier-1 pieces already exist in + // content; what this location changes is how often they show up. The + // Charred Captain's Pendant stays out -- it belongs to the Slice 0.11 boss, + // not to this elite. + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.6000'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-blade', 2, '0.2500'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-hood', 3, '0.1800'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'scorched-hide', 1, '0.6000'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'ash-boots', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.7000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-gloves', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 3, '0.2000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'guardsman-legs', 4, '0.2200'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '1.0000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'guardsman-legs', 3, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.2000'), +``` + +- [ ] **Step 6: Add the exchange rules** + +Append to `EXCHANGE_RULES` in `apps/api/src/database/seeds/npc-content.ts`: + +```ts + // Watchpost goods (Playable Slice 0.10 §10). Priced above the road tier so + // the longer trip pays, and below the rare Charred Raider Insignia so the + // rare drop stays the best thing in the region. + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['scorched-hide'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 12, + regionReputationReward: 4, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 5, + enabled: true, + }, + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['raider-warband-mark'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 20, + regionReputationReward: 7, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 6, + enabled: true, + }, +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/database/seeds/item.constants.ts apps/api/src/database/seeds/item-content.ts apps/api/src/database/seeds/npc-content.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts +git commit -m "feat(seed): add the watchpost trade goods and their loot tables" +``` + +--- + +### Task 9: Seed the locations, monsters and encounter pool + +**Files:** +- Modify: `apps/api/src/database/seeds/vertical-slice.constants.ts` +- Modify: `apps/api/src/database/seeds/local-location.content.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: loot-table constants from Task 8, `MonsterAbilities` from Tasks 2–3, `LocalLocationContent` from `local-location.content.ts`. +- Produces: constants `ABANDONED_WATCHPOST_ID`, `ASH_PIT_ID`, `RAIDER_SCOUT_MONSTER_ID`, `BURNED_HOUND_MONSTER_ID`, `RAIDER_VETERAN_MONSTER_ID`, `RAIDER_CAPTAIN_MONSTER_ID`; exports `ABANDONED_WATCHPOST_LOCAL_CONTENT`, `ASH_PIT_LOCAL_CONTENT`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`: + +```ts + it('seeds the watchpost as a huntable outpost', async () => { + const { repositories } = await runSeed(); + const locations = repositories.get(LocationDefinition)!.rows; + + const watchpost = locations.find( + (row) => row.key === 'abandoned-watchpost', + ); + + expect(watchpost).toMatchObject({ + locationType: 'OUTPOST', + huntingEnabled: true, + isSafe: false, + regionKey: 'ashen-fields', + }); + }); + + it('connects the burned road and the watchpost both ways without a gate', async () => { + const { repositories } = await runSeed(); + const connections = repositories.get(LocationConnection)!.rows; + + const outbound = connections.find( + (row) => + row.fromLocationId === BURNED_ROAD_ID && + row.toLocationId === ABANDONED_WATCHPOST_ID, + ); + const inbound = connections.find( + (row) => + row.fromLocationId === ABANDONED_WATCHPOST_ID && + row.toLocationId === BURNED_ROAD_ID, + ); + + expect(outbound).toMatchObject({ + travelDurationSeconds: 15, + ambushChance: '0.1000', + requiresDiscovery: false, + }); + expect(inbound).toMatchObject({ + travelDurationSeconds: 15, + requiresDiscovery: false, + }); + }); + + it('gates the outbound ash pit route and leaves the way back open', async () => { + const { repositories } = await runSeed(); + const connections = repositories.get(LocationConnection)!.rows; + + const outbound = connections.find( + (row) => + row.fromLocationId === ABANDONED_WATCHPOST_ID && + row.toLocationId === ASH_PIT_ID, + ); + const inbound = connections.find( + (row) => + row.fromLocationId === ASH_PIT_ID && + row.toLocationId === ABANDONED_WATCHPOST_ID, + ); + + expect(outbound?.requiresDiscovery).toBe(true); + // Whoever got there must always be able to leave. + expect(inbound?.requiresDiscovery).toBe(false); + }); + + it('points the watchpost investigation at the ash pit', async () => { + const { repositories } = await runSeed(); + const watchpost = repositories + .get(LocationDefinition)! + .rows.find((row) => row.key === 'abandoned-watchpost'); + + const poi = ( + watchpost?.localPointsOfInterest as Array> + ).find((entry) => entry.key === 'inspect-watchpost'); + + expect(poi?.discoversLocationKey).toBe('ash-pit'); + }); + + it('gives the watchpost its own encounter pool', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ABANDONED_WATCHPOST_ID); + + expect(pool.map((row) => row.monsterId).sort()).toEqual( + [ + RAIDER_SCOUT_MONSTER_ID, + BURNED_HOUND_MONSTER_ID, + ROAD_BANDIT_MONSTER_ID, + RAIDER_VETERAN_MONSTER_ID, + RAIDER_CAPTAIN_MONSTER_ID, + ].sort(), + ); + }); + + it('leaves the burned road pool exactly as it was', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === BURNED_ROAD_ID); + + expect(pool).toHaveLength(4); + }); + + it('marks only the captain as a rare encounter', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ABANDONED_WATCHPOST_ID); + + const rare = pool.filter((row) => row.encounterType === 'RARE'); + expect(rare).toHaveLength(1); + expect(rare[0].monsterId).toBe(RAIDER_CAPTAIN_MONSTER_ID); + }); + + it('arms the veteran with a telegraph and a guard on different cadences', async () => { + const { repositories } = await runSeed(); + const veteran = repositories + .get(MonsterDefinition)! + .rows.find((row) => row.key === 'raider-veteran'); + + expect(veteran?.abilities).toEqual({ + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, + }); + }); + + it('gives the hound bleeding and a low-HP rage', async () => { + const { repositories } = await runSeed(); + const hound = repositories + .get(MonsterDefinition)! + .rows.find((row) => row.key === 'burned-hound'); + + expect(hound?.abilities).toEqual({ + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, + }); + }); + + it('seeds the ash pit as a stub with nothing to hunt yet', async () => { + const { repositories } = await runSeed(); + const ashPit = repositories + .get(LocationDefinition)! + .rows.find((row) => row.key === 'ash-pit'); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ASH_PIT_ID); + + expect(ashPit?.huntingEnabled).toBe(false); + expect(pool).toHaveLength(0); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `ABANDONED_WATCHPOST_ID is not defined` + +- [ ] **Step 3: Add the constants** + +Append to `apps/api/src/database/seeds/vertical-slice.constants.ts`: + +```ts +export const ABANDONED_WATCHPOST_ID = '20000000-0000-4000-8000-000000000003'; +export const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; +export const RAIDER_SCOUT_MONSTER_ID = '30000000-0000-4000-8000-000000000005'; +export const BURNED_HOUND_MONSTER_ID = '30000000-0000-4000-8000-000000000006'; +export const RAIDER_VETERAN_MONSTER_ID = '30000000-0000-4000-8000-000000000007'; +export const RAIDER_CAPTAIN_MONSTER_ID = '30000000-0000-4000-8000-000000000008'; +``` + +- [ ] **Step 4: Author the local content** + +Append to `apps/api/src/database/seeds/local-location.content.ts`: + +```ts +export const ABANDONED_WATCHPOST_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'OUTPOST', + localDescription: + 'A border tower the Watch gave up on. The palisade still stands, the gate does not, and someone has been sleeping here who was never posted here.', + localArtworkPath: '/images/backgrounds/Wachturm.png', + // Anchored to painted detail in `Wachturm.png`: the open ground before the + // tower, the tower base itself, the collapsed lean-to on the left, and the + // track leading off to the right. + localPointsOfInterest: [ + { + key: 'hunt-area', + title: 'Hunting Ground', + actionLabel: 'Begin Hunt', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 62, + yPercent: 38, + enabled: true, + }, + { + key: 'inspect-watchpost', + title: 'The Watchpost', + actionLabel: 'Inspect', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 44, + yPercent: 46, + enabled: true, + resultTitle: 'The Watchpost', + // Quoted from Playable Slice 0.10 §8. + resultText: + "The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation.", + discoversLocationKey: 'ash-pit', + }, + { + key: 'search-guard-quarters', + title: "Guards' Quarters", + actionLabel: 'Search', + type: 'SEARCH', + iconKey: 'search', + xPercent: 18, + yPercent: 68, + enabled: true, + resultTitle: "Guards' Quarters", + resultText: + 'Straw, a cold hearth, and a duty roster with every name scratched out but one. Nothing here is worth carrying.', + }, + { + key: 'east-road', + title: 'Track East', + actionLabel: 'To Map', + type: 'MAP', + iconKey: 'map', + xPercent: 88, + yPercent: 74, + enabled: true, + }, + ], + localPrimaryActions: [ + { + key: 'start-hunt', + label: 'Begin Hunt', + description: 'Hunt in this area', + type: 'HUNT', + iconKey: 'hunt', + enabled: true, + }, + { + key: 'inspect-watchpost', + label: 'Inspect the watchpost', + description: 'Find clues', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-watchpost', + }, + { + key: 'search-quarters', + label: "Search the quarters", + description: 'Find loot', + type: 'SEARCH', + iconKey: 'search', + enabled: true, + poiKey: 'search-guard-quarters', + }, + { + key: 'open-map', + label: 'To Map', + description: 'Change area', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + // Same rule as the Burned Road: only categories the loot tables here + // actually back, and no Silver or experience, because a normal kill grants + // neither (slice §6). + localRewardPreview: [ + { key: 'equipment', label: 'Equipment', iconKey: 'equipment' }, + { key: 'material', label: 'Trade Goods', iconKey: 'material' }, + ], +}; + +/** + * A stub (Playable Slice 0.10 §9, design decision D2). + * + * The discovered route needs a real destination for the gate to mean anything, + * so the Ash Pit exists as a place you can stand -- and nothing more. Slice + * 0.11 gives it an encounter pool, an elite and its own hotspots. + */ +export const ASH_PIT_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'TRANSITION', + localDescription: + 'The old ash excavation drops away in terraces, grey on grey. Something down there is still being worked.', + localArtworkPath: '/images/backgrounds/Aschengrube.png', + localPointsOfInterest: [ + { + key: 'pit-rim', + title: 'Back Along the Track', + actionLabel: 'To Map', + type: 'MAP', + iconKey: 'map', + xPercent: 20, + yPercent: 76, + enabled: true, + }, + ], + localPrimaryActions: [ + { + key: 'open-map', + label: 'To Map', + description: 'Change area', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + localRewardPreview: [], +}; +``` + +- [ ] **Step 5: Seed the locations and connections** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`, extend the imports and +append two entries to the `locations` array: + +```ts + { + id: ABANDONED_WATCHPOST_ID, + key: 'abandoned-watchpost', + name: 'Abandoned Watchpost', + description: + 'A border tower the Watch gave up on. Whoever holds it now is watching the road, not guarding it.', + regionKey: 'ashen-fields', + minRecommendedLevel: 2, + maxRecommendedLevel: 3, + dangerLevel: 2, + isSafe: false, + huntingEnabled: true, + artworkPath: '/images/backgrounds/Wachturm.png', + ...ABANDONED_WATCHPOST_LOCAL_CONTENT, + }, + { + id: ASH_PIT_ID, + key: 'ash-pit', + name: 'Ash Pit', + description: + 'The old ash excavation east of the watchpost, cut in terraces and still being worked by someone.', + regionKey: 'ashen-fields', + minRecommendedLevel: 3, + maxRecommendedLevel: 4, + dangerLevel: 3, + isSafe: false, + huntingEnabled: false, + artworkPath: '/images/backgrounds/Aschengrube.png', + ...ASH_PIT_LOCAL_CONTENT, + }, +``` + +Resolve the ids after the loop, beside the existing two: + +```ts + const watchpostId = + locationIds.get('abandoned-watchpost') ?? ABANDONED_WATCHPOST_ID; + const ashPitId = locationIds.get('ash-pit') ?? ASH_PIT_ID; +``` + +Add four rows to the existing `connectionRepository.upsert` array. Every +pre-existing row needs `requiresDiscovery: false` added explicitly so the upsert +writes the column on a re-seed: + +```ts + { + fromLocationId: burnedRoadId, + toLocationId: watchpostId, + travelDurationSeconds: 15, + ambushChance: '0.1000', + enabled: true, + requiresDiscovery: false, + }, + { + fromLocationId: watchpostId, + toLocationId: burnedRoadId, + travelDurationSeconds: 15, + ambushChance: '0.1000', + enabled: true, + requiresDiscovery: false, + }, + // The one gated route in the game (slice §9). Discovery, not a level, + // is what opens it -- and the way back is never gated, so a character + // who walked in can always walk out. + { + fromLocationId: watchpostId, + toLocationId: ashPitId, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + }, + { + fromLocationId: ashPitId, + toLocationId: watchpostId, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: false, + }, +``` + +- [ ] **Step 6: Seed the monsters** + +Append to the `monsters` array in the same file: + +```ts + { + id: RAIDER_SCOUT_MONSTER_ID, + key: 'raider-scout', + name: 'Raider Scout', + monsterCategory: MonsterCategory.HUMANOID, + level: 2, + maxHp: 70, + attack: 10, + armor: 3, + flavorText: + 'Light on their feet and already backing away from the fight they started.', + // The farming target: no mechanic at all, so the pool has somewhere + // for a player to breathe between the harder fights (slice §5). + abilities: {}, + artworkPath: '/images/monsters/raider-scout.png', + iconPath: '/images/combat/icons/raider-scout-128.png', + lootTableId: RAIDER_SCOUT_LOOT_TABLE_ID, + }, + { + id: BURNED_HOUND_MONSTER_ID, + key: 'burned-hound', + name: 'Burned Hound', + monsterCategory: MonsterCategory.BEAST, + level: 3, + maxHp: 80, + attack: 12, + armor: 2, + flavorText: + 'The fire took its coat and left the cracks glowing underneath.', + // Bleeding on a tighter cadence than the road hound, plus a rage that + // punishes a player who lets the fight run long (slice §5). + abilities: { + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, + }, + artworkPath: '/images/monsters/burned-hound.png', + iconPath: '/images/combat/icons/burned-hound-128.png', + lootTableId: BURNED_HOUND_LOOT_TABLE_ID, + }, + { + id: RAIDER_VETERAN_MONSTER_ID, + key: 'raider-veteran', + name: 'Raider Veteran', + monsterCategory: MonsterCategory.HUMANOID, + level: 3, + maxHp: 120, + attack: 14, + armor: 10, + flavorText: + 'Plated, patient, and entirely willing to wait behind their guard.', + // The fight this location is built around: the telegraph the player + // already knows, plus a guard that answers to the same Shield Bash. + // Intervals 3 and 4 so the two only collide every twelfth round. + abilities: { + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, + }, + artworkPath: '/images/monsters/raider-veteran.png', + iconPath: '/images/combat/icons/raider-veteran-128.png', + lootTableId: RAIDER_VETERAN_LOOT_TABLE_ID, + }, + { + id: RAIDER_CAPTAIN_MONSTER_ID, + key: 'raider-captain', + name: 'Raider Captain', + monsterCategory: MonsterCategory.HUMANOID, + level: 4, + maxHp: 160, + attack: 17, + armor: 12, + flavorText: + 'Whoever gave the order to watch this road is standing in front of you.', + // The elite (slice §5): the veteran's two mechanics on tighter + // cadences and better stats, not a third subsystem. Deliberately not + // the Captain of the Ashen Band -- that boss belongs to Slice 0.11. + abilities: { + telegraph: { roundInterval: 2, damageMultiplier: 1.7 }, + guard: { roundInterval: 3, armorBonus: 12, durationRounds: 2 }, + }, + artworkPath: '/images/monsters/raider-captain.png', + iconPath: '/images/combat/icons/raider-captain-128.png', + lootTableId: RAIDER_CAPTAIN_LOOT_TABLE_ID, + }, +``` + +- [ ] **Step 7: Seed the encounter pool** + +After the existing Burned Road `locationMonsterRepository.upsert`, add: + +```ts + // Watchpost roster (slice §4). The Road Bandit is reused deliberately: it + // bridges the two locations and keeps the Raider Insignia economy + // connected. Its definition and loot table are untouched. + const watchpostPool: ReadonlyArray<{ + key: string; + weight: number; + encounterType: EncounterType; + }> = [ + { key: 'raider-scout', weight: 35, encounterType: EncounterType.NORMAL }, + { key: 'burned-hound', weight: 28, encounterType: EncounterType.NORMAL }, + { key: 'road-bandit', weight: 20, encounterType: EncounterType.NORMAL }, + { key: 'raider-veteran', weight: 14, encounterType: EncounterType.NORMAL }, + { key: 'raider-captain', weight: 3, encounterType: EncounterType.RARE }, + ]; + + await locationMonsterRepository.upsert( + watchpostPool.map(({ key, weight, encounterType }) => ({ + locationId: watchpostId, + monsterId: monsterIds.get(key) as string, + weight, + encounterType, + enabled: true, + })), + ['locationId', 'monsterId'], + ); +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS + +- [ ] **Step 9: Run the whole API suite** + +Run: `npm test --workspace=@ashen-realms/api` +Expected: PASS — no regressions anywhere + +- [ ] **Step 10: Commit** + +```bash +git add apps/api/src/database/seeds/vertical-slice.constants.ts apps/api/src/database/seeds/local-location.content.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts +git commit -m "feat(seed): open the abandoned watchpost and the route beyond it" +``` + +--- + +### Task 10: Derive the web assets for the new content + +**Files:** +- Create: `tools/derive-monster-assets.ps1` +- Create (generated, committed): `apps/web/public/images/monsters/{raider-scout,raider-veteran,burned-hound,raider-captain}.png`, their `runtime/*-560.jpg`, `apps/web/public/images/combat/sprites/*.png`, `apps/web/public/images/combat/icons/*-128.png` +- Create (copied): `apps/web/public/images/backgrounds/{Wachturm.png,Aschengrube.png}` and `backgrounds/runtime/{Wachturm-960.jpg,Aschengrube-960.jpg}` +- Modify: `apps/web/src/app/shared/monster-artwork.ts` +- Test: `apps/web/src/app/shared/monster-artwork.spec.ts` + +**Interfaces:** +- Consumes: art committed in `2435d25`. +- Produces: the four asset paths per monster key, registered in `monster-artwork.ts`. + +Note the deliberate crossing (design D7): the file named `raider-scout.png` +depicts the heavier plated figure and becomes the **Veteran**; `raider-veteran.png` +depicts the leaner one and becomes the **Scout**. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/web/src/app/shared/monster-artwork.spec.ts`: + +```ts +describe('watchpost monsters', () => { + const keys = ['raider-scout', 'raider-veteran', 'burned-hound', 'raider-captain']; + + it('has a cutout for every watchpost monster', () => { + for (const key of keys) { + expect(monsterCutoutPath(key)).toBeDefined(); + } + }); + + it('has an icon for every watchpost monster', () => { + for (const key of keys) { + expect(monsterIconPath(key)).toBeDefined(); + } + }); + + it('has a runtime derivative for every watchpost artwork', () => { + for (const key of keys) { + expect(runtimeMonsterArtworkPath(`/images/monsters/${key}.png`)).toBeDefined(); + } + }); + + it('scales the captain larger than the hound', () => { + // A hulking elite and a low-slung dog must not share a silhouette height. + expect(combatMonsterSpriteScale('raider-captain')).toBeGreaterThan( + combatMonsterSpriteScale('burned-hound'), + ); + }); +}); +``` + +Add `combatMonsterSpriteScale` to the file's imports. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: FAIL — `monsterCutoutPath('raider-scout')` is `undefined` + +- [ ] **Step 3: Write the derivation script** + +Create `tools/derive-monster-assets.ps1`: + +```powershell +# tools/derive-monster-assets.ps1 +# Derives the web assets each monster needs from the hand-made art in +# art/enemies and art/backgrounds. +# +# Per monster it writes four files: +# apps/web/public/images/monsters/.png full painted artwork +# apps/web/public/images/monsters/runtime/-560.jpg downscaled for the web +# apps/web/public/images/combat/sprites/-.png background-free cutout +# apps/web/public/images/combat/icons/-128.png medallion icon +# +# The generated files are committed, so this only needs re-running when the +# source art changes. Re-runnable: it overwrites its own output and touches +# nothing else. +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $PSScriptRoot +$artEnemies = Join-Path $root 'art\enemies' +$artCutouts = Join-Path $artEnemies 'transparent-background' +$artBackgrounds = Join-Path $root 'art\backgrounds' +$webImages = Join-Path $root 'apps\web\public\images' + +foreach ($dir in @( + (Join-Path $webImages 'monsters\runtime'), + (Join-Path $webImages 'combat\sprites'), + (Join-Path $webImages 'combat\icons'), + (Join-Path $webImages 'backgrounds\runtime'))) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} + +function Save-Scaled { + param( + [string] $SourcePath, + [string] $TargetPath, + [int] $TargetWidth, + [string] $Format, + [switch] $Opaque + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $height = [int][Math]::Round($img.Height * $TargetWidth / $img.Width) + $bitmap = New-Object System.Drawing.Bitmap $TargetWidth, $height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + if ($Opaque) { + $graphics.Clear([System.Drawing.Color]::Black) + } + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage($img, 0, 0, $TargetWidth, $height) + $graphics.Dispose() + if ($Format -eq 'jpg') { + $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | + Where-Object { $_.MimeType -eq 'image/jpeg' } + $params = New-Object System.Drawing.Imaging.EncoderParameters 1 + $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter( + [System.Drawing.Imaging.Encoder]::Quality, 82) + $bitmap.Save($TargetPath, $codec, $params) + } + else { + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +function Save-Icon { + param( + [string] $SourcePath, + [string] $TargetPath, + # Crop window as fractions of the source, chosen per monster so the icon + # lands on the head rather than on whatever the centre happens to be. + [double] $CropX, [double] $CropY, [double] $CropSize + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $side = [int]([Math]::Min($img.Width, $img.Height) * $CropSize) + $x = [int]($img.Width * $CropX) + $y = [int]($img.Height * $CropY) + $bitmap = New-Object System.Drawing.Bitmap 128, 128 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage( + $img, + (New-Object System.Drawing.Rectangle 0, 0, 128, 128), + (New-Object System.Drawing.Rectangle $x, $y, $side, $side), + [System.Drawing.GraphicsUnit]::Pixel) + $graphics.Dispose() + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +# `source` is the painted file, `cutout` the background-free one. +# +# NOTE the deliberate crossing on the first two rows: the file named +# raider-scout depicts the heavier, plated, spear-carrying figure and is the +# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10 +# design decision D7. +$monsters = @( + @{ key = 'raider-scout'; source = 'raider-veteran'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'raider-veteran'; source = 'raider-scout'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'burned-hound'; source = 'burned-hound'; spriteHeight = 760; cropX = 0.06; cropY = 0.12; cropSize = 0.34 }, + @{ key = 'raider-captain'; source = 'Pluendererhauptmann'; spriteHeight = 620; cropX = 0.36; cropY = 0.02; cropSize = 0.28 } +) + +foreach ($monster in $monsters) { + $key = $monster.key + $painted = Join-Path $artEnemies "$($monster.source).png" + $cutout = Join-Path $artCutouts "$($monster.source).png" + + Copy-Item -Force $painted (Join-Path $webImages "monsters\$key.png") + Write-Host "wrote $key.png" + + Save-Scaled -SourcePath $painted ` + -TargetPath (Join-Path $webImages "monsters\runtime\$key-560.jpg") ` + -TargetWidth 560 -Format 'jpg' -Opaque + + Save-Scaled -SourcePath $cutout ` + -TargetPath (Join-Path $webImages "combat\sprites\$key-$($monster.spriteHeight).png") ` + -TargetWidth $monster.spriteHeight -Format 'png' + + Save-Icon -SourcePath $cutout ` + -TargetPath (Join-Path $webImages "combat\icons\$key-128.png") ` + -CropX $monster.cropX -CropY $monster.cropY -CropSize $monster.cropSize +} + +# The Raider Captain has a hand-made icon. Authored art beats a crop. +Copy-Item -Force (Join-Path $artEnemies 'PluendererhauptmannIcon.png') ` + (Join-Path $webImages 'combat\icons\raider-captain-128.png') +Write-Host 'wrote raider-captain-128.png (hand-made)' + +foreach ($background in 'Wachturm', 'Aschengrube') { + $source = Join-Path $artBackgrounds "$background.png" + Copy-Item -Force $source (Join-Path $webImages "backgrounds\$background.png") + Write-Host "wrote $background.png" + Save-Scaled -SourcePath $source ` + -TargetPath (Join-Path $webImages "backgrounds\runtime\$background-960.jpg") ` + -TargetWidth 960 -Format 'jpg' -Opaque +} +``` + +- [ ] **Step 4: Run the script** + +Run: `powershell -ExecutionPolicy Bypass -File tools/derive-monster-assets.ps1` +Expected: 22 "wrote …" lines, no errors. + +Then open the four generated `*-128.png` icons and confirm each frames the +monster's head. If one is off, adjust that monster's `cropX` / `cropY` / +`cropSize` in the script and re-run — the script is re-runnable by design. + +- [ ] **Step 5: Register the assets** + +In `apps/web/src/app/shared/monster-artwork.ts`, add to each map: + +```ts + '/images/monsters/raider-scout.png': '/images/monsters/runtime/raider-scout-560.jpg', + '/images/monsters/raider-veteran.png': '/images/monsters/runtime/raider-veteran-560.jpg', + '/images/monsters/burned-hound.png': '/images/monsters/runtime/burned-hound-560.jpg', + '/images/monsters/raider-captain.png': '/images/monsters/runtime/raider-captain-560.jpg', +``` + +```ts + 'raider-scout': '/images/combat/sprites/raider-scout-620.png', + 'raider-veteran': '/images/combat/sprites/raider-veteran-620.png', + 'burned-hound': '/images/combat/sprites/burned-hound-760.png', + 'raider-captain': '/images/combat/sprites/raider-captain-620.png', +``` + +```ts + 'raider-scout': '/images/combat/icons/raider-scout-128.png', + 'raider-veteran': '/images/combat/icons/raider-veteran-128.png', + 'burned-hound': '/images/combat/icons/burned-hound-128.png', + 'raider-captain': '/images/combat/icons/raider-captain-128.png', +``` + +```ts + 'raider-scout': 0.78, + 'raider-veteran': 0.84, + 'burned-hound': 0.6, + 'raider-captain': 0.9, +``` + +Also register the two new backgrounds in +`apps/web/src/app/features/world/location-page/location-page.component.ts`: + +```ts + '/images/backgrounds/Wachturm.png': '/images/backgrounds/runtime/Wachturm-960.jpg', + '/images/backgrounds/Aschengrube.png': '/images/backgrounds/runtime/Aschengrube-960.jpg', +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add tools/derive-monster-assets.ps1 apps/web/public/images apps/web/src/app/shared/monster-artwork.ts apps/web/src/app/shared/monster-artwork.spec.ts apps/web/src/app/features/world/location-page/location-page.component.ts +git commit -m "feat(web): derive and register the watchpost artwork" +``` + +--- + +### Task 11: Show guard and enrage in combat + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.scss` +- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `CombatMonsterDto.guardRemainingRounds` / `.enraged` from Task 4; event types from Tasks 2–3. +- Produces: `CombatMonster.guardRemainingRounds: number | null`, `CombatMonster.enraged: boolean`, `CombatEventType` union extended with `'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +The file already has an `activeCombat` fixture object and an +`async function setup(combat)` that returns the `ComponentFixture`. It runs on +**vitest** (`vi.fn`), not Jasmine. First add the two new fields to +`activeCombat.monster` so the existing tests still compile: + +```ts + pendingIntent: null, + guardRemainingRounds: null, + enraged: false, +``` + +Then add the tests inside the existing `describe`: + +```ts + const veteran: Combat = { + ...activeCombat, + monster: { ...activeCombat.monster, name: 'Raider Veteran' }, + }; + + it('announces a raised guard with the rounds it still covers', async () => { + const fixture = await setup({ + ...veteran, + monster: { ...veteran.monster, guardRemainingRounds: 2 }, + }); + + const banner: HTMLElement | null = fixture.nativeElement.querySelector( + '[data-combat-guard]', + ); + expect(banner?.textContent).toContain('Raider Veteran'); + expect(banner?.textContent).toContain('2'); + }); + + it('says nothing about a guard when the monster is open', async () => { + const fixture = await setup(activeCombat); + + expect( + fixture.nativeElement.querySelector('[data-combat-guard]'), + ).toBeNull(); + }); + + it('marks an enraged monster', async () => { + const fixture = await setup({ + ...activeCombat, + monster: { ...activeCombat.monster, enraged: true }, + }); + + expect( + fixture.nativeElement.querySelector('[data-combat-enraged]'), + ).not.toBeNull(); + }); + + it('reads the new events back in the log', async () => { + const fixture = await setup({ + ...veteran, + events: [ + { round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 }, + { round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' }, + { round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' }, + ], + }); + + const log = ( + fixture.nativeElement as HTMLElement + ).querySelector('.combat__log')?.textContent; + expect(log).toContain('Raider Veteran raises its guard.'); + expect(log).toContain('Raider Veteran turns savage.'); + expect(log).toContain("Raider Veteran's guard drops."); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: FAIL — `'guardRemainingRounds' does not exist in type 'CombatMonster'` + +- [ ] **Step 3: Extend the client models** + +In `apps/web/src/app/core/api/game-api.models.ts`, add to `CombatMonster`: + +```ts + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; +``` + +and add `| 'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'` to the `CombatEventType` +union. + +Both fields are required, matching the API DTO. Four spec files build a +`CombatMonster` literal and will stop compiling until each gets +`guardRemainingRounds: null, enraged: false` added to its fixture: + +```text +apps/web/src/app/core/resume-combat.spec.ts +apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +apps/web/src/app/features/combat/combat.store.spec.ts +apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +``` + +- [ ] **Step 4: Add the component logic** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`, add +two accessors next to `monsterIntentLabel`: + +```ts + protected monsterGuardLabel(): string | null { + const combat = this.displayed(); + const rounds = combat?.monster.guardRemainingRounds; + if (!combat || !rounds) { + return null; + } + const roundWord = rounds === 1 ? 'round' : 'rounds'; + return `${combat.monster.name} is covering — ${rounds} ${roundWord}. Shield Bash breaks it.`; + } + + protected monsterIsEnraged(): boolean { + return this.displayed()?.monster.enraged ?? false; + } +``` + +and add three branches to the log formatter, beside the `TELEGRAPH` branch: + +```ts + if (event.type === 'GUARD_RAISED') { + return `${monsterName} raises its guard.`; + } + + if (event.type === 'GUARD_ENDED') { + return `${monsterName}'s guard drops.`; + } + + if (event.type === 'ENRAGED') { + return `${monsterName} turns savage.`; + } +``` + +- [ ] **Step 5: Add the markup** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, +below the existing telegraph paragraph: + +```html + @if (monsterGuardLabel(); as guard) { +

{{ guard }}

+ } + + @if (monsterIsEnraged()) { +

+ {{ combat.monster.name }} is enraged. +

+ } +``` + +- [ ] **Step 6: Style the two banners** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`, +beside the existing `.combat__telegraph` rule, add two variants that reuse its +shape rather than inventing a new one. Copy the `.combat__telegraph` +declarations and change only the accent colour: a cold steel tone for +`.combat__guard`, the existing danger accent for `.combat__enraged`. Take both +colours from the design tokens the file already imports — do not introduce new +hex values. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/combat/combat-page +git commit -m "feat(web): show monster guard and rage on the combat screen" +``` + +--- + +### Task 12: Announce the discovered route in the interaction panel + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts` +- Modify: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html` +- Modify: `apps/web/src/app/features/world/local-location.store.ts` +- Test: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts` + +**Interfaces:** +- Consumes: `LocationInteractionResultDto.discoveredLocation` from Task 7. +- Produces: `LocationInteractionResult.discoveredLocation: { key: string; name: string } | null` in the client model. + +- [ ] **Step 1: Write the failing tests** + +Add to +`apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts`: + +The file has an `async function setup(inputs)` returning `{ fixture, closed, +element }`, and its `result` parameter is typed inline as +`{ interactionKey: string; title: string; text: string; img?: string }`. Add +`discoveredLocation?: { key: string; name: string } | null;` to that inline type, +then add the tests inside the existing `describe`: + +```ts + it('says so when the interaction revealed a route', async () => { + const { element } = await setup({ + result: { + interactionKey: 'inspect-watchpost', + title: 'The Watchpost', + text: 'Fresh tracks lead east.', + discoveredLocation: { key: 'ash-pit', name: 'Ash Pit' }, + }, + }); + + const banner = element.querySelector('[data-discovered-location]'); + expect(banner?.textContent).toContain('Ash Pit'); + }); + + it('stays quiet when nothing new was revealed', async () => { + const { element } = await setup({ + result: { + interactionKey: 'search-guard-quarters', + title: 'Guard Quarters', + text: 'Nothing but ash.', + discoveredLocation: null, + }, + }); + + expect(element.querySelector('[data-discovered-location]')).toBeNull(); + }); +``` + + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: FAIL — `'discoveredLocation' does not exist in type 'LocationInteractionResult'` + +- [ ] **Step 3: Extend the client model** + +In `apps/web/src/app/core/api/game-api.models.ts`, add to +`LocationInteractionResult`: + +```ts + /** + * Set only the first time an interaction reveals a route. Optional on the + * client although the API always sends it: every existing test fixture + * builds this object literally, and a required field would break them all + * for no gain. + */ + discoveredLocation?: { key: string; name: string } | null; +``` + +- [ ] **Step 4: Render the banner** + +In +`apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html`, +below the result text: + +The panel uses a classic `@Input() result: LocationInteractionResult | null`, +not a signal input, so the template reads the property directly: + +```html + @if (result?.discoveredLocation; as discovered) { +

+ New route discovered: {{ discovered.name }}. +

+ } +``` + +Style `.interaction__discovery` in the component's SCSS using the accent token +the panel already uses for emphasis. Do not add a new colour. + +- [ ] **Step 5: Refresh the map after a discovery** + +In `apps/web/src/app/features/world/local-location.store.ts`, after an +interaction resolves, reload the current location when the result carries a +discovery — the new route has to appear without a manual refresh: + +```ts + if (result.discoveredLocation) { + // The connection list is server-filtered, so a fresh reveal only shows + // up after the location is re-read. + await this.loadCurrentLocation(); + } +``` + +Use whatever the store's existing reload method is called. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 7: Run the whole web suite** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/world +git commit -m "feat(web): announce a newly discovered route" +``` + +--- + +### Task 13: Verify end-to-end, build, and document + +**Files:** +- Create: `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md` +- Modify: `docs/playable-slices/0.10-Abandoned-Watchpost.md` (tick the acceptance criteria) + +**Interfaces:** +- Consumes: everything above. +- Produces: a record of what was built and where it deviates from the slice document. + +- [ ] **Step 1: Run the migration and seed against a real database** + +```bash +npm run db:migrate +npm run db:seed +npm run db:seed +``` + +Expected: the migration applies cleanly; the seed runs twice with no duplicate-key +error and no duplicated rows (AGENTS §8). + +- [ ] **Step 2: Walk the loop in the browser** + +Start the app (`npm run dev:api` and `npm run dev:web`) and confirm by hand: + +1. The Burned Road shows a route to the Abandoned Watchpost; travelling takes ~15 s. +2. The Watchpost map shows **no** Ash Pit route. +3. Inspecting the watchpost reveals the §8 clue and announces the new route. +4. The Ash Pit route now appears and can be travelled. +5. A hunt at the Watchpost only offers the five monsters from its own pool. +6. A Raider Veteran fight shows the guard banner; Shield Bash breaks it. +7. A Burned Hound below 35 % HP shows the enrage banner and hits harder. +8. Scorched Hide and Raider Warband Mark both drop and both sell to Borin. + +- [ ] **Step 3: Run lint and build** + +```bash +npm run lint --workspace=@ashen-realms/api +npm run build +``` + +Expected: both pass. Fix anything they surface before continuing. + +- [ ] **Step 4: Run the full test suite** + +```bash +npm test +``` + +Expected: PASS across both workspaces. + +- [ ] **Step 5: Write the implementation notes** + +Create `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md` +following the shape of the Slice 0.9 notes. Record at minimum: + +- the discovery model (table, connection column, service) and why the gate is + enforced in two places +- `guard` and `enrage` as content abilities, with their exact configuration and + the telegraph-wins priority rule +- the Ash Pit stub and what Slice 0.11 is expected to add to it +- deviations from the slice document: no surviving-guard NPC (§3, no portrait + art exists — the investigation is a hotspot instead), and the crossed raider + artwork files (design D7) +- the new `tools/derive-monster-assets.ps1` and when it needs re-running + +- [ ] **Step 6: Tick the acceptance criteria** + +In `docs/playable-slices/0.10-Abandoned-Watchpost.md` §12, change each `- [ ]` +to `- [x]`. If any criterion is genuinely unmet, leave it unticked and say why +in the implementation notes rather than ticking it anyway. + +- [ ] **Step 7: Commit** + +```bash +git add docs/playable-slices +git commit -m "docs: record the Slice 0.10 watchpost implementation and its deviations" +``` + +--- + +## Self-Review Notes + +Spec coverage check, section by section: + +| Spec section | Task | +|---|---| +| §3 Discovery data + service | 1, 5 | +| §3.3 Two call sites | 6, 7 | +| §3.4 POI discovery | 7, 9 | +| §4.1 Locations | 9 | +| §4.2 Connections | 9 | +| §4.3 Local view | 9 | +| §5 Encounter pool | 9 | +| §6.1 Trade goods + exchange | 8 | +| §6.2 Loot tables | 8 | +| §7.1–7.2 Guard | 2 | +| §7.3 Enrage | 3 | +| §7.4 Contract changes | 1, 2, 3, 4 | +| §8 Frontend | 11, 12 | +| §9 Artwork | 10 | +| §10 Tests | woven through every task | +| §11 Out of scope | 9 (Ash Pit stays a stub), 13 (documented) |