# 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) |