Thirteen TDD tasks from the approved design: schema, the guard and enrage combat abilities, the discovery service and its two enforcement points, the seed content, the derived artwork, and the two frontend surfaces. Each task carries its own failing test, the code to make it pass and a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012NjEPjZ8R8e9c23z3vR8bt
106 KiB
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
requiredLevelgating 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
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, columnlocation_connections.requires_discovery, enum valuesGUARD_RAISED/GUARD_ENDED/ENRAGEDoncombat_event_type_enum. Class nameCreateAbandonedWatchpost1798000000000. -
Step 1: Write the failing test
Create apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.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<string> {
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<string> {
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:
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<void> {
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<void> {
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
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)fromcombat-damage.ts. -
Produces:
MonsterGuardAbility { roundInterval, armorBonus, durationRounds }onMonsterAbilities.guard;CombatEngineCombatantStats.activeGuard?: { remainingRounds: number; armorBonus: number }; event typesGUARD_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:
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:
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:
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:
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:
// 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:
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:
/** Armor the monster actually presents this round, guard included. */
private effectiveArmor(combatant: CombatEngineCombatant): number {
return combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0);
}
const damage = calculateDamage(
player.stats,
this.effectiveArmor(monster),
multiplier,
);
In resolveShieldBash, extend the interrupt block so it also breaks a guard:
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:
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:
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:
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:
/**
* 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:
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
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:
MonsterAbilitiesfrom Task 2. -
Produces:
MonsterEnrageAbility { hpThresholdPercent, damageMultiplier }onMonsterAbilities.enrage;CombatEngineCombatantStats.enraged?: boolean; event typeENRAGED. -
Step 1: Write the failing tests
Append to apps/api/src/combat/combat-engine.service.spec.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:
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:
// 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:
this.checkEnrage(monster, events);
Add the method:
/**
* 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:
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
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/.enragedfrom Tasks 2 and 3. - Produces:
CombatMonsterDto.guardRemainingRounds: number | nullandCombatMonsterDto.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:
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:
/** 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:
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
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: booleanclass CharacterLocationDiscovery { id, characterId, locationId, discoveredAt }WorldDiscoveryService.getDiscoveredLocationIds(characterId): Promise<Set<string>>WorldDiscoveryService.discover(characterId, locationKey, manager?): Promise<DiscoveredLocation | null>whereDiscoveredLocation = { key: string; name: string }WorldDiscoveryService.isTravelAllowed(characterId, connection, manager?): Promise<boolean>WorldDiscoveryModuleexporting 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:
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<string, unknown>;
orIgnore: boolean;
}
function buildService(options: {
discoveries?: Array<{ locationId: string }>;
locations?: Array<Partial<LocationDefinition>>;
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<string, unknown>) => {
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<CharacterLocationDiscovery>;
const locationRepository = {
findOneBy: jest.fn(({ key }: { key: string }) =>
Promise.resolve(locations.find((location) => location.key === key) ?? null),
),
} as unknown as Repository<LocationDefinition>;
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:
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:
/**
* 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:
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<Set<string>> {
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<DiscoveredLocation | null> {
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<LocationConnection, 'toLocationId' | 'requiresDiscovery'>,
manager?: EntityManager,
): Promise<boolean> {
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:
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
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.isTravelAllowedfrom Task 5. -
Produces:
TravelServiceconstructor gains a third parameterworldDiscovery: 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:
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:
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:
import { WorldDiscoveryService } from '../world/discovery/world-discovery.service';
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:
// 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:
import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module';
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
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:
WorldDiscoveryServicefrom Task 5. -
Produces:
LocationPointOfInterestContent.discoversLocationKey?: stringLocationInteractionResultDto.discoveredLocation: DiscoveredLocation | nullWorldServiceconstructor gainsworldDiscovery: WorldDiscoveryServiceas its final parameter.
-
Step 1: Write the failing tests
Add to apps/api/src/world/world.service.spec.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:
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:
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:
/**
* 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:
/**
* 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:
import { WorldDiscoveryService } from './discovery/world-discovery.service';
Add the constructor parameter after locationMonsters:
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:
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:
// 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:
import { WorldDiscoveryModule } from './discovery/world-discovery.module';
TravelModule,
WorldDiscoveryModule,
- Step 6: Run tests to verify they pass
Run: npm test --workspace=@ashen-realms/api -- world
Expected: PASS
- Step 7: Commit
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()andentry()helpers initem-content.ts,SeedExchangeRuleinnpc-content.ts. -
Produces: item keys
scorched-hide,raider-warband-mark; loot-table constantsRAIDER_SCOUT_LOOT_TABLE_ID,BURNED_HOUND_LOOT_TABLE_ID,RAIDER_VETERAN_LOOT_TABLE_ID,RAIDER_CAPTAIN_LOOT_TABLE_ID; four newLOOT_TABLESrows and their entries; two newEXCHANGE_RULES. -
Step 1: Write the failing tests
Add to apps/api/src/database/seeds/vertical-slice.seed.spec.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':
'scorched-hide': '50000000-0000-4000-8000-000000000010',
'raider-warband-mark': '50000000-0000-4000-8000-000000000011',
and append the four table ids:
// 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:
// 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:
{
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:
// 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:
// 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
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,
MonsterAbilitiesfrom Tasks 2–3,LocalLocationContentfromlocal-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; exportsABANDONED_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:
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<Record<string, unknown>>
).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:
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:
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:
{
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:
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:
{
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:
{
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:
// 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
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, theirruntime/*-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}andbackgrounds/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:
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:
# 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/<key>.png full painted artwork
# apps/web/public/images/monsters/runtime/<key>-560.jpg downscaled for the web
# apps/web/public/images/combat/sprites/<key>-<h>.png background-free cutout
# apps/web/public/images/combat/icons/<key>-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:
'/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',
'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',
'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',
'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:
'/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
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/.enragedfrom Task 4; event types from Tasks 2–3. -
Produces:
CombatMonster.guardRemainingRounds: number | null,CombatMonster.enraged: boolean,CombatEventTypeunion 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:
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
Then add the tests inside the existing describe:
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:
/** 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:
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:
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:
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:
@if (monsterGuardLabel(); as guard) {
<p class="combat__guard" data-combat-guard role="status">{{ guard }}</p>
}
@if (monsterIsEnraged()) {
<p class="combat__enraged" data-combat-enraged role="status">
{{ combat.monster.name }} is enraged.
</p>
}
- 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
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.discoveredLocationfrom Task 7. -
Produces:
LocationInteractionResult.discoveredLocation: { key: string; name: string } | nullin 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:
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:
/**
* 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:
@if (result?.discoveredLocation; as discovered) {
<p class="interaction__discovery" data-discovered-location role="status">
New route discovered: {{ discovered.name }}.
</p>
}
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:
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
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
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:
- The Burned Road shows a route to the Abandoned Watchpost; travelling takes ~15 s.
- The Watchpost map shows no Ash Pit route.
- Inspecting the watchpost reveals the §8 clue and announces the new route.
- The Ash Pit route now appears and can be travelled.
- A hunt at the Watchpost only offers the five monsters from its own pool.
- A Raider Veteran fight shows the guard banner; Shield Bash breaks it.
- A Burned Hound below 35 % HP shows the enrage banner and hits harder.
- Scorched Hide and Raider Warband Mark both drop and both sell to Borin.
- Step 3: Run lint and build
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
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
-
guardandenrageas 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.ps1and 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
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) |