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
3262 lines
106 KiB
Markdown
3262 lines
106 KiB
Markdown
# Playable Slice 0.10 — Abandoned Watchpost Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add the Abandoned Watchpost as a full playable location with its own encounter pool, trade goods and equipment progression, plus the first non-level world gate: an Ash Pit route the player has to discover.
|
||
|
||
**Architecture:** Three genuinely new mechanisms — a per-character discovery table gating individual travel connections, two content-driven combat abilities (`guard`, `enrage`) added to the ability set the engine already reads, and a re-runnable PowerShell script that derives per-monster web assets from the committed art. Everything else is seed content that flows through systems that already exist.
|
||
|
||
**Tech Stack:** NestJS 11, TypeORM, PostgreSQL, Jest (API); Angular 20 standalone components, Karma/Jasmine (web); PowerShell + System.Drawing for asset derivation.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- All player-facing content is **English** (slice §12, AGENTS §33).
|
||
- Server is authoritative: every gate enforced in the API, never only in the UI (AGENTS §5).
|
||
- No normal enemy grants Silver, XP or reputation directly (slice §6). Silver reaches the player only through Borin's exchange.
|
||
- No `requiredLevel` gating anywhere in this slice (slice §2, §9).
|
||
- Combat stays deterministic: fixed cadences, no hidden rolls (AGENTS §10).
|
||
- Monster behaviour is content, never an engine branch on a monster key (AGENTS §9).
|
||
- Seeds are idempotent — re-running must never duplicate rows (AGENTS §8).
|
||
- Do not retune existing Burned Road content. This slice adds; it does not rebalance (AGENTS §39).
|
||
- API tests: `npm test --workspace=@ashen-realms/api`. Web tests: `npm test --workspace=@ashen-realms/web`.
|
||
- Every task ends with a commit. Commit messages follow the repo's Conventional Commits style (`feat(api):`, `test(seed):`, `docs:`).
|
||
|
||
### Stable IDs used throughout this plan
|
||
|
||
```text
|
||
ABANDONED_WATCHPOST_ID 20000000-0000-4000-8000-000000000003
|
||
ASH_PIT_ID 20000000-0000-4000-8000-000000000004
|
||
RAIDER_SCOUT_MONSTER_ID 30000000-0000-4000-8000-000000000005
|
||
BURNED_HOUND_MONSTER_ID 30000000-0000-4000-8000-000000000006
|
||
RAIDER_VETERAN_MONSTER_ID 30000000-0000-4000-8000-000000000007
|
||
RAIDER_CAPTAIN_MONSTER_ID 30000000-0000-4000-8000-000000000008
|
||
scorched-hide item 50000000-0000-4000-8000-000000000010
|
||
raider-warband-mark item 50000000-0000-4000-8000-000000000011
|
||
RAIDER_SCOUT_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000005
|
||
BURNED_HOUND_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000006
|
||
RAIDER_VETERAN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000007
|
||
RAIDER_CAPTAIN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000008
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Migration — discovery table, connection gate, combat event types
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts`
|
||
- Test: `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: table `character_location_discoveries`, column `location_connections.requires_discovery`, enum values `GUARD_RAISED` / `GUARD_ENDED` / `ENRAGED` on `combat_event_type_enum`. Class name `CreateAbandonedWatchpost1798000000000`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts`:
|
||
|
||
```ts
|
||
import 'reflect-metadata';
|
||
import { QueryRunner } from 'typeorm';
|
||
import { CreateAbandonedWatchpost1798000000000 } from './1798000000000-CreateAbandonedWatchpost';
|
||
|
||
/**
|
||
* The migration writes multi-line SQL, so every assertion below reads it with
|
||
* runs of whitespace collapsed -- the same harness the other migration specs
|
||
* use, so reindenting a statement never breaks a test that still describes
|
||
* the right schema.
|
||
*/
|
||
function collapse(statements: string[]): string {
|
||
return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n');
|
||
}
|
||
|
||
async function runUp(): Promise<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`:
|
||
|
||
```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**
|
||
|
||
```bash
|
||
git add apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts
|
||
git commit -m "feat(api): add schema for watchpost discovery and guard events"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Combat engine — the `guard` ability
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/monsters/monster-abilities.ts`
|
||
- Modify: `apps/api/src/combat/combat-event-type.enum.ts`
|
||
- Modify: `apps/api/src/combat/combat-engine.types.ts`
|
||
- Modify: `apps/api/src/combat/combat-engine.service.ts`
|
||
- Test: `apps/api/src/combat/combat-engine.service.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `calculateDamage(attacker, targetArmor, multiplier)` from `combat-damage.ts`.
|
||
- Produces: `MonsterGuardAbility { roundInterval, armorBonus, durationRounds }` on `MonsterAbilities.guard`; `CombatEngineCombatantStats.activeGuard?: { remainingRounds: number; armorBonus: number }`; event types `GUARD_RAISED`, `GUARD_ENDED`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `apps/api/src/combat/combat-engine.service.spec.ts`, inside the top-level `describe`:
|
||
|
||
```ts
|
||
const VETERAN_ABILITIES: MonsterAbilities = {
|
||
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
||
guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 },
|
||
};
|
||
|
||
describe('guard', () => {
|
||
it('raises its guard instead of attacking on the interval round', () => {
|
||
const state = baseState({
|
||
round: 4,
|
||
monster: withAbilities(VETERAN_ABILITIES, {
|
||
currentHp: 120,
|
||
maxHp: 120,
|
||
stats: { attack: 14, armor: 10, abilities: VETERAN_ABILITIES },
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.player.currentHp).toBe(100);
|
||
expect(result.events.map((event) => event.type)).toContain(
|
||
CombatEventType.GUARD_RAISED,
|
||
);
|
||
expect(result.state.monster.stats.activeGuard).toEqual({
|
||
remainingRounds: 2,
|
||
armorBonus: 10,
|
||
});
|
||
});
|
||
|
||
it('adds the guard bonus to the armor the player has to cut through', () => {
|
||
const guarded = baseState({
|
||
round: 5,
|
||
monster: withAbilities(VETERAN_ABILITIES, {
|
||
currentHp: 120,
|
||
maxHp: 120,
|
||
stats: {
|
||
attack: 14,
|
||
armor: 10,
|
||
abilities: VETERAN_ABILITIES,
|
||
activeGuard: { remainingRounds: 2, armorBonus: 10 },
|
||
},
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(guarded, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
// 14 raw damage against armor 20 instead of 10: 14*60/80 = 10.5 -> 11.
|
||
const damage = result.events.find(
|
||
(event) => event.type === CombatEventType.DAMAGE,
|
||
);
|
||
expect(damage?.amount).toBe(11);
|
||
});
|
||
|
||
it('drops the guard when its rounds run out', () => {
|
||
const state = baseState({
|
||
round: 5,
|
||
monster: withAbilities(VETERAN_ABILITIES, {
|
||
currentHp: 120,
|
||
maxHp: 120,
|
||
stats: {
|
||
attack: 14,
|
||
armor: 10,
|
||
abilities: VETERAN_ABILITIES,
|
||
activeGuard: { remainingRounds: 1, armorBonus: 10 },
|
||
},
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.monster.stats.activeGuard).toBeUndefined();
|
||
expect(result.events.map((event) => event.type)).toContain(
|
||
CombatEventType.GUARD_ENDED,
|
||
);
|
||
});
|
||
|
||
it('lets Shield Bash break an active guard', () => {
|
||
const state = baseState({
|
||
round: 5,
|
||
monster: withAbilities(VETERAN_ABILITIES, {
|
||
currentHp: 120,
|
||
maxHp: 120,
|
||
stats: {
|
||
attack: 14,
|
||
armor: 10,
|
||
abilities: VETERAN_ABILITIES,
|
||
activeGuard: { remainingRounds: 2, armorBonus: 10 },
|
||
},
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.SHIELD_BASH,
|
||
});
|
||
|
||
expect(result.state.monster.stats.activeGuard).toBeUndefined();
|
||
const types = result.events.map((event) => event.type);
|
||
expect(types).toContain(CombatEventType.INTERRUPT);
|
||
expect(types).toContain(CombatEventType.GUARD_ENDED);
|
||
});
|
||
|
||
it('lets a telegraph win when both are due in the same round', () => {
|
||
const collidingAbilities: MonsterAbilities = {
|
||
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
|
||
guard: { roundInterval: 2, armorBonus: 10, durationRounds: 2 },
|
||
};
|
||
const state = baseState({
|
||
round: 2,
|
||
monster: withAbilities(collidingAbilities, {
|
||
currentHp: 120,
|
||
maxHp: 120,
|
||
stats: { attack: 14, armor: 10, abilities: collidingAbilities },
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
|
||
expect(result.state.monster.stats.activeGuard).toBeUndefined();
|
||
});
|
||
|
||
it('leaves a monster without the ability exactly as it was', () => {
|
||
const state = baseState({ round: 4 });
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.monster.stats.activeGuard).toBeUndefined();
|
||
expect(result.events.map((event) => event.type)).not.toContain(
|
||
CombatEventType.GUARD_RAISED,
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service`
|
||
Expected: FAIL — `Property 'guard' does not exist on type 'MonsterAbilities'` and `GUARD_RAISED` missing from `CombatEventType`
|
||
|
||
- [ ] **Step 3: Add the ability type**
|
||
|
||
In `apps/api/src/monsters/monster-abilities.ts`, add before `MonsterAbilities`:
|
||
|
||
```ts
|
||
export interface MonsterGuardAbility {
|
||
/**
|
||
* The monster forgoes its attack on every round divisible by this and
|
||
* covers instead, raising its armor for `durationRounds`. SHIELD_BASH
|
||
* breaks it, the same answer the telegraph already taught (Playable Slice
|
||
* 0.10 §5).
|
||
*/
|
||
roundInterval: number;
|
||
armorBonus: number;
|
||
durationRounds: number;
|
||
}
|
||
```
|
||
|
||
and extend the interface:
|
||
|
||
```ts
|
||
export interface MonsterAbilities {
|
||
telegraph?: MonsterTelegraphAbility;
|
||
bleed?: MonsterBleedAbility;
|
||
guard?: MonsterGuardAbility;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add the event types**
|
||
|
||
In `apps/api/src/combat/combat-event-type.enum.ts`, add to the enum after `INTERRUPT`:
|
||
|
||
```ts
|
||
GUARD_RAISED = 'GUARD_RAISED',
|
||
GUARD_ENDED = 'GUARD_ENDED',
|
||
```
|
||
|
||
- [ ] **Step 5: Add the engine state field**
|
||
|
||
In `apps/api/src/combat/combat-engine.types.ts`, add to `CombatEngineCombatantStats` after `pendingAction`:
|
||
|
||
```ts
|
||
// Monster-only: set when it covers, cleared when the rounds run out or a
|
||
// Shield Bash breaks it. Its bonus is added to the monster's armor while
|
||
// it lasts (Playable Slice 0.10 §7).
|
||
activeGuard?: { remainingRounds: number; armorBonus: number };
|
||
```
|
||
|
||
- [ ] **Step 6: Implement the engine behaviour**
|
||
|
||
In `apps/api/src/combat/combat-engine.service.ts`:
|
||
|
||
Add the import:
|
||
|
||
```ts
|
||
import type {
|
||
MonsterBleedAbility,
|
||
MonsterGuardAbility,
|
||
MonsterTelegraphAbility,
|
||
} from '../monsters/monster-abilities';
|
||
```
|
||
|
||
Add a helper that reads the monster's effective armor, and use it in both places
|
||
the player's blow is calculated (`resolvePlayerStrike` and `resolveShieldBash`),
|
||
replacing `monster.stats.armor`:
|
||
|
||
```ts
|
||
/** Armor the monster actually presents this round, guard included. */
|
||
private effectiveArmor(combatant: CombatEngineCombatant): number {
|
||
return combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0);
|
||
}
|
||
```
|
||
|
||
```ts
|
||
const damage = calculateDamage(
|
||
player.stats,
|
||
this.effectiveArmor(monster),
|
||
multiplier,
|
||
);
|
||
```
|
||
|
||
In `resolveShieldBash`, extend the interrupt block so it also breaks a guard:
|
||
|
||
```ts
|
||
let interrupted = false;
|
||
if (monster.stats.pendingAction) {
|
||
monster.stats.pendingAction = undefined;
|
||
interrupted = true;
|
||
events.push({
|
||
source: Combatant.PLAYER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.INTERRUPT,
|
||
});
|
||
}
|
||
|
||
// A guard is a prepared stance like a wind-up, and the bash answers both.
|
||
// Only one INTERRUPT is emitted even when the bash breaks both at once --
|
||
// the player made one interruption, not two.
|
||
if (monster.stats.activeGuard) {
|
||
monster.stats.activeGuard = undefined;
|
||
if (!interrupted) {
|
||
events.push({
|
||
source: Combatant.PLAYER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.INTERRUPT,
|
||
});
|
||
}
|
||
interrupted = true;
|
||
events.push({
|
||
source: Combatant.MONSTER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.GUARD_ENDED,
|
||
});
|
||
}
|
||
```
|
||
|
||
In `resolveMonsterTurn`, insert the guard branch after the telegraph branch and
|
||
before the normal strike:
|
||
|
||
```ts
|
||
if (this.shouldTrigger(abilities.guard, round)) {
|
||
const guard = abilities.guard as MonsterGuardAbility;
|
||
monster.stats.activeGuard = {
|
||
remainingRounds: guard.durationRounds,
|
||
armorBonus: guard.armorBonus,
|
||
};
|
||
events.push({
|
||
source: Combatant.MONSTER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.GUARD_RAISED,
|
||
amount: guard.durationRounds,
|
||
});
|
||
return;
|
||
}
|
||
```
|
||
|
||
Widen the `shouldTrigger` signature to accept the new ability:
|
||
|
||
```ts
|
||
private shouldTrigger(
|
||
ability:
|
||
| MonsterTelegraphAbility
|
||
| MonsterBleedAbility
|
||
| MonsterGuardAbility
|
||
| undefined,
|
||
round: number,
|
||
): boolean {
|
||
```
|
||
|
||
Age the guard **before** the monster acts. In `finishRound`, immediately before
|
||
the `if (!interrupted)` block, add:
|
||
|
||
```ts
|
||
this.ageGuard(monster, events);
|
||
```
|
||
|
||
Order matters here. The player's blow for this round was already calculated
|
||
against the guard by the time `finishRound` runs, and the guard is raised inside
|
||
`resolveMonsterTurn` — which comes after this line. So a guard raised on round N
|
||
is first aged on round N+1, and `durationRounds: 2` means it turns aside the
|
||
player's next two attacks. Ageing after `resolveMonsterTurn` instead would eat a
|
||
round off the guard the moment it went up.
|
||
|
||
Placing it before the `if (!interrupted)` guard also means the counter runs down
|
||
even on a round the monster was interrupted out of, so a Shield Bash on a
|
||
telegraph never accidentally extends an unrelated guard.
|
||
|
||
Add the method:
|
||
|
||
```ts
|
||
/**
|
||
* Counts one round off an active guard, and drops it when it runs out.
|
||
*
|
||
* Called before the monster acts, so the guard it raises this round is not
|
||
* immediately aged: `durationRounds: 2` turns aside the player's next two
|
||
* attacks.
|
||
*/
|
||
private ageGuard(
|
||
monster: CombatEngineCombatant,
|
||
events: CombatEngineEvent[],
|
||
): void {
|
||
const guard = monster.stats.activeGuard;
|
||
if (!guard) {
|
||
return;
|
||
}
|
||
|
||
const remainingRounds = guard.remainingRounds - 1;
|
||
if (remainingRounds > 0) {
|
||
monster.stats.activeGuard = { ...guard, remainingRounds };
|
||
return;
|
||
}
|
||
|
||
monster.stats.activeGuard = undefined;
|
||
events.push({
|
||
source: Combatant.MONSTER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.GUARD_ENDED,
|
||
});
|
||
}
|
||
```
|
||
|
||
Finally, clear the guard on death alongside `pendingAction` in `finishRound`:
|
||
|
||
```ts
|
||
const defeatedMonster = {
|
||
...monster,
|
||
stats: {
|
||
...monster.stats,
|
||
pendingAction: undefined,
|
||
activeGuard: undefined,
|
||
},
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service`
|
||
Expected: PASS — all previously existing tests plus the six new `guard` tests
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts
|
||
git commit -m "feat(api): let monsters raise a breakable defensive guard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Combat engine — the `enrage` ability
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/monsters/monster-abilities.ts`
|
||
- Modify: `apps/api/src/combat/combat-event-type.enum.ts`
|
||
- Modify: `apps/api/src/combat/combat-engine.types.ts`
|
||
- Modify: `apps/api/src/combat/combat-engine.service.ts`
|
||
- Test: `apps/api/src/combat/combat-engine.service.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MonsterAbilities` from Task 2.
|
||
- Produces: `MonsterEnrageAbility { hpThresholdPercent, damageMultiplier }` on `MonsterAbilities.enrage`; `CombatEngineCombatantStats.enraged?: boolean`; event type `ENRAGED`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `apps/api/src/combat/combat-engine.service.spec.ts`:
|
||
|
||
```ts
|
||
const HOUND_ABILITIES: MonsterAbilities = {
|
||
bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 },
|
||
enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 },
|
||
};
|
||
|
||
describe('enrage', () => {
|
||
it('turns aggressive once its HP crosses the threshold', () => {
|
||
// 80 max HP, 40 left; a 14-damage blow lands it on 26, below 35%.
|
||
const state = baseState({
|
||
round: 1,
|
||
player: {
|
||
currentHp: 100,
|
||
maxHp: 100,
|
||
stats: { attack: 6, weaponDamage: 8, armor: 0 },
|
||
},
|
||
monster: withAbilities(HOUND_ABILITIES, {
|
||
currentHp: 40,
|
||
maxHp: 80,
|
||
stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES },
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.monster.stats.enraged).toBe(true);
|
||
expect(result.events.map((event) => event.type)).toContain(
|
||
CombatEventType.ENRAGED,
|
||
);
|
||
// The same round's reply already hits harder: 12 * 1.4 = 16.8 -> 17.
|
||
const monsterHit = result.events.find(
|
||
(event) =>
|
||
event.type === CombatEventType.DAMAGE &&
|
||
event.source === Combatant.MONSTER,
|
||
);
|
||
expect(monsterHit?.amount).toBe(17);
|
||
});
|
||
|
||
it('stays quiet above the threshold', () => {
|
||
const state = baseState({
|
||
round: 1,
|
||
monster: withAbilities(HOUND_ABILITIES, {
|
||
currentHp: 80,
|
||
maxHp: 80,
|
||
stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES },
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.ATTACK,
|
||
});
|
||
|
||
expect(result.state.monster.stats.enraged).toBeUndefined();
|
||
expect(result.events.map((event) => event.type)).not.toContain(
|
||
CombatEventType.ENRAGED,
|
||
);
|
||
});
|
||
|
||
it('announces the change only once', () => {
|
||
const state = baseState({
|
||
round: 2,
|
||
monster: withAbilities(HOUND_ABILITIES, {
|
||
currentHp: 20,
|
||
maxHp: 80,
|
||
stats: {
|
||
attack: 12,
|
||
armor: 0,
|
||
abilities: HOUND_ABILITIES,
|
||
enraged: true,
|
||
},
|
||
}),
|
||
});
|
||
|
||
const result = new CombatEngineService().resolveAction(state, {
|
||
action: CombatAction.DEFEND,
|
||
});
|
||
|
||
expect(result.events.map((event) => event.type)).not.toContain(
|
||
CombatEventType.ENRAGED,
|
||
);
|
||
expect(result.state.monster.stats.enraged).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service`
|
||
Expected: FAIL — `Property 'enrage' does not exist on type 'MonsterAbilities'`
|
||
|
||
- [ ] **Step 3: Add the ability type**
|
||
|
||
In `apps/api/src/monsters/monster-abilities.ts`:
|
||
|
||
```ts
|
||
export interface MonsterEnrageAbility {
|
||
/**
|
||
* Once its HP first falls to or below this share of maximum, the monster
|
||
* hits harder for the rest of the fight. One deterministic state change,
|
||
* never reversed and never rolled (Playable Slice 0.10 §5).
|
||
*/
|
||
hpThresholdPercent: number;
|
||
damageMultiplier: number;
|
||
}
|
||
```
|
||
|
||
and add `enrage?: MonsterEnrageAbility;` to `MonsterAbilities`.
|
||
|
||
- [ ] **Step 4: Add the event type and state field**
|
||
|
||
In `apps/api/src/combat/combat-event-type.enum.ts` add `ENRAGED = 'ENRAGED',` after `GUARD_ENDED`.
|
||
|
||
In `apps/api/src/combat/combat-engine.types.ts` add to `CombatEngineCombatantStats`:
|
||
|
||
```ts
|
||
// Monster-only: latched the first time its HP crosses the enrage threshold.
|
||
enraged?: boolean;
|
||
```
|
||
|
||
- [ ] **Step 5: Implement the engine behaviour**
|
||
|
||
In `apps/api/src/combat/combat-engine.service.ts`, add the check at the top of
|
||
`resolveMonsterTurn`, before the pending-action branch — the monster notices its
|
||
wounds before it decides what to do:
|
||
|
||
```ts
|
||
this.checkEnrage(monster, events);
|
||
```
|
||
|
||
Add the method:
|
||
|
||
```ts
|
||
/**
|
||
* Latches the enraged state the first time the monster's HP crosses its
|
||
* threshold. Checked before it acts, so the blow that wounded it is already
|
||
* answered in kind.
|
||
*/
|
||
private checkEnrage(
|
||
monster: CombatEngineCombatant,
|
||
events: CombatEngineEvent[],
|
||
): void {
|
||
const enrage = monster.stats.abilities?.enrage;
|
||
if (!enrage || monster.stats.enraged) {
|
||
return;
|
||
}
|
||
|
||
const threshold = (monster.maxHp * enrage.hpThresholdPercent) / 100;
|
||
if (monster.currentHp > threshold) {
|
||
return;
|
||
}
|
||
|
||
monster.stats.enraged = true;
|
||
events.push({
|
||
source: Combatant.MONSTER,
|
||
target: Combatant.MONSTER,
|
||
type: CombatEventType.ENRAGED,
|
||
});
|
||
}
|
||
```
|
||
|
||
Apply the multiplier where the monster strikes. In `strikePlayer`, fold it into
|
||
the multiplier passed in:
|
||
|
||
```ts
|
||
private strikePlayer(
|
||
monster: CombatEngineCombatant,
|
||
player: CombatEngineCombatant,
|
||
multiplier: number,
|
||
events: CombatEngineEvent[],
|
||
): void {
|
||
const enrageMultiplier = monster.stats.enraged
|
||
? (monster.stats.abilities?.enrage?.damageMultiplier ?? 1)
|
||
: 1;
|
||
const damage = calculateDamage(
|
||
monster.stats,
|
||
player.stats.armor,
|
||
multiplier * enrageMultiplier,
|
||
);
|
||
player.currentHp = Math.max(0, player.currentHp - damage);
|
||
events.push({
|
||
source: Combatant.MONSTER,
|
||
target: Combatant.PLAYER,
|
||
type: CombatEventType.DAMAGE,
|
||
amount: damage,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts
|
||
git commit -m "feat(api): let wounded monsters turn aggressive"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Expose guard and enrage through the combat API
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/combat/combat.service.ts`
|
||
- Test: `apps/api/src/combat/combat.service.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CombatEngineCombatantStats.activeGuard` / `.enraged` from Tasks 2 and 3.
|
||
- Produces: `CombatMonsterDto.guardRemainingRounds: number | null` and `CombatMonsterDto.enraged: boolean`.
|
||
|
||
Note: `combat.monsterState` is assigned straight from `result.state.monster.stats`,
|
||
so both fields already persist. Only the DTO needs work.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `apps/api/src/combat/combat.service.spec.ts`, in the describe block that
|
||
covers the combat DTO:
|
||
|
||
```ts
|
||
it('reports an active guard and an enraged monster to the client', async () => {
|
||
const { service, combat } = await startCombatFixture();
|
||
combat.monsterState = {
|
||
...combat.monsterState,
|
||
activeGuard: { remainingRounds: 2, armorBonus: 10 },
|
||
enraged: true,
|
||
};
|
||
|
||
const dto = await service.getCombat(CHARACTER_ID, combat.id);
|
||
|
||
expect(dto.monster.guardRemainingRounds).toBe(2);
|
||
expect(dto.monster.enraged).toBe(true);
|
||
});
|
||
|
||
it('reports no guard when the monster is not covering', async () => {
|
||
const { service, combat } = await startCombatFixture();
|
||
|
||
const dto = await service.getCombat(CHARACTER_ID, combat.id);
|
||
|
||
expect(dto.monster.guardRemainingRounds).toBeNull();
|
||
expect(dto.monster.enraged).toBe(false);
|
||
});
|
||
```
|
||
|
||
If the existing spec has no `startCombatFixture` helper, use whatever fixture
|
||
factory the file already provides to obtain a started combat and its `Combat`
|
||
row; the two assertions are the point, not the helper's name.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat.service`
|
||
Expected: FAIL — `Property 'guardRemainingRounds' does not exist on type 'CombatMonsterDto'`
|
||
|
||
- [ ] **Step 3: Extend the DTO**
|
||
|
||
In `apps/api/src/combat/combat.service.ts`, add to `CombatMonsterDto`:
|
||
|
||
```ts
|
||
/** Rounds the monster's raised guard still covers, or null when open. */
|
||
guardRemainingRounds: number | null;
|
||
enraged: boolean;
|
||
```
|
||
|
||
and in `toCombatDto`'s `monster` block, after `pendingIntent`:
|
||
|
||
```ts
|
||
guardRemainingRounds:
|
||
combat.monsterState.activeGuard?.remainingRounds ?? null,
|
||
enraged: combat.monsterState.enraged ?? false,
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- combat.service`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
|
||
git commit -m "feat(api): report monster guard and enrage state in the combat DTO"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `WorldDiscoveryService` and its entity
|
||
|
||
**Files:**
|
||
- Create: `apps/api/src/world/discovery/character-location-discovery.entity.ts`
|
||
- Create: `apps/api/src/world/discovery/world-discovery.service.ts`
|
||
- Create: `apps/api/src/world/discovery/world-discovery.module.ts`
|
||
- Test: `apps/api/src/world/discovery/world-discovery.service.spec.ts`
|
||
- Modify: `apps/api/src/world/entities/location-connection.entity.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LocationConnection`, `LocationDefinition`.
|
||
- Produces:
|
||
- `LocationConnection.requiresDiscovery: boolean`
|
||
- `class CharacterLocationDiscovery { id, characterId, locationId, discoveredAt }`
|
||
- `WorldDiscoveryService.getDiscoveredLocationIds(characterId): Promise<Set<string>>`
|
||
- `WorldDiscoveryService.discover(characterId, locationKey, manager?): Promise<DiscoveredLocation | null>` where `DiscoveredLocation = { key: string; name: string }`
|
||
- `WorldDiscoveryService.isTravelAllowed(characterId, connection, manager?): Promise<boolean>`
|
||
- `WorldDiscoveryModule` exporting the service
|
||
|
||
Its own module, not a provider inside `WorldModule`: `WorldModule` already
|
||
imports `TravelModule`, and `TravelService` needs this service too. A shared
|
||
leaf module is what keeps that from becoming a cycle.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `apps/api/src/world/discovery/world-discovery.service.spec.ts`:
|
||
|
||
```ts
|
||
import { DataSource, Repository } from 'typeorm';
|
||
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
|
||
import { LocationConnection } from '../entities/location-connection.entity';
|
||
import { LocationDefinition } from '../entities/location-definition.entity';
|
||
import { WorldDiscoveryService } from './world-discovery.service';
|
||
|
||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||
const WATCHPOST_ID = '20000000-0000-4000-8000-000000000003';
|
||
const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004';
|
||
|
||
interface InsertCall {
|
||
values: Record<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`:
|
||
|
||
```ts
|
||
import {
|
||
Column,
|
||
CreateDateColumn,
|
||
Entity,
|
||
Index,
|
||
JoinColumn,
|
||
ManyToOne,
|
||
PrimaryGeneratedColumn,
|
||
} from 'typeorm';
|
||
import { Character } from '../../characters/entities/character.entity';
|
||
import { LocationDefinition } from '../entities/location-definition.entity';
|
||
|
||
/**
|
||
* A place this character knows about (Playable Slice 0.10 §9).
|
||
*
|
||
* Player state, not content: which routes are gated at all lives on the
|
||
* connection. A row here is written once and never updated, so the unique
|
||
* pair is the whole concurrency story (AGENTS.md §30).
|
||
*/
|
||
@Entity({ name: 'character_location_discoveries' })
|
||
@Index(
|
||
'IDX_character_location_discoveries_pair',
|
||
['characterId', 'locationId'],
|
||
{ unique: true },
|
||
)
|
||
export class CharacterLocationDiscovery {
|
||
@PrimaryGeneratedColumn('uuid', { name: 'id' })
|
||
id!: string;
|
||
|
||
@Column({ name: 'character_id', type: 'uuid' })
|
||
characterId!: string;
|
||
|
||
@Column({ name: 'location_id', type: 'uuid' })
|
||
locationId!: string;
|
||
|
||
@CreateDateColumn({ name: 'discovered_at', type: 'timestamptz' })
|
||
discoveredAt!: Date;
|
||
|
||
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
|
||
@JoinColumn({ name: 'character_id' })
|
||
character!: Character;
|
||
|
||
@ManyToOne(() => LocationDefinition, { onDelete: 'CASCADE' })
|
||
@JoinColumn({ name: 'location_id' })
|
||
location!: LocationDefinition;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add the connection column**
|
||
|
||
In `apps/api/src/world/entities/location-connection.entity.ts`, after `enabled`:
|
||
|
||
```ts
|
||
/**
|
||
* When true this route only exists for a character who has discovered its
|
||
* target (Playable Slice 0.10 §9). Default false: every route that existed
|
||
* before this slice stays open.
|
||
*/
|
||
@Column({ name: 'requires_discovery', type: 'boolean', default: false })
|
||
requiresDiscovery!: boolean;
|
||
```
|
||
|
||
- [ ] **Step 5: Create the service**
|
||
|
||
Create `apps/api/src/world/discovery/world-discovery.service.ts`:
|
||
|
||
```ts
|
||
import { Injectable } from '@nestjs/common';
|
||
import { DataSource, EntityManager } from 'typeorm';
|
||
import { LocationConnection } from '../entities/location-connection.entity';
|
||
import { LocationDefinition } from '../entities/location-definition.entity';
|
||
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
|
||
|
||
export interface DiscoveredLocation {
|
||
key: string;
|
||
name: string;
|
||
}
|
||
|
||
/**
|
||
* Which places a character knows about, and whether a gated route is open to
|
||
* them yet (Playable Slice 0.10 §9).
|
||
*
|
||
* One service rather than a check inlined in `WorldService` and
|
||
* `TravelService`: the map must hide exactly what travel refuses, and two
|
||
* copies of that rule would drift the moment Slice 0.11 adds a second gate.
|
||
*/
|
||
@Injectable()
|
||
export class WorldDiscoveryService {
|
||
constructor(private readonly dataSource: DataSource) {}
|
||
|
||
async getDiscoveredLocationIds(
|
||
characterId: string,
|
||
manager?: EntityManager,
|
||
): Promise<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`:
|
||
|
||
```ts
|
||
import { Module } from '@nestjs/common';
|
||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||
import { LocationDefinition } from '../entities/location-definition.entity';
|
||
import { CharacterLocationDiscovery } from './character-location-discovery.entity';
|
||
import { WorldDiscoveryService } from './world-discovery.service';
|
||
|
||
/**
|
||
* A leaf module on purpose. `WorldModule` already imports `TravelModule`, and
|
||
* both need this service; giving it its own module is what keeps that from
|
||
* becoming a circular import.
|
||
*/
|
||
@Module({
|
||
imports: [
|
||
TypeOrmModule.forFeature([CharacterLocationDiscovery, LocationDefinition]),
|
||
],
|
||
providers: [WorldDiscoveryService],
|
||
exports: [WorldDiscoveryService],
|
||
})
|
||
export class WorldDiscoveryModule {}
|
||
```
|
||
|
||
- [ ] **Step 7: Run test to verify it passes**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- world-discovery.service`
|
||
Expected: PASS (8 tests)
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/world/discovery apps/api/src/world/entities/location-connection.entity.ts
|
||
git commit -m "feat(api): track which locations a character has discovered"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Enforce the gate in travel
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/travel/travel.service.ts`
|
||
- Modify: `apps/api/src/travel/travel.module.ts`
|
||
- Test: `apps/api/src/travel/travel.service.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `WorldDiscoveryService.isTravelAllowed` from Task 5.
|
||
- Produces: `TravelService` constructor gains a third parameter `worldDiscovery: WorldDiscoveryService`. Every existing test that constructs it directly must be updated.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `apps/api/src/travel/travel.service.spec.ts`:
|
||
|
||
```ts
|
||
it('refuses a gated route the character has not discovered', async () => {
|
||
const { service, state } = buildService();
|
||
state.connections[0].requiresDiscovery = true;
|
||
|
||
await expect(
|
||
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
|
||
).rejects.toBeInstanceOf(TravelDomainError);
|
||
expect(state.travels).toHaveLength(0);
|
||
});
|
||
|
||
it('allows a gated route once it has been discovered', async () => {
|
||
const { service, state } = buildService({ discovered: [BURNED_ROAD_ID] });
|
||
state.connections[0].requiresDiscovery = true;
|
||
|
||
await expect(
|
||
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
|
||
).resolves.toMatchObject({ status: TravelStatus.TRAVELLING });
|
||
});
|
||
```
|
||
|
||
Extend the file's existing `buildService` helper to accept
|
||
`{ discovered?: string[] }` and pass a stub as the new third constructor
|
||
argument:
|
||
|
||
```ts
|
||
const worldDiscovery = {
|
||
isTravelAllowed: (
|
||
_characterId: string,
|
||
connection: { toLocationId: string; requiresDiscovery: boolean },
|
||
) =>
|
||
Promise.resolve(
|
||
!connection.requiresDiscovery ||
|
||
(options.discovered ?? []).includes(connection.toLocationId),
|
||
),
|
||
} as unknown as WorldDiscoveryService;
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- travel.service`
|
||
Expected: FAIL — the gated route still starts a travel
|
||
|
||
- [ ] **Step 3: Inject the service and enforce the rule**
|
||
|
||
In `apps/api/src/travel/travel.service.ts`, add the import and the constructor
|
||
parameter:
|
||
|
||
```ts
|
||
import { WorldDiscoveryService } from '../world/discovery/world-discovery.service';
|
||
```
|
||
|
||
```ts
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
@Inject(CLOCK) private readonly clock: Clock,
|
||
private readonly worldDiscovery: WorldDiscoveryService,
|
||
) {}
|
||
```
|
||
|
||
In `startTravel`, immediately after the existing `if (!connection) { throw
|
||
invalidTravelTarget(); }` block:
|
||
|
||
```ts
|
||
// The map already hides an undiscovered route, but the map is not what
|
||
// decides. A gated target is refused here too, inside the same
|
||
// transaction that locks the character (AGENTS.md §5).
|
||
const allowed = await this.worldDiscovery.isTravelAllowed(
|
||
characterId,
|
||
connection,
|
||
manager,
|
||
);
|
||
if (!allowed) {
|
||
throw invalidTravelTarget();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Register the module**
|
||
|
||
In `apps/api/src/travel/travel.module.ts`, add `WorldDiscoveryModule` to
|
||
`imports`:
|
||
|
||
```ts
|
||
import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module';
|
||
```
|
||
|
||
```ts
|
||
imports: [
|
||
TypeOrmModule.forFeature([
|
||
Character,
|
||
LocationDefinition,
|
||
LocationConnection,
|
||
Travel,
|
||
]),
|
||
WorldDiscoveryModule,
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- travel`
|
||
Expected: PASS — including every pre-existing travel test
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/travel/travel.service.ts apps/api/src/travel/travel.module.ts apps/api/src/travel/travel.service.spec.ts
|
||
git commit -m "feat(api): refuse travel down an undiscovered route"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Hide the route on the map and discover it from a hotspot
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/world/local-location.types.ts`
|
||
- Modify: `apps/api/src/world/world.service.ts`
|
||
- Modify: `apps/api/src/world/world.module.ts`
|
||
- Test: `apps/api/src/world/world.service.spec.ts`
|
||
- Test: `apps/api/src/world/local-location-interaction.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `WorldDiscoveryService` from Task 5.
|
||
- Produces:
|
||
- `LocationPointOfInterestContent.discoversLocationKey?: string`
|
||
- `LocationInteractionResultDto.discoveredLocation: DiscoveredLocation | null`
|
||
- `WorldService` constructor gains `worldDiscovery: WorldDiscoveryService` as its final parameter.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `apps/api/src/world/world.service.spec.ts`:
|
||
|
||
```ts
|
||
it('hides a gated connection until the character has discovered it', async () => {
|
||
const { service } = buildService({
|
||
connections: [
|
||
{
|
||
fromLocationId: BURNED_ROAD_ID,
|
||
toLocationId: ASH_PIT_ID,
|
||
travelDurationSeconds: 20,
|
||
ambushChance: '0.1500',
|
||
enabled: true,
|
||
requiresDiscovery: true,
|
||
toLocation: ashPitLocation,
|
||
} as unknown as LocationConnection,
|
||
],
|
||
});
|
||
|
||
const location = await service.getCurrentLocation(CHARACTER_ID);
|
||
|
||
expect(location.connections).toHaveLength(0);
|
||
});
|
||
|
||
it('shows a gated connection once it has been discovered', async () => {
|
||
const { service } = buildService({
|
||
discovered: [ASH_PIT_ID],
|
||
connections: [
|
||
{
|
||
fromLocationId: BURNED_ROAD_ID,
|
||
toLocationId: ASH_PIT_ID,
|
||
travelDurationSeconds: 20,
|
||
ambushChance: '0.1500',
|
||
enabled: true,
|
||
requiresDiscovery: true,
|
||
toLocation: ashPitLocation,
|
||
} as unknown as LocationConnection,
|
||
],
|
||
});
|
||
|
||
const location = await service.getCurrentLocation(CHARACTER_ID);
|
||
|
||
expect(location.connections).toHaveLength(1);
|
||
expect(location.connections[0].targetLocation.key).toBe('ash-pit');
|
||
});
|
||
```
|
||
|
||
Add to `apps/api/src/world/local-location-interaction.spec.ts`:
|
||
|
||
```ts
|
||
it('discovers the route the hotspot points at', async () => {
|
||
const { service, discover } = buildService({
|
||
pointsOfInterest: [
|
||
{
|
||
key: 'inspect-watchpost',
|
||
title: 'The Watchpost',
|
||
actionLabel: 'Inspect',
|
||
type: 'INVESTIGATE',
|
||
iconKey: 'investigate',
|
||
xPercent: 50,
|
||
yPercent: 50,
|
||
enabled: true,
|
||
resultTitle: 'The Watchpost',
|
||
resultText: 'Fresh tracks lead east.',
|
||
discoversLocationKey: 'ash-pit',
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = await service.runLocalInteraction(
|
||
CHARACTER_ID,
|
||
'inspect-watchpost',
|
||
);
|
||
|
||
expect(discover).toHaveBeenCalledWith(CHARACTER_ID, 'ash-pit');
|
||
expect(result.discoveredLocation).toEqual({
|
||
key: 'ash-pit',
|
||
name: 'Ash Pit',
|
||
});
|
||
});
|
||
|
||
it('reports no discovery the second time the hotspot is used', async () => {
|
||
const { service } = buildService({
|
||
alreadyDiscovered: true,
|
||
pointsOfInterest: [
|
||
{
|
||
key: 'inspect-watchpost',
|
||
title: 'The Watchpost',
|
||
type: 'INVESTIGATE',
|
||
iconKey: 'investigate',
|
||
xPercent: 50,
|
||
yPercent: 50,
|
||
enabled: true,
|
||
resultText: 'Fresh tracks lead east.',
|
||
discoversLocationKey: 'ash-pit',
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = await service.runLocalInteraction(
|
||
CHARACTER_ID,
|
||
'inspect-watchpost',
|
||
);
|
||
|
||
expect(result.discoveredLocation).toBeNull();
|
||
expect(result.text).toBe('Fresh tracks lead east.');
|
||
});
|
||
|
||
it('reports no discovery for a hotspot that reveals nothing', async () => {
|
||
const { service, discover } = buildService({
|
||
pointsOfInterest: [
|
||
{
|
||
key: 'search-quarters',
|
||
title: 'Guard Quarters',
|
||
type: 'SEARCH',
|
||
iconKey: 'search',
|
||
xPercent: 20,
|
||
yPercent: 60,
|
||
enabled: true,
|
||
resultText: 'Nothing but ash.',
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = await service.runLocalInteraction(
|
||
CHARACTER_ID,
|
||
'search-quarters',
|
||
);
|
||
|
||
expect(discover).not.toHaveBeenCalled();
|
||
expect(result.discoveredLocation).toBeNull();
|
||
});
|
||
```
|
||
|
||
In both spec files, extend the existing service factory to build and pass a
|
||
`WorldDiscoveryService` stub as the final constructor argument:
|
||
|
||
```ts
|
||
const discover = jest.fn().mockResolvedValue(
|
||
options.alreadyDiscovered ? null : { key: 'ash-pit', name: 'Ash Pit' },
|
||
);
|
||
const worldDiscovery = {
|
||
discover,
|
||
isTravelAllowed: (
|
||
_characterId: string,
|
||
connection: { toLocationId: string; requiresDiscovery: boolean },
|
||
) =>
|
||
Promise.resolve(
|
||
!connection.requiresDiscovery ||
|
||
(options.discovered ?? []).includes(connection.toLocationId),
|
||
),
|
||
} as unknown as WorldDiscoveryService;
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- world`
|
||
Expected: FAIL — `Object literal may only specify known properties, and 'discoversLocationKey' does not exist`
|
||
|
||
- [ ] **Step 3: Extend the content and transport types**
|
||
|
||
In `apps/api/src/world/local-location.types.ts`, add to
|
||
`LocationPointOfInterestContent`:
|
||
|
||
```ts
|
||
/**
|
||
* Names a location this hotspot reveals (Playable Slice 0.10 §9). Setting it
|
||
* turns a read-only reveal into a piece of world progress, which is why the
|
||
* interaction endpoint writes as well as reads.
|
||
*/
|
||
discoversLocationKey?: string;
|
||
```
|
||
|
||
and to `LocationInteractionResultDto`:
|
||
|
||
```ts
|
||
/**
|
||
* Set only on the interaction that reveals a route for the first time, so
|
||
* the UI can say so once instead of on every repeat.
|
||
*/
|
||
discoveredLocation: { key: string; name: string } | null;
|
||
```
|
||
|
||
`toPointOfInterestDto` needs no change: `discoversLocationKey` is server-only,
|
||
exactly like `resultText`.
|
||
|
||
- [ ] **Step 4: Wire the service**
|
||
|
||
In `apps/api/src/world/world.service.ts`:
|
||
|
||
```ts
|
||
import { WorldDiscoveryService } from './discovery/world-discovery.service';
|
||
```
|
||
|
||
Add the constructor parameter after `locationMonsters`:
|
||
|
||
```ts
|
||
private readonly worldDiscovery: WorldDiscoveryService,
|
||
```
|
||
|
||
Replace the `connections` mapping in `getCurrentLocation` so the gate is applied
|
||
before the map ever sees the route. Load the connections, then:
|
||
|
||
```ts
|
||
const visibleConnections: LocationConnection[] = [];
|
||
for (const connection of connections) {
|
||
if (await this.worldDiscovery.isTravelAllowed(characterId, connection)) {
|
||
visibleConnections.push(connection);
|
||
}
|
||
}
|
||
```
|
||
|
||
and map `visibleConnections` instead of `connections.filter(...)` in the
|
||
response.
|
||
|
||
In `runLocalInteraction`, after the `poi` guard and before the return:
|
||
|
||
```ts
|
||
// A hotspot that reveals a route writes before it speaks. Idempotent by
|
||
// the unique pair, so a second click simply reports nothing new.
|
||
const discoveredLocation = poi.discoversLocationKey
|
||
? await this.worldDiscovery.discover(
|
||
characterId,
|
||
poi.discoversLocationKey,
|
||
)
|
||
: null;
|
||
```
|
||
|
||
and add `discoveredLocation,` to the returned object.
|
||
|
||
- [ ] **Step 5: Register the module**
|
||
|
||
In `apps/api/src/world/world.module.ts` add `WorldDiscoveryModule` to `imports`:
|
||
|
||
```ts
|
||
import { WorldDiscoveryModule } from './discovery/world-discovery.module';
|
||
```
|
||
|
||
```ts
|
||
TravelModule,
|
||
WorldDiscoveryModule,
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- world`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/world/local-location.types.ts apps/api/src/world/world.service.ts apps/api/src/world/world.module.ts apps/api/src/world/world.service.spec.ts apps/api/src/world/local-location-interaction.spec.ts
|
||
git commit -m "feat(api): reveal the ash pit route from the watchpost hotspot"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Trade goods, loot tables and exchange rules
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/database/seeds/item.constants.ts`
|
||
- Modify: `apps/api/src/database/seeds/item-content.ts`
|
||
- Modify: `apps/api/src/database/seeds/npc-content.ts`
|
||
- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `item()` and `entry()` helpers in `item-content.ts`, `SeedExchangeRule` in `npc-content.ts`.
|
||
- Produces: item keys `scorched-hide`, `raider-warband-mark`; loot-table constants `RAIDER_SCOUT_LOOT_TABLE_ID`, `BURNED_HOUND_LOOT_TABLE_ID`, `RAIDER_VETERAN_LOOT_TABLE_ID`, `RAIDER_CAPTAIN_LOOT_TABLE_ID`; four new `LOOT_TABLES` rows and their entries; two new `EXCHANGE_RULES`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`:
|
||
|
||
```ts
|
||
it('gives each new trade good the carrying category it belongs to', async () => {
|
||
const { repositories } = await runSeed();
|
||
const items = repositories.get(ItemDefinition)!.rows;
|
||
|
||
const hide = items.find((row) => row.key === 'scorched-hide');
|
||
const mark = items.find((row) => row.key === 'raider-warband-mark');
|
||
|
||
expect(hide?.lootCategory).toBe('HIDE');
|
||
expect(mark?.lootCategory).toBe('RAIDER_TROPHY');
|
||
});
|
||
|
||
it('lets Borin buy both watchpost trade goods', async () => {
|
||
const { repositories } = await runSeed();
|
||
const rules = repositories.get(ExchangeRule)!.rows;
|
||
|
||
const hideRule = rules.find(
|
||
(row) => row.inputItemId === ITEM_IDS['scorched-hide'],
|
||
);
|
||
const markRule = rules.find(
|
||
(row) => row.inputItemId === ITEM_IDS['raider-warband-mark'],
|
||
);
|
||
|
||
expect(hideRule).toMatchObject({ silverReward: 12, regionReputationReward: 4 });
|
||
expect(markRule).toMatchObject({ silverReward: 20, regionReputationReward: 7 });
|
||
});
|
||
|
||
it('pays more for watchpost goods than for road goods', async () => {
|
||
const { repositories } = await runSeed();
|
||
const rules = repositories.get(ExchangeRule)!.rows;
|
||
const silverFor = (key: keyof typeof ITEM_IDS) =>
|
||
rules.find((row) => row.inputItemId === ITEM_IDS[key])?.silverReward;
|
||
|
||
// The longer trip has to pay, or §10's loop has no pull (slice §6).
|
||
expect(silverFor('scorched-hide')).toBeGreaterThan(silverFor('tough-hide')!);
|
||
expect(silverFor('raider-warband-mark')).toBeGreaterThan(
|
||
silverFor('bandit-insignia')!,
|
||
);
|
||
});
|
||
|
||
it('guarantees the captain trophy and offers its focused drop', async () => {
|
||
const { repositories } = await runSeed();
|
||
const entries = repositories
|
||
.get(LootTableEntry)!
|
||
.rows.filter((row) => row.lootTableId === RAIDER_CAPTAIN_LOOT_TABLE_ID);
|
||
|
||
expect(
|
||
entries.find((row) => row.itemDefinitionId === ITEM_IDS['raider-warband-mark'])
|
||
?.dropChance,
|
||
).toBe('1.0000');
|
||
expect(
|
||
entries.find((row) => row.itemDefinitionId === ITEM_IDS['borderwatch-sigil'])
|
||
?.dropChance,
|
||
).toBe('0.2000');
|
||
});
|
||
|
||
it('leaves the road bandit loot table untouched', async () => {
|
||
const { repositories } = await runSeed();
|
||
const entries = repositories
|
||
.get(LootTableEntry)!
|
||
.rows.filter((row) => row.lootTableId === ROAD_BANDIT_LOOT_TABLE_ID);
|
||
|
||
// This slice adds a pool entry for the bandit, not a rebalance
|
||
// (AGENTS.md §39).
|
||
expect(entries).toHaveLength(4);
|
||
expect(
|
||
entries.find((row) => row.itemDefinitionId === ITEM_IDS['bandit-blade'])
|
||
?.dropChance,
|
||
).toBe('0.1800');
|
||
});
|
||
```
|
||
|
||
Use whatever `runSeed()` / repository-lookup helper the file already defines;
|
||
the assertions are the point.
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed`
|
||
Expected: FAIL — `'scorched-hide' is not assignable to ItemKey`
|
||
|
||
- [ ] **Step 3: Add the item and loot-table ids**
|
||
|
||
In `apps/api/src/database/seeds/item.constants.ts`, add to `ITEM_IDS` after
|
||
`'charred-raider-insignia'`:
|
||
|
||
```ts
|
||
'scorched-hide': '50000000-0000-4000-8000-000000000010',
|
||
'raider-warband-mark': '50000000-0000-4000-8000-000000000011',
|
||
```
|
||
|
||
and append the four table ids:
|
||
|
||
```ts
|
||
// Playable Slice 0.10 §6: one table per Watchpost enemy, same rule as the
|
||
// Burned Road -- each enemy owns its guaranteed trade good.
|
||
export const RAIDER_SCOUT_LOOT_TABLE_ID =
|
||
'60000000-0000-4000-8000-000000000005';
|
||
export const BURNED_HOUND_LOOT_TABLE_ID =
|
||
'60000000-0000-4000-8000-000000000006';
|
||
export const RAIDER_VETERAN_LOOT_TABLE_ID =
|
||
'60000000-0000-4000-8000-000000000007';
|
||
export const RAIDER_CAPTAIN_LOOT_TABLE_ID =
|
||
'60000000-0000-4000-8000-000000000008';
|
||
```
|
||
|
||
- [ ] **Step 4: Add the trade goods**
|
||
|
||
In `apps/api/src/database/seeds/item-content.ts`, import the four new table ids
|
||
and append to `ITEM_DEFINITIONS`:
|
||
|
||
```ts
|
||
// Watchpost trade goods (Playable Slice 0.10 §6). One per carrying
|
||
// category, so both the Hide Bag and the Trophy Pouch matter at this
|
||
// location and a player who owns neither runs into the capacity of 1.
|
||
item(
|
||
'scorched-hide',
|
||
'Scorched Hide',
|
||
'Hound hide burned hard as bark, the cracks in it still warm.',
|
||
ItemType.TRADE_GOOD,
|
||
null,
|
||
ItemRarity.COMMON,
|
||
{},
|
||
LootCategory.HIDE,
|
||
),
|
||
item(
|
||
'raider-warband-mark',
|
||
'Raider Warband Mark',
|
||
'A watchpost tally-token, re-stamped with the mark of the band that took it.',
|
||
ItemType.TROPHY,
|
||
null,
|
||
ItemRarity.COMMON,
|
||
{},
|
||
LootCategory.RAIDER_TROPHY,
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 5: Add the loot tables**
|
||
|
||
Append to `LOOT_TABLES` in the same file:
|
||
|
||
```ts
|
||
{
|
||
id: RAIDER_SCOUT_LOOT_TABLE_ID,
|
||
key: 'raider-scout-loot',
|
||
name: 'Raider Scout Loot',
|
||
},
|
||
{
|
||
id: BURNED_HOUND_LOOT_TABLE_ID,
|
||
key: 'burned-hound-loot',
|
||
name: 'Burned Hound Loot',
|
||
},
|
||
{
|
||
id: RAIDER_VETERAN_LOOT_TABLE_ID,
|
||
key: 'raider-veteran-loot',
|
||
name: 'Raider Veteran Loot',
|
||
},
|
||
{
|
||
id: RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
||
key: 'raider-captain-loot',
|
||
name: 'Raider Captain Loot',
|
||
},
|
||
```
|
||
|
||
and to `LOOT_TABLE_ENTRIES`:
|
||
|
||
```ts
|
||
// Watchpost (Playable Slice 0.10 §7). The Tier-1 pieces already exist in
|
||
// content; what this location changes is how often they show up. The
|
||
// Charred Captain's Pendant stays out -- it belongs to the Slice 0.11 boss,
|
||
// not to this elite.
|
||
entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.6000'),
|
||
entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-blade', 2, '0.2500'),
|
||
entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-hood', 3, '0.1800'),
|
||
entry(BURNED_HOUND_LOOT_TABLE_ID, 'scorched-hide', 1, '0.6000'),
|
||
entry(BURNED_HOUND_LOOT_TABLE_ID, 'ash-boots', 2, '0.1500'),
|
||
entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.7000'),
|
||
entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-gloves', 2, '0.1500'),
|
||
entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 3, '0.2000'),
|
||
entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'guardsman-legs', 4, '0.2200'),
|
||
entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '1.0000'),
|
||
entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.3000'),
|
||
entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'guardsman-legs', 3, '0.3000'),
|
||
entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.2000'),
|
||
```
|
||
|
||
- [ ] **Step 6: Add the exchange rules**
|
||
|
||
Append to `EXCHANGE_RULES` in `apps/api/src/database/seeds/npc-content.ts`:
|
||
|
||
```ts
|
||
// Watchpost goods (Playable Slice 0.10 §10). Priced above the road tier so
|
||
// the longer trip pays, and below the rare Charred Raider Insignia so the
|
||
// rare drop stays the best thing in the region.
|
||
{
|
||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||
inputItemId: ITEM_IDS['scorched-hide'],
|
||
inputQuantity: 1,
|
||
factionId: BORDER_GUARD_FACTION_ID,
|
||
silverReward: 12,
|
||
regionReputationReward: 4,
|
||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||
conditions: [],
|
||
sortOrder: 5,
|
||
enabled: true,
|
||
},
|
||
{
|
||
profileId: BORIN_EXCHANGE_PROFILE_ID,
|
||
inputItemId: ITEM_IDS['raider-warband-mark'],
|
||
inputQuantity: 1,
|
||
factionId: BORDER_GUARD_FACTION_ID,
|
||
silverReward: 20,
|
||
regionReputationReward: 7,
|
||
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
|
||
conditions: [],
|
||
sortOrder: 6,
|
||
enabled: true,
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/database/seeds/item.constants.ts apps/api/src/database/seeds/item-content.ts apps/api/src/database/seeds/npc-content.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts
|
||
git commit -m "feat(seed): add the watchpost trade goods and their loot tables"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Seed the locations, monsters and encounter pool
|
||
|
||
**Files:**
|
||
- Modify: `apps/api/src/database/seeds/vertical-slice.constants.ts`
|
||
- Modify: `apps/api/src/database/seeds/local-location.content.ts`
|
||
- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts`
|
||
- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: loot-table constants from Task 8, `MonsterAbilities` from Tasks 2–3, `LocalLocationContent` from `local-location.content.ts`.
|
||
- Produces: constants `ABANDONED_WATCHPOST_ID`, `ASH_PIT_ID`, `RAIDER_SCOUT_MONSTER_ID`, `BURNED_HOUND_MONSTER_ID`, `RAIDER_VETERAN_MONSTER_ID`, `RAIDER_CAPTAIN_MONSTER_ID`; exports `ABANDONED_WATCHPOST_LOCAL_CONTENT`, `ASH_PIT_LOCAL_CONTENT`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`:
|
||
|
||
```ts
|
||
it('seeds the watchpost as a huntable outpost', async () => {
|
||
const { repositories } = await runSeed();
|
||
const locations = repositories.get(LocationDefinition)!.rows;
|
||
|
||
const watchpost = locations.find(
|
||
(row) => row.key === 'abandoned-watchpost',
|
||
);
|
||
|
||
expect(watchpost).toMatchObject({
|
||
locationType: 'OUTPOST',
|
||
huntingEnabled: true,
|
||
isSafe: false,
|
||
regionKey: 'ashen-fields',
|
||
});
|
||
});
|
||
|
||
it('connects the burned road and the watchpost both ways without a gate', async () => {
|
||
const { repositories } = await runSeed();
|
||
const connections = repositories.get(LocationConnection)!.rows;
|
||
|
||
const outbound = connections.find(
|
||
(row) =>
|
||
row.fromLocationId === BURNED_ROAD_ID &&
|
||
row.toLocationId === ABANDONED_WATCHPOST_ID,
|
||
);
|
||
const inbound = connections.find(
|
||
(row) =>
|
||
row.fromLocationId === ABANDONED_WATCHPOST_ID &&
|
||
row.toLocationId === BURNED_ROAD_ID,
|
||
);
|
||
|
||
expect(outbound).toMatchObject({
|
||
travelDurationSeconds: 15,
|
||
ambushChance: '0.1000',
|
||
requiresDiscovery: false,
|
||
});
|
||
expect(inbound).toMatchObject({
|
||
travelDurationSeconds: 15,
|
||
requiresDiscovery: false,
|
||
});
|
||
});
|
||
|
||
it('gates the outbound ash pit route and leaves the way back open', async () => {
|
||
const { repositories } = await runSeed();
|
||
const connections = repositories.get(LocationConnection)!.rows;
|
||
|
||
const outbound = connections.find(
|
||
(row) =>
|
||
row.fromLocationId === ABANDONED_WATCHPOST_ID &&
|
||
row.toLocationId === ASH_PIT_ID,
|
||
);
|
||
const inbound = connections.find(
|
||
(row) =>
|
||
row.fromLocationId === ASH_PIT_ID &&
|
||
row.toLocationId === ABANDONED_WATCHPOST_ID,
|
||
);
|
||
|
||
expect(outbound?.requiresDiscovery).toBe(true);
|
||
// Whoever got there must always be able to leave.
|
||
expect(inbound?.requiresDiscovery).toBe(false);
|
||
});
|
||
|
||
it('points the watchpost investigation at the ash pit', async () => {
|
||
const { repositories } = await runSeed();
|
||
const watchpost = repositories
|
||
.get(LocationDefinition)!
|
||
.rows.find((row) => row.key === 'abandoned-watchpost');
|
||
|
||
const poi = (
|
||
watchpost?.localPointsOfInterest as Array<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`:
|
||
|
||
```ts
|
||
export const ABANDONED_WATCHPOST_ID = '20000000-0000-4000-8000-000000000003';
|
||
export const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004';
|
||
export const RAIDER_SCOUT_MONSTER_ID = '30000000-0000-4000-8000-000000000005';
|
||
export const BURNED_HOUND_MONSTER_ID = '30000000-0000-4000-8000-000000000006';
|
||
export const RAIDER_VETERAN_MONSTER_ID = '30000000-0000-4000-8000-000000000007';
|
||
export const RAIDER_CAPTAIN_MONSTER_ID = '30000000-0000-4000-8000-000000000008';
|
||
```
|
||
|
||
- [ ] **Step 4: Author the local content**
|
||
|
||
Append to `apps/api/src/database/seeds/local-location.content.ts`:
|
||
|
||
```ts
|
||
export const ABANDONED_WATCHPOST_LOCAL_CONTENT: LocalLocationContent = {
|
||
regionName: 'Ashen Fields',
|
||
regionTierLabel: 'Tier 1',
|
||
locationType: 'OUTPOST',
|
||
localDescription:
|
||
'A border tower the Watch gave up on. The palisade still stands, the gate does not, and someone has been sleeping here who was never posted here.',
|
||
localArtworkPath: '/images/backgrounds/Wachturm.png',
|
||
// Anchored to painted detail in `Wachturm.png`: the open ground before the
|
||
// tower, the tower base itself, the collapsed lean-to on the left, and the
|
||
// track leading off to the right.
|
||
localPointsOfInterest: [
|
||
{
|
||
key: 'hunt-area',
|
||
title: 'Hunting Ground',
|
||
actionLabel: 'Begin Hunt',
|
||
type: 'HUNT',
|
||
iconKey: 'hunt',
|
||
xPercent: 62,
|
||
yPercent: 38,
|
||
enabled: true,
|
||
},
|
||
{
|
||
key: 'inspect-watchpost',
|
||
title: 'The Watchpost',
|
||
actionLabel: 'Inspect',
|
||
type: 'INVESTIGATE',
|
||
iconKey: 'investigate',
|
||
xPercent: 44,
|
||
yPercent: 46,
|
||
enabled: true,
|
||
resultTitle: 'The Watchpost',
|
||
// Quoted from Playable Slice 0.10 §8.
|
||
resultText:
|
||
"The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation.",
|
||
discoversLocationKey: 'ash-pit',
|
||
},
|
||
{
|
||
key: 'search-guard-quarters',
|
||
title: "Guards' Quarters",
|
||
actionLabel: 'Search',
|
||
type: 'SEARCH',
|
||
iconKey: 'search',
|
||
xPercent: 18,
|
||
yPercent: 68,
|
||
enabled: true,
|
||
resultTitle: "Guards' Quarters",
|
||
resultText:
|
||
'Straw, a cold hearth, and a duty roster with every name scratched out but one. Nothing here is worth carrying.',
|
||
},
|
||
{
|
||
key: 'east-road',
|
||
title: 'Track East',
|
||
actionLabel: 'To Map',
|
||
type: 'MAP',
|
||
iconKey: 'map',
|
||
xPercent: 88,
|
||
yPercent: 74,
|
||
enabled: true,
|
||
},
|
||
],
|
||
localPrimaryActions: [
|
||
{
|
||
key: 'start-hunt',
|
||
label: 'Begin Hunt',
|
||
description: 'Hunt in this area',
|
||
type: 'HUNT',
|
||
iconKey: 'hunt',
|
||
enabled: true,
|
||
},
|
||
{
|
||
key: 'inspect-watchpost',
|
||
label: 'Inspect the watchpost',
|
||
description: 'Find clues',
|
||
type: 'INVESTIGATE',
|
||
iconKey: 'investigate',
|
||
enabled: true,
|
||
poiKey: 'inspect-watchpost',
|
||
},
|
||
{
|
||
key: 'search-quarters',
|
||
label: "Search the quarters",
|
||
description: 'Find loot',
|
||
type: 'SEARCH',
|
||
iconKey: 'search',
|
||
enabled: true,
|
||
poiKey: 'search-guard-quarters',
|
||
},
|
||
{
|
||
key: 'open-map',
|
||
label: 'To Map',
|
||
description: 'Change area',
|
||
type: 'MAP',
|
||
iconKey: 'map',
|
||
enabled: true,
|
||
},
|
||
],
|
||
// Same rule as the Burned Road: only categories the loot tables here
|
||
// actually back, and no Silver or experience, because a normal kill grants
|
||
// neither (slice §6).
|
||
localRewardPreview: [
|
||
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
|
||
{ key: 'material', label: 'Trade Goods', iconKey: 'material' },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* A stub (Playable Slice 0.10 §9, design decision D2).
|
||
*
|
||
* The discovered route needs a real destination for the gate to mean anything,
|
||
* so the Ash Pit exists as a place you can stand -- and nothing more. Slice
|
||
* 0.11 gives it an encounter pool, an elite and its own hotspots.
|
||
*/
|
||
export const ASH_PIT_LOCAL_CONTENT: LocalLocationContent = {
|
||
regionName: 'Ashen Fields',
|
||
regionTierLabel: 'Tier 1',
|
||
locationType: 'TRANSITION',
|
||
localDescription:
|
||
'The old ash excavation drops away in terraces, grey on grey. Something down there is still being worked.',
|
||
localArtworkPath: '/images/backgrounds/Aschengrube.png',
|
||
localPointsOfInterest: [
|
||
{
|
||
key: 'pit-rim',
|
||
title: 'Back Along the Track',
|
||
actionLabel: 'To Map',
|
||
type: 'MAP',
|
||
iconKey: 'map',
|
||
xPercent: 20,
|
||
yPercent: 76,
|
||
enabled: true,
|
||
},
|
||
],
|
||
localPrimaryActions: [
|
||
{
|
||
key: 'open-map',
|
||
label: 'To Map',
|
||
description: 'Change area',
|
||
type: 'MAP',
|
||
iconKey: 'map',
|
||
enabled: true,
|
||
},
|
||
],
|
||
localRewardPreview: [],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 5: Seed the locations and connections**
|
||
|
||
In `apps/api/src/database/seeds/vertical-slice.seed.ts`, extend the imports and
|
||
append two entries to the `locations` array:
|
||
|
||
```ts
|
||
{
|
||
id: ABANDONED_WATCHPOST_ID,
|
||
key: 'abandoned-watchpost',
|
||
name: 'Abandoned Watchpost',
|
||
description:
|
||
'A border tower the Watch gave up on. Whoever holds it now is watching the road, not guarding it.',
|
||
regionKey: 'ashen-fields',
|
||
minRecommendedLevel: 2,
|
||
maxRecommendedLevel: 3,
|
||
dangerLevel: 2,
|
||
isSafe: false,
|
||
huntingEnabled: true,
|
||
artworkPath: '/images/backgrounds/Wachturm.png',
|
||
...ABANDONED_WATCHPOST_LOCAL_CONTENT,
|
||
},
|
||
{
|
||
id: ASH_PIT_ID,
|
||
key: 'ash-pit',
|
||
name: 'Ash Pit',
|
||
description:
|
||
'The old ash excavation east of the watchpost, cut in terraces and still being worked by someone.',
|
||
regionKey: 'ashen-fields',
|
||
minRecommendedLevel: 3,
|
||
maxRecommendedLevel: 4,
|
||
dangerLevel: 3,
|
||
isSafe: false,
|
||
huntingEnabled: false,
|
||
artworkPath: '/images/backgrounds/Aschengrube.png',
|
||
...ASH_PIT_LOCAL_CONTENT,
|
||
},
|
||
```
|
||
|
||
Resolve the ids after the loop, beside the existing two:
|
||
|
||
```ts
|
||
const watchpostId =
|
||
locationIds.get('abandoned-watchpost') ?? ABANDONED_WATCHPOST_ID;
|
||
const ashPitId = locationIds.get('ash-pit') ?? ASH_PIT_ID;
|
||
```
|
||
|
||
Add four rows to the existing `connectionRepository.upsert` array. Every
|
||
pre-existing row needs `requiresDiscovery: false` added explicitly so the upsert
|
||
writes the column on a re-seed:
|
||
|
||
```ts
|
||
{
|
||
fromLocationId: burnedRoadId,
|
||
toLocationId: watchpostId,
|
||
travelDurationSeconds: 15,
|
||
ambushChance: '0.1000',
|
||
enabled: true,
|
||
requiresDiscovery: false,
|
||
},
|
||
{
|
||
fromLocationId: watchpostId,
|
||
toLocationId: burnedRoadId,
|
||
travelDurationSeconds: 15,
|
||
ambushChance: '0.1000',
|
||
enabled: true,
|
||
requiresDiscovery: false,
|
||
},
|
||
// The one gated route in the game (slice §9). Discovery, not a level,
|
||
// is what opens it -- and the way back is never gated, so a character
|
||
// who walked in can always walk out.
|
||
{
|
||
fromLocationId: watchpostId,
|
||
toLocationId: ashPitId,
|
||
travelDurationSeconds: 20,
|
||
ambushChance: '0.1500',
|
||
enabled: true,
|
||
requiresDiscovery: true,
|
||
},
|
||
{
|
||
fromLocationId: ashPitId,
|
||
toLocationId: watchpostId,
|
||
travelDurationSeconds: 20,
|
||
ambushChance: '0.1500',
|
||
enabled: true,
|
||
requiresDiscovery: false,
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 6: Seed the monsters**
|
||
|
||
Append to the `monsters` array in the same file:
|
||
|
||
```ts
|
||
{
|
||
id: RAIDER_SCOUT_MONSTER_ID,
|
||
key: 'raider-scout',
|
||
name: 'Raider Scout',
|
||
monsterCategory: MonsterCategory.HUMANOID,
|
||
level: 2,
|
||
maxHp: 70,
|
||
attack: 10,
|
||
armor: 3,
|
||
flavorText:
|
||
'Light on their feet and already backing away from the fight they started.',
|
||
// The farming target: no mechanic at all, so the pool has somewhere
|
||
// for a player to breathe between the harder fights (slice §5).
|
||
abilities: {},
|
||
artworkPath: '/images/monsters/raider-scout.png',
|
||
iconPath: '/images/combat/icons/raider-scout-128.png',
|
||
lootTableId: RAIDER_SCOUT_LOOT_TABLE_ID,
|
||
},
|
||
{
|
||
id: BURNED_HOUND_MONSTER_ID,
|
||
key: 'burned-hound',
|
||
name: 'Burned Hound',
|
||
monsterCategory: MonsterCategory.BEAST,
|
||
level: 3,
|
||
maxHp: 80,
|
||
attack: 12,
|
||
armor: 2,
|
||
flavorText:
|
||
'The fire took its coat and left the cracks glowing underneath.',
|
||
// Bleeding on a tighter cadence than the road hound, plus a rage that
|
||
// punishes a player who lets the fight run long (slice §5).
|
||
abilities: {
|
||
bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 },
|
||
enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 },
|
||
},
|
||
artworkPath: '/images/monsters/burned-hound.png',
|
||
iconPath: '/images/combat/icons/burned-hound-128.png',
|
||
lootTableId: BURNED_HOUND_LOOT_TABLE_ID,
|
||
},
|
||
{
|
||
id: RAIDER_VETERAN_MONSTER_ID,
|
||
key: 'raider-veteran',
|
||
name: 'Raider Veteran',
|
||
monsterCategory: MonsterCategory.HUMANOID,
|
||
level: 3,
|
||
maxHp: 120,
|
||
attack: 14,
|
||
armor: 10,
|
||
flavorText:
|
||
'Plated, patient, and entirely willing to wait behind their guard.',
|
||
// The fight this location is built around: the telegraph the player
|
||
// already knows, plus a guard that answers to the same Shield Bash.
|
||
// Intervals 3 and 4 so the two only collide every twelfth round.
|
||
abilities: {
|
||
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
|
||
guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 },
|
||
},
|
||
artworkPath: '/images/monsters/raider-veteran.png',
|
||
iconPath: '/images/combat/icons/raider-veteran-128.png',
|
||
lootTableId: RAIDER_VETERAN_LOOT_TABLE_ID,
|
||
},
|
||
{
|
||
id: RAIDER_CAPTAIN_MONSTER_ID,
|
||
key: 'raider-captain',
|
||
name: 'Raider Captain',
|
||
monsterCategory: MonsterCategory.HUMANOID,
|
||
level: 4,
|
||
maxHp: 160,
|
||
attack: 17,
|
||
armor: 12,
|
||
flavorText:
|
||
'Whoever gave the order to watch this road is standing in front of you.',
|
||
// The elite (slice §5): the veteran's two mechanics on tighter
|
||
// cadences and better stats, not a third subsystem. Deliberately not
|
||
// the Captain of the Ashen Band -- that boss belongs to Slice 0.11.
|
||
abilities: {
|
||
telegraph: { roundInterval: 2, damageMultiplier: 1.7 },
|
||
guard: { roundInterval: 3, armorBonus: 12, durationRounds: 2 },
|
||
},
|
||
artworkPath: '/images/monsters/raider-captain.png',
|
||
iconPath: '/images/combat/icons/raider-captain-128.png',
|
||
lootTableId: RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 7: Seed the encounter pool**
|
||
|
||
After the existing Burned Road `locationMonsterRepository.upsert`, add:
|
||
|
||
```ts
|
||
// Watchpost roster (slice §4). The Road Bandit is reused deliberately: it
|
||
// bridges the two locations and keeps the Raider Insignia economy
|
||
// connected. Its definition and loot table are untouched.
|
||
const watchpostPool: ReadonlyArray<{
|
||
key: string;
|
||
weight: number;
|
||
encounterType: EncounterType;
|
||
}> = [
|
||
{ key: 'raider-scout', weight: 35, encounterType: EncounterType.NORMAL },
|
||
{ key: 'burned-hound', weight: 28, encounterType: EncounterType.NORMAL },
|
||
{ key: 'road-bandit', weight: 20, encounterType: EncounterType.NORMAL },
|
||
{ key: 'raider-veteran', weight: 14, encounterType: EncounterType.NORMAL },
|
||
{ key: 'raider-captain', weight: 3, encounterType: EncounterType.RARE },
|
||
];
|
||
|
||
await locationMonsterRepository.upsert(
|
||
watchpostPool.map(({ key, weight, encounterType }) => ({
|
||
locationId: watchpostId,
|
||
monsterId: monsterIds.get(key) as string,
|
||
weight,
|
||
encounterType,
|
||
enabled: true,
|
||
})),
|
||
['locationId', 'monsterId'],
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 9: Run the whole API suite**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/api`
|
||
Expected: PASS — no regressions anywhere
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add apps/api/src/database/seeds/vertical-slice.constants.ts apps/api/src/database/seeds/local-location.content.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts
|
||
git commit -m "feat(seed): open the abandoned watchpost and the route beyond it"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Derive the web assets for the new content
|
||
|
||
**Files:**
|
||
- Create: `tools/derive-monster-assets.ps1`
|
||
- Create (generated, committed): `apps/web/public/images/monsters/{raider-scout,raider-veteran,burned-hound,raider-captain}.png`, their `runtime/*-560.jpg`, `apps/web/public/images/combat/sprites/*.png`, `apps/web/public/images/combat/icons/*-128.png`
|
||
- Create (copied): `apps/web/public/images/backgrounds/{Wachturm.png,Aschengrube.png}` and `backgrounds/runtime/{Wachturm-960.jpg,Aschengrube-960.jpg}`
|
||
- Modify: `apps/web/src/app/shared/monster-artwork.ts`
|
||
- Test: `apps/web/src/app/shared/monster-artwork.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: art committed in `2435d25`.
|
||
- Produces: the four asset paths per monster key, registered in `monster-artwork.ts`.
|
||
|
||
Note the deliberate crossing (design D7): the file named `raider-scout.png`
|
||
depicts the heavier plated figure and becomes the **Veteran**; `raider-veteran.png`
|
||
depicts the leaner one and becomes the **Scout**.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `apps/web/src/app/shared/monster-artwork.spec.ts`:
|
||
|
||
```ts
|
||
describe('watchpost monsters', () => {
|
||
const keys = ['raider-scout', 'raider-veteran', 'burned-hound', 'raider-captain'];
|
||
|
||
it('has a cutout for every watchpost monster', () => {
|
||
for (const key of keys) {
|
||
expect(monsterCutoutPath(key)).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('has an icon for every watchpost monster', () => {
|
||
for (const key of keys) {
|
||
expect(monsterIconPath(key)).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('has a runtime derivative for every watchpost artwork', () => {
|
||
for (const key of keys) {
|
||
expect(runtimeMonsterArtworkPath(`/images/monsters/${key}.png`)).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('scales the captain larger than the hound', () => {
|
||
// A hulking elite and a low-slung dog must not share a silhouette height.
|
||
expect(combatMonsterSpriteScale('raider-captain')).toBeGreaterThan(
|
||
combatMonsterSpriteScale('burned-hound'),
|
||
);
|
||
});
|
||
});
|
||
```
|
||
|
||
Add `combatMonsterSpriteScale` to the file's imports.
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: FAIL — `monsterCutoutPath('raider-scout')` is `undefined`
|
||
|
||
- [ ] **Step 3: Write the derivation script**
|
||
|
||
Create `tools/derive-monster-assets.ps1`:
|
||
|
||
```powershell
|
||
# tools/derive-monster-assets.ps1
|
||
# Derives the web assets each monster needs from the hand-made art in
|
||
# art/enemies and art/backgrounds.
|
||
#
|
||
# Per monster it writes four files:
|
||
# apps/web/public/images/monsters/<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:
|
||
|
||
```ts
|
||
'/images/monsters/raider-scout.png': '/images/monsters/runtime/raider-scout-560.jpg',
|
||
'/images/monsters/raider-veteran.png': '/images/monsters/runtime/raider-veteran-560.jpg',
|
||
'/images/monsters/burned-hound.png': '/images/monsters/runtime/burned-hound-560.jpg',
|
||
'/images/monsters/raider-captain.png': '/images/monsters/runtime/raider-captain-560.jpg',
|
||
```
|
||
|
||
```ts
|
||
'raider-scout': '/images/combat/sprites/raider-scout-620.png',
|
||
'raider-veteran': '/images/combat/sprites/raider-veteran-620.png',
|
||
'burned-hound': '/images/combat/sprites/burned-hound-760.png',
|
||
'raider-captain': '/images/combat/sprites/raider-captain-620.png',
|
||
```
|
||
|
||
```ts
|
||
'raider-scout': '/images/combat/icons/raider-scout-128.png',
|
||
'raider-veteran': '/images/combat/icons/raider-veteran-128.png',
|
||
'burned-hound': '/images/combat/icons/burned-hound-128.png',
|
||
'raider-captain': '/images/combat/icons/raider-captain-128.png',
|
||
```
|
||
|
||
```ts
|
||
'raider-scout': 0.78,
|
||
'raider-veteran': 0.84,
|
||
'burned-hound': 0.6,
|
||
'raider-captain': 0.9,
|
||
```
|
||
|
||
Also register the two new backgrounds in
|
||
`apps/web/src/app/features/world/location-page/location-page.component.ts`:
|
||
|
||
```ts
|
||
'/images/backgrounds/Wachturm.png': '/images/backgrounds/runtime/Wachturm-960.jpg',
|
||
'/images/backgrounds/Aschengrube.png': '/images/backgrounds/runtime/Aschengrube-960.jpg',
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tools/derive-monster-assets.ps1 apps/web/public/images apps/web/src/app/shared/monster-artwork.ts apps/web/src/app/shared/monster-artwork.spec.ts apps/web/src/app/features/world/location-page/location-page.component.ts
|
||
git commit -m "feat(web): derive and register the watchpost artwork"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Show guard and enrage in combat
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/app/core/api/game-api.models.ts`
|
||
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`
|
||
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html`
|
||
- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`
|
||
- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CombatMonsterDto.guardRemainingRounds` / `.enraged` from Task 4; event types from Tasks 2–3.
|
||
- Produces: `CombatMonster.guardRemainingRounds: number | null`, `CombatMonster.enraged: boolean`, `CombatEventType` union extended with `'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`:
|
||
|
||
The file already has an `activeCombat` fixture object and an
|
||
`async function setup(combat)` that returns the `ComponentFixture`. It runs on
|
||
**vitest** (`vi.fn`), not Jasmine. First add the two new fields to
|
||
`activeCombat.monster` so the existing tests still compile:
|
||
|
||
```ts
|
||
pendingIntent: null,
|
||
guardRemainingRounds: null,
|
||
enraged: false,
|
||
```
|
||
|
||
Then add the tests inside the existing `describe`:
|
||
|
||
```ts
|
||
const veteran: Combat = {
|
||
...activeCombat,
|
||
monster: { ...activeCombat.monster, name: 'Raider Veteran' },
|
||
};
|
||
|
||
it('announces a raised guard with the rounds it still covers', async () => {
|
||
const fixture = await setup({
|
||
...veteran,
|
||
monster: { ...veteran.monster, guardRemainingRounds: 2 },
|
||
});
|
||
|
||
const banner: HTMLElement | null = fixture.nativeElement.querySelector(
|
||
'[data-combat-guard]',
|
||
);
|
||
expect(banner?.textContent).toContain('Raider Veteran');
|
||
expect(banner?.textContent).toContain('2');
|
||
});
|
||
|
||
it('says nothing about a guard when the monster is open', async () => {
|
||
const fixture = await setup(activeCombat);
|
||
|
||
expect(
|
||
fixture.nativeElement.querySelector('[data-combat-guard]'),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('marks an enraged monster', async () => {
|
||
const fixture = await setup({
|
||
...activeCombat,
|
||
monster: { ...activeCombat.monster, enraged: true },
|
||
});
|
||
|
||
expect(
|
||
fixture.nativeElement.querySelector('[data-combat-enraged]'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('reads the new events back in the log', async () => {
|
||
const fixture = await setup({
|
||
...veteran,
|
||
events: [
|
||
{ round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 },
|
||
{ round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' },
|
||
{ round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' },
|
||
],
|
||
});
|
||
|
||
const log = (
|
||
fixture.nativeElement as HTMLElement
|
||
).querySelector('.combat__log')?.textContent;
|
||
expect(log).toContain('Raider Veteran raises its guard.');
|
||
expect(log).toContain('Raider Veteran turns savage.');
|
||
expect(log).toContain("Raider Veteran's guard drops.");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: FAIL — `'guardRemainingRounds' does not exist in type 'CombatMonster'`
|
||
|
||
- [ ] **Step 3: Extend the client models**
|
||
|
||
In `apps/web/src/app/core/api/game-api.models.ts`, add to `CombatMonster`:
|
||
|
||
```ts
|
||
/** Rounds the monster's raised guard still covers, or null when open. */
|
||
guardRemainingRounds: number | null;
|
||
enraged: boolean;
|
||
```
|
||
|
||
and add `| 'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'` to the `CombatEventType`
|
||
union.
|
||
|
||
Both fields are required, matching the API DTO. Four spec files build a
|
||
`CombatMonster` literal and will stop compiling until each gets
|
||
`guardRemainingRounds: null, enraged: false` added to its fixture:
|
||
|
||
```text
|
||
apps/web/src/app/core/resume-combat.spec.ts
|
||
apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
|
||
apps/web/src/app/features/combat/combat.store.spec.ts
|
||
apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
|
||
```
|
||
|
||
- [ ] **Step 4: Add the component logic**
|
||
|
||
In `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`, add
|
||
two accessors next to `monsterIntentLabel`:
|
||
|
||
```ts
|
||
protected monsterGuardLabel(): string | null {
|
||
const combat = this.displayed();
|
||
const rounds = combat?.monster.guardRemainingRounds;
|
||
if (!combat || !rounds) {
|
||
return null;
|
||
}
|
||
const roundWord = rounds === 1 ? 'round' : 'rounds';
|
||
return `${combat.monster.name} is covering — ${rounds} ${roundWord}. Shield Bash breaks it.`;
|
||
}
|
||
|
||
protected monsterIsEnraged(): boolean {
|
||
return this.displayed()?.monster.enraged ?? false;
|
||
}
|
||
```
|
||
|
||
and add three branches to the log formatter, beside the `TELEGRAPH` branch:
|
||
|
||
```ts
|
||
if (event.type === 'GUARD_RAISED') {
|
||
return `${monsterName} raises its guard.`;
|
||
}
|
||
|
||
if (event.type === 'GUARD_ENDED') {
|
||
return `${monsterName}'s guard drops.`;
|
||
}
|
||
|
||
if (event.type === 'ENRAGED') {
|
||
return `${monsterName} turns savage.`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add the markup**
|
||
|
||
In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`,
|
||
below the existing telegraph paragraph:
|
||
|
||
```html
|
||
@if (monsterGuardLabel(); as guard) {
|
||
<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**
|
||
|
||
```bash
|
||
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/combat/combat-page
|
||
git commit -m "feat(web): show monster guard and rage on the combat screen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Announce the discovered route in the interaction panel
|
||
|
||
**Files:**
|
||
- Modify: `apps/web/src/app/core/api/game-api.models.ts`
|
||
- Modify: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts`
|
||
- Modify: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html`
|
||
- Modify: `apps/web/src/app/features/world/local-location.store.ts`
|
||
- Test: `apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LocationInteractionResultDto.discoveredLocation` from Task 7.
|
||
- Produces: `LocationInteractionResult.discoveredLocation: { key: string; name: string } | null` in the client model.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to
|
||
`apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts`:
|
||
|
||
The file has an `async function setup(inputs)` returning `{ fixture, closed,
|
||
element }`, and its `result` parameter is typed inline as
|
||
`{ interactionKey: string; title: string; text: string; img?: string }`. Add
|
||
`discoveredLocation?: { key: string; name: string } | null;` to that inline type,
|
||
then add the tests inside the existing `describe`:
|
||
|
||
```ts
|
||
it('says so when the interaction revealed a route', async () => {
|
||
const { element } = await setup({
|
||
result: {
|
||
interactionKey: 'inspect-watchpost',
|
||
title: 'The Watchpost',
|
||
text: 'Fresh tracks lead east.',
|
||
discoveredLocation: { key: 'ash-pit', name: 'Ash Pit' },
|
||
},
|
||
});
|
||
|
||
const banner = element.querySelector('[data-discovered-location]');
|
||
expect(banner?.textContent).toContain('Ash Pit');
|
||
});
|
||
|
||
it('stays quiet when nothing new was revealed', async () => {
|
||
const { element } = await setup({
|
||
result: {
|
||
interactionKey: 'search-guard-quarters',
|
||
title: 'Guard Quarters',
|
||
text: 'Nothing but ash.',
|
||
discoveredLocation: null,
|
||
},
|
||
});
|
||
|
||
expect(element.querySelector('[data-discovered-location]')).toBeNull();
|
||
});
|
||
```
|
||
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: FAIL — `'discoveredLocation' does not exist in type 'LocationInteractionResult'`
|
||
|
||
- [ ] **Step 3: Extend the client model**
|
||
|
||
In `apps/web/src/app/core/api/game-api.models.ts`, add to
|
||
`LocationInteractionResult`:
|
||
|
||
```ts
|
||
/**
|
||
* Set only the first time an interaction reveals a route. Optional on the
|
||
* client although the API always sends it: every existing test fixture
|
||
* builds this object literally, and a required field would break them all
|
||
* for no gain.
|
||
*/
|
||
discoveredLocation?: { key: string; name: string } | null;
|
||
```
|
||
|
||
- [ ] **Step 4: Render the banner**
|
||
|
||
In
|
||
`apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html`,
|
||
below the result text:
|
||
|
||
The panel uses a classic `@Input() result: LocationInteractionResult | null`,
|
||
not a signal input, so the template reads the property directly:
|
||
|
||
```html
|
||
@if (result?.discoveredLocation; as discovered) {
|
||
<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:
|
||
|
||
```ts
|
||
if (result.discoveredLocation) {
|
||
// The connection list is server-filtered, so a fresh reveal only shows
|
||
// up after the location is re-read.
|
||
await this.loadCurrentLocation();
|
||
}
|
||
```
|
||
|
||
Use whatever the store's existing reload method is called.
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 7: Run the whole web suite**
|
||
|
||
Run: `npm test --workspace=@ashen-realms/web`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/world
|
||
git commit -m "feat(web): announce a newly discovered route"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Verify end-to-end, build, and document
|
||
|
||
**Files:**
|
||
- Create: `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md`
|
||
- Modify: `docs/playable-slices/0.10-Abandoned-Watchpost.md` (tick the acceptance criteria)
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything above.
|
||
- Produces: a record of what was built and where it deviates from the slice document.
|
||
|
||
- [ ] **Step 1: Run the migration and seed against a real database**
|
||
|
||
```bash
|
||
npm run db:migrate
|
||
npm run db:seed
|
||
npm run db:seed
|
||
```
|
||
|
||
Expected: the migration applies cleanly; the seed runs twice with no duplicate-key
|
||
error and no duplicated rows (AGENTS §8).
|
||
|
||
- [ ] **Step 2: Walk the loop in the browser**
|
||
|
||
Start the app (`npm run dev:api` and `npm run dev:web`) and confirm by hand:
|
||
|
||
1. The Burned Road shows a route to the Abandoned Watchpost; travelling takes ~15 s.
|
||
2. The Watchpost map shows **no** Ash Pit route.
|
||
3. Inspecting the watchpost reveals the §8 clue and announces the new route.
|
||
4. The Ash Pit route now appears and can be travelled.
|
||
5. A hunt at the Watchpost only offers the five monsters from its own pool.
|
||
6. A Raider Veteran fight shows the guard banner; Shield Bash breaks it.
|
||
7. A Burned Hound below 35 % HP shows the enrage banner and hits harder.
|
||
8. Scorched Hide and Raider Warband Mark both drop and both sell to Borin.
|
||
|
||
- [ ] **Step 3: Run lint and build**
|
||
|
||
```bash
|
||
npm run lint --workspace=@ashen-realms/api
|
||
npm run build
|
||
```
|
||
|
||
Expected: both pass. Fix anything they surface before continuing.
|
||
|
||
- [ ] **Step 4: Run the full test suite**
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
Expected: PASS across both workspaces.
|
||
|
||
- [ ] **Step 5: Write the implementation notes**
|
||
|
||
Create `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md`
|
||
following the shape of the Slice 0.9 notes. Record at minimum:
|
||
|
||
- the discovery model (table, connection column, service) and why the gate is
|
||
enforced in two places
|
||
- `guard` and `enrage` as content abilities, with their exact configuration and
|
||
the telegraph-wins priority rule
|
||
- the Ash Pit stub and what Slice 0.11 is expected to add to it
|
||
- deviations from the slice document: no surviving-guard NPC (§3, no portrait
|
||
art exists — the investigation is a hotspot instead), and the crossed raider
|
||
artwork files (design D7)
|
||
- the new `tools/derive-monster-assets.ps1` and when it needs re-running
|
||
|
||
- [ ] **Step 6: Tick the acceptance criteria**
|
||
|
||
In `docs/playable-slices/0.10-Abandoned-Watchpost.md` §12, change each `- [ ]`
|
||
to `- [x]`. If any criterion is genuinely unmet, leave it unticked and say why
|
||
in the implementation notes rather than ticking it anyway.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add docs/playable-slices
|
||
git commit -m "docs: record the Slice 0.10 watchpost implementation and its deviations"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
Spec coverage check, section by section:
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| §3 Discovery data + service | 1, 5 |
|
||
| §3.3 Two call sites | 6, 7 |
|
||
| §3.4 POI discovery | 7, 9 |
|
||
| §4.1 Locations | 9 |
|
||
| §4.2 Connections | 9 |
|
||
| §4.3 Local view | 9 |
|
||
| §5 Encounter pool | 9 |
|
||
| §6.1 Trade goods + exchange | 8 |
|
||
| §6.2 Loot tables | 8 |
|
||
| §7.1–7.2 Guard | 2 |
|
||
| §7.3 Enrage | 3 |
|
||
| §7.4 Contract changes | 1, 2, 3, 4 |
|
||
| §8 Frontend | 11, 12 |
|
||
| §9 Artwork | 10 |
|
||
| §10 Tests | woven through every task |
|
||
| §11 Out of scope | 9 (Ash Pit stays a stub), 13 (documented) |
|