diff --git a/.gitignore b/.gitignore index 69fc1b5..f604432 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ coverage/ .DS_Store -.worktrees/ \ No newline at end of file +.worktrees/ +.superpowers/ diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index ab6d9a3..6ad59c7 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -725,4 +725,231 @@ describe('CombatEngineService', () => { expect(state).toEqual(snapshot); }); }); + + 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, + ); + }); + }); + + 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); + }); + }); }); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index 9cc7d2b..5050183 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -15,6 +15,7 @@ import { CombatStatus } from './combat-status.enum'; import { StatusEffectType } from './status-effect.enum'; import type { MonsterBleedAbility, + MonsterGuardAbility, MonsterTelegraphAbility, } from '../monsters/monster-abilities'; @@ -65,7 +66,7 @@ export class CombatEngineService { const damage = calculateDamage( player.stats, - monster.stats.armor, + this.effectiveArmor(monster), multiplier, ); monster.currentHp = Math.max(0, monster.currentHp - damage); @@ -86,7 +87,7 @@ export class CombatEngineService { const damage = calculateDamage( player.stats, - monster.stats.armor, + this.effectiveArmor(monster), SHIELD_BASH_MULTIPLIER, ); monster.currentHp = Math.max(0, monster.currentHp - damage); @@ -108,6 +109,26 @@ export class CombatEngineService { }); } + // 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, + }); + } + return this.finishRound(state, player, monster, events, false, interrupted); } @@ -170,7 +191,11 @@ export class CombatEngineService { }); const defeatedMonster = { ...monster, - stats: { ...monster.stats, pendingAction: undefined }, + stats: { + ...monster.stats, + pendingAction: undefined, + activeGuard: undefined, + }, }; return { state: { @@ -183,6 +208,8 @@ export class CombatEngineService { }; } + this.ageGuard(monster, events); + if (!interrupted) { this.resolveMonsterTurn(state.round, monster, player, defended, events); } @@ -222,6 +249,8 @@ export class CombatEngineService { defended: boolean, events: CombatEngineEvent[], ): void { + this.checkEnrage(monster, events); + const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1; const abilities = monster.stats.abilities ?? {}; @@ -247,6 +276,21 @@ export class CombatEngineService { return; } + 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; + } + this.strikePlayer(monster, player, defendMultiplier, events); // The bite lands first and then tears: Bleeding is applied on top of a @@ -263,10 +307,13 @@ export class CombatEngineService { 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, + multiplier * enrageMultiplier, ); player.currentHp = Math.max(0, player.currentHp - damage); events.push({ @@ -277,13 +324,51 @@ export class CombatEngineService { }); } + /** + * 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, + }); + } + + /** Armor the monster actually presents this round, guard included. */ + private effectiveArmor(combatant: CombatEngineCombatant): number { + return ( + combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0) + ); + } + /** * A content-configured ability fires on every round divisible by its * interval. A fixed cadence rather than a hidden roll keeps the fight * readable and the engine deterministic (AGENTS §10). */ private shouldTrigger( - ability: MonsterTelegraphAbility | MonsterBleedAbility | undefined, + ability: + | MonsterTelegraphAbility + | MonsterBleedAbility + | MonsterGuardAbility + | undefined, round: number, ): boolean { return ( @@ -325,6 +410,36 @@ export class CombatEngineService { }); } + /** + * 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, + }); + } + /** * Deals one round of every active effect, then ages it. Bleeding ignores * armor: it is an open wound, not a blow that can be turned aside. diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts index fa089f8..cce3bed 100644 --- a/apps/api/src/combat/combat-engine.types.ts +++ b/apps/api/src/combat/combat-engine.types.ts @@ -34,12 +34,18 @@ export interface CombatEngineCombatantStats { // Monster-only: set when it telegraphs, cleared when the action resolves // or is interrupted. Optional because the player's stats never carry it. pendingAction?: CombatIntent; + // 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 }; // Monster-only: the content-authored mechanics this enemy fights with. // Absent (or empty) means a plain attacker with no special behaviour. abilities?: MonsterAbilities; // Effects currently ticking on this combatant. Only the player carries // any today -- nothing in this slice bleeds a monster. statusEffects?: ActiveStatusEffect[]; + // Monster-only: latched the first time its HP crosses the enrage threshold. + enraged?: boolean; } export interface CombatEngineCombatant { diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts index 938a542..913195b 100644 --- a/apps/api/src/combat/combat-event-type.enum.ts +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -4,6 +4,9 @@ export enum CombatEventType { DEFEND = 'DEFEND', TELEGRAPH = 'TELEGRAPH', INTERRUPT = 'INTERRUPT', + GUARD_RAISED = 'GUARD_RAISED', + GUARD_ENDED = 'GUARD_ENDED', + ENRAGED = 'ENRAGED', STATUS_APPLIED = 'STATUS_APPLIED', STATUS_DAMAGE = 'STATUS_DAMAGE', STATUS_EXPIRED = 'STATUS_EXPIRED', diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 7de0813..d84ece9 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -343,6 +343,8 @@ describe('CombatService', () => { currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }); expect(combat.events).toEqual([]); expect(dataSource.state.combats).toHaveLength(1); @@ -818,6 +820,34 @@ describe('CombatService', () => { }); describe('getCombat', () => { + it('reports an active guard and an enraged monster to the client', async () => { + const state = createState(); + const { service, dataSource } = createService({ state }); + const started = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); + + dataSource.state.combats[0].monsterState = { + ...dataSource.state.combats[0].monsterState, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + enraged: true, + }; + + const dto = await service.getCombat(CHARACTER_ID, started.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 state = createState(); + const { service, dataSource } = createService({ state }); + const started = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID); + + const dto = await service.getCombat(CHARACTER_ID, started.id); + + expect(dto.monster.guardRemainingRounds).toBeNull(); + expect(dto.monster.enraged).toBe(false); + }); + it('returns the persisted state and ordered events after a refresh', async () => { const context = createService(); const started = await context.service.startCombat( diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 1bcedbb..62abf45 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -68,6 +68,9 @@ export interface CombatMonsterDto { currentHp: number; artworkPath: string; pendingIntent: CombatIntent | null; + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; } export interface CombatEventDto { @@ -459,6 +462,9 @@ export class CombatService { currentHp: combat.monsterCurrentHp, artworkPath: monster.artworkPath, pendingIntent: combat.monsterState.pendingAction ?? null, + guardRemainingRounds: + combat.monsterState.activeGuard?.remainingRounds ?? null, + enraged: combat.monsterState.enraged ?? false, }, events: events.map((event) => ({ round: event.round, diff --git a/apps/api/src/combat/entities/combat.entity.ts b/apps/api/src/combat/entities/combat.entity.ts index ed0bea8..bc54296 100644 --- a/apps/api/src/combat/entities/combat.entity.ts +++ b/apps/api/src/combat/entities/combat.entity.ts @@ -26,6 +26,9 @@ export interface CombatMonsterState extends CombatCombatantState { // Snapshotted from MonsterDefinition when the fight starts, so retuning // content mid-fight cannot change the rules of a running combat. abilities: MonsterAbilities; + // Snapshotted monster combat state persisted during the fight. + activeGuard?: { remainingRounds: number; armorBonus: number }; + enraged?: boolean; } export interface CombatPlayerState extends CombatCombatantState { diff --git a/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts b/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts new file mode 100644 index 0000000..087e621 --- /dev/null +++ b/apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Schema for the Abandoned Watchpost (Playable Slice 0.10 §9, §5). + * + * Two unrelated-looking things in one migration because they arrive with one + * slice: the world gate that hides the Ash Pit route until it is found, and + * the two combat event types the Raider Veteran's new mechanics emit. + * + * `character_location_discoveries` is player state and nothing else -- which + * location is gated at all is content, and lives on the connection + * (AGENTS.md §7). A connection carrying its own gate means a place can be + * reachable by one road and hidden behind another. + */ +export class CreateAbandonedWatchpost1798000000000 + implements MigrationInterface +{ + name = 'CreateAbandonedWatchpost1798000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "character_location_discoveries" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "location_id" uuid NOT NULL, + "discovered_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_location_discoveries" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_location_discoveries_character" + FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE, + CONSTRAINT "FK_character_location_discoveries_location" + FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")`, + ); + + await queryRunner.query( + `ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false`, + ); + + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_character_location_discoveries_pair"`, + ); + await queryRunner.query(`DROP TABLE "character_location_discoveries"`); + await queryRunner.query( + `ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"`, + ); + + // Postgres cannot drop an enum value, so the type is rebuilt -- the same + // tradeoff migration 1790 already makes. Fails if any row uses one of the + // new values, which is the expected shape of a dev rollback. + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`, + ); + await queryRunner.query(`DROP TYPE "combat_event_type_enum"`); + await queryRunner.query( + `CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED', 'COMBAT_WON', 'COMBAT_LOST')`, + ); + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`, + ); + } +} diff --git a/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts b/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts new file mode 100644 index 0000000..5e0ede9 --- /dev/null +++ b/apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { CreateAbandonedWatchpost1798000000000 } from './1798000000000-CreateAbandonedWatchpost'; + +/** + * The migration writes multi-line SQL, so every assertion below reads it with + * runs of whitespace collapsed -- the same harness the other migration specs + * use, so reindenting a statement never breaks a test that still describes + * the right schema. + */ +function collapse(statements: string[]): string { + return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); +} + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new CreateAbandonedWatchpost1798000000000().up(queryRunner); + return collapse(query.mock.calls.map(([sql]) => sql as string)); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateAbandonedWatchpost1798000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); +} + +describe('CreateAbandonedWatchpost1798000000000', () => { + it('creates the discovery table', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TABLE "character_location_discoveries"'); + }); + + it('lets a character discover a location only once', async () => { + const joined = await runUp(); + + // The unique index, not a disabled button, is what makes a repeated + // investigation harmless (AGENTS.md §30). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")', + ); + }); + + it('cascades discoveries away with their character and location', async () => { + const joined = await runUp(); + + expect(joined).toContain( + 'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE', + ); + expect(joined).toContain( + 'FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE', + ); + }); + + it('adds an ungated-by-default discovery flag to connections', async () => { + const joined = await runUp(); + + // Default false: every route that exists today stays walkable. + expect(joined).toContain( + 'ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false', + ); + }); + + it('extends the combat event enum with the two new mechanics', async () => { + const joined = await runUp(); + + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + }); + + it('reverses the table and the column', async () => { + const joined = await runDown(); + + expect(joined).toContain('DROP TABLE "character_location_discoveries"'); + expect(joined).toContain( + 'ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"', + ); + }); +}); diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts index 4115761..a780985 100644 --- a/apps/api/src/database/seeds/item-content.ts +++ b/apps/api/src/database/seeds/item-content.ts @@ -4,9 +4,13 @@ import { ItemType } from '../../items/item-type.enum'; import { LootCategory } from '../../items/loot-category.enum'; import { ASH_RAT_LOOT_TABLE_ID, + BURNED_HOUND_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, ItemKey, + RAIDER_CAPTAIN_LOOT_TABLE_ID, + RAIDER_SCOUT_LOOT_TABLE_ID, + RAIDER_VETERAN_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; @@ -212,6 +216,29 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [ {}, LootCategory.RAIDER_TROPHY, ), + // 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, + ), ]; export const LOOT_TABLES = [ @@ -231,6 +258,26 @@ export const LOOT_TABLES = [ key: 'charred-looter-loot', name: 'Charred Raider Loot', }, + { + 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', + }, ]; export interface SeedLootTableEntry { @@ -290,4 +337,21 @@ export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'), + // 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'), ]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts index 54b6036..72a14a9 100644 --- a/apps/api/src/database/seeds/item.constants.ts +++ b/apps/api/src/database/seeds/item.constants.ts @@ -15,6 +15,8 @@ export const ITEM_IDS = { 'bandit-insignia': '50000000-0000-4000-8000-00000000000d', 'tough-hide': '50000000-0000-4000-8000-00000000000e', 'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f', + 'scorched-hide': '50000000-0000-4000-8000-000000000010', + 'raider-warband-mark': '50000000-0000-4000-8000-000000000011', } as const; export type ItemKey = keyof typeof ITEM_IDS; @@ -27,3 +29,14 @@ export const WILD_ROAD_DOG_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000003'; export const CHARRED_LOOTER_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000004'; + +// 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'; diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index fe48588..8de6996 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -240,3 +240,147 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { ], localRewardPreview: [], }; + +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: [], +}; diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 12a4cf5..df54c14 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -550,4 +550,31 @@ export const EXCHANGE_RULES: SeedExchangeRule[] = [ sortOrder: 4, enabled: true, }, + // 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, + }, ]; diff --git a/apps/api/src/database/seeds/vertical-slice.constants.ts b/apps/api/src/database/seeds/vertical-slice.constants.ts index 45c0543..32270e6 100644 --- a/apps/api/src/database/seeds/vertical-slice.constants.ts +++ b/apps/api/src/database/seeds/vertical-slice.constants.ts @@ -4,3 +4,13 @@ export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003'; export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004'; + +// Playable Slice 0.10. +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'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 781a4e4..d8ac3f5 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -28,9 +28,18 @@ import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, + RAIDER_CAPTAIN_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; +import { + ABANDONED_WATCHPOST_ID, + ASH_PIT_ID, + BURNED_HOUND_MONSTER_ID, + RAIDER_CAPTAIN_MONSTER_ID, + RAIDER_SCOUT_MONSTER_ID, + RAIDER_VETERAN_MONSTER_ID, +} from './vertical-slice.constants'; import { NpcQuestAssignment } from '../../quests/entities/npc-quest-assignment.entity'; import { QuestDefinition } from '../../quests/entities/quest-definition.entity'; import { QuestObjective } from '../../quests/entities/quest-objective.entity'; @@ -253,7 +262,9 @@ describe('seedVisibleVerticalSlice', () => { expect(characterRepository.insert).toHaveBeenCalledWith( expect.objectContaining({ id: DEMO_CHARACTER_ID }), ); - expect(locationRepository.rows).toHaveLength(2); + // South Gate, Burned Road, plus the Watchpost and Ash Pit added in + // Playable Slice 0.10 §5, §9. + expect(locationRepository.rows).toHaveLength(4); expect(locationRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -266,7 +277,9 @@ describe('seedVisibleVerticalSlice', () => { }), ]), ); - expect(connectionRepository.rows).toHaveLength(2); + // 2 original (South Gate <-> Burned Road) + 2 new (Burned Road <-> + // Watchpost) + 2 gated (Watchpost <-> Ash Pit) (Playable Slice 0.10 §9). + expect(connectionRepository.rows).toHaveLength(6); expect(characterRepository.rows).toHaveLength(1); expect(characterRepository.rows[0]).toEqual( expect.objectContaining({ @@ -276,8 +289,9 @@ describe('seedVisibleVerticalSlice', () => { }), ); - expect(monsterRepository.insert).toHaveBeenCalledTimes(4); - expect(monsterRepository.rows).toHaveLength(4); + // 4 Burned Road monsters + 4 Watchpost monsters (Playable Slice 0.10 §5). + expect(monsterRepository.insert).toHaveBeenCalledTimes(8); + expect(monsterRepository.rows).toHaveLength(8); expect(monsterRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -371,7 +385,8 @@ describe('seedVisibleVerticalSlice', () => { ]), ['locationId', 'monsterId'], ); - expect(locationMonsterRepository.rows).toHaveLength(4); + // 4 Burned Road entries + 5 Watchpost entries (Playable Slice 0.10 §4). + expect(locationMonsterRepository.rows).toHaveLength(9); }); it('seeds the local view content of the Burned Road with four points of interest', async () => { @@ -532,7 +547,9 @@ describe('seedVisibleVerticalSlice', () => { await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - expect(itemRepository.rows).toHaveLength(15); + // 15 Burned Road items plus the two Watchpost trade goods (Playable + // Slice 0.10 §6): the same item repository holds both locations' content. + expect(itemRepository.rows).toHaveLength(17); expect(itemRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -586,8 +603,10 @@ describe('seedVisibleVerticalSlice', () => { } // One table per monster now that each has its own guaranteed trade good. - expect(lootTableRepository.rows).toHaveLength(4); - expect(lootEntryRepository.rows).toHaveLength(12); + // Four for the Burned Road plus four more for the Watchpost (Playable + // Slice 0.10 §6, §7), all upserted into this same loot table repository. + expect(lootTableRepository.rows).toHaveLength(8); + expect(lootEntryRepository.rows).toHaveLength(25); // Every Burned Road enemy carries exactly one trade good as its first // entry (spec §5). The three common ones were retuned from a guaranteed @@ -868,9 +887,10 @@ describe('seedVisibleVerticalSlice', () => { expect(borin.locationId).toBe(SOUTH_GATE_ID); expect(npcShopRepository.rows).toHaveLength(1); - // All four Burned Road trade goods are accepted (slice 0.8 §5), and the - // rare Charred Raider Insignia pays visibly more than the common pelt. - expect(exchangeRuleRepository.rows).toHaveLength(4); + // All four Burned Road trade goods, plus the two Watchpost goods added in + // Playable Slice 0.10 §10, are accepted, and the rare Charred Raider + // Insignia pays visibly more than the common pelt. + expect(exchangeRuleRepository.rows).toHaveLength(6); const silverByItem = new Map( exchangeRuleRepository.rows.map((row: Row) => [ row.inputItemId, @@ -1257,4 +1277,306 @@ describe('seedVisibleVerticalSlice', () => { ).find((entry) => entry.poiKey === 'gate-watch') as Record; expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); }); + + it('gives each new trade good the carrying category it belongs to', async () => { + const itemRepository = new InMemoryRepository(); + const dataSource = createDataSource({ item: itemRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const hide = itemRepository.rows.find( + (row) => row.key === 'scorched-hide', + ); + const mark = itemRepository.rows.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 exchangeRuleRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + exchangeRule: exchangeRuleRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const hideRule = exchangeRuleRepository.rows.find( + (row) => row.inputItemId === ITEM_IDS['scorched-hide'], + ); + const markRule = exchangeRuleRepository.rows.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 exchangeRuleRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + exchangeRule: exchangeRuleRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const silverFor = (key: keyof typeof ITEM_IDS) => + exchangeRuleRepository.rows.find( + (row) => row.inputItemId === ITEM_IDS[key], + )?.silverReward as number; + + // 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 lootEntryRepository = new InMemoryRepository(); + const dataSource = createDataSource({ lootEntry: lootEntryRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const entries = lootEntryRepository.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 lootEntryRepository = new InMemoryRepository(); + const dataSource = createDataSource({ lootEntry: lootEntryRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const entries = lootEntryRepository.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'); + }); + + it('seeds the watchpost as a huntable outpost', async () => { + const locationRepository = new InMemoryRepository(); + const dataSource = createDataSource({ location: locationRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const watchpost = locationRepository.rows.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 connectionRepository = new InMemoryRepository(); + const dataSource = createDataSource({ connection: connectionRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const connections = connectionRepository.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 connectionRepository = new InMemoryRepository(); + const dataSource = createDataSource({ connection: connectionRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const connections = connectionRepository.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 locationRepository = new InMemoryRepository(); + const dataSource = createDataSource({ location: locationRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const watchpost = locationRepository.rows.find( + (row) => row.key === 'abandoned-watchpost', + ); + + const poi = ( + watchpost?.localPointsOfInterest as Array> + ).find((entry) => entry.key === 'inspect-watchpost'); + + expect(poi?.discoversLocationKey).toBe('ash-pit'); + }); + + it('gives the watchpost its own encounter pool', async () => { + const locationMonsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + locationMonster: locationMonsterRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const pool = locationMonsterRepository.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 locationMonsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + locationMonster: locationMonsterRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const pool = locationMonsterRepository.rows.filter( + (row) => row.locationId === BURNED_ROAD_ID, + ); + + expect(pool).toHaveLength(4); + }); + + it('marks only the captain as a rare encounter', async () => { + const locationMonsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + locationMonster: locationMonsterRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const pool = locationMonsterRepository.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 monsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ monster: monsterRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const veteran = monsterRepository.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 monsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ monster: monsterRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const hound = monsterRepository.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 locationRepository = new InMemoryRepository(); + const locationMonsterRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + location: locationRepository, + locationMonster: locationMonsterRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const ashPit = locationRepository.rows.find( + (row) => row.key === 'ash-pit', + ); + const pool = locationMonsterRepository.rows.filter( + (row) => row.locationId === ASH_PIT_ID, + ); + + expect(ashPit?.huntingEnabled).toBe(false); + expect(pool).toHaveLength(0); + }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 7a65c84..17fb938 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -36,12 +36,18 @@ import { } from './item-content'; import { ASH_RAT_LOOT_TABLE_ID, + BURNED_HOUND_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, + RAIDER_CAPTAIN_LOOT_TABLE_ID, + RAIDER_SCOUT_LOOT_TABLE_ID, + RAIDER_VETERAN_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; import { + ABANDONED_WATCHPOST_LOCAL_CONTENT, + ASH_PIT_LOCAL_CONTENT, BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT, } from './local-location.content'; @@ -62,9 +68,15 @@ import { SHOP_OFFERS, } from './npc-content'; import { + ABANDONED_WATCHPOST_ID, + ASH_PIT_ID, ASH_RAT_MONSTER_ID, + BURNED_HOUND_MONSTER_ID, BURNED_ROAD_ID, CHARRED_LOOTER_MONSTER_ID, + RAIDER_CAPTAIN_MONSTER_ID, + RAIDER_SCOUT_MONSTER_ID, + RAIDER_VETERAN_MONSTER_ID, ROAD_BANDIT_MONSTER_ID, SOUTH_GATE_ID, WILD_ROAD_DOG_MONSTER_ID, @@ -131,6 +143,36 @@ export async function seedVisibleVerticalSlice( artworkPath: '/images/backgrounds/Aschestrasse.png', ...BURNED_ROAD_LOCAL_CONTENT, }, + { + 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, + }, ]; const locationIds = new Map(); @@ -149,6 +191,9 @@ export async function seedVisibleVerticalSlice( const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID; const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID; + const watchpostId = + locationIds.get('abandoned-watchpost') ?? ABANDONED_WATCHPOST_ID; + const ashPitId = locationIds.get('ash-pit') ?? ASH_PIT_ID; await connectionRepository.upsert( [ @@ -158,6 +203,7 @@ export async function seedVisibleVerticalSlice( travelDurationSeconds: 10, ambushChance: '0.0500', enabled: true, + requiresDiscovery: false, }, { fromLocationId: burnedRoadId, @@ -165,6 +211,42 @@ export async function seedVisibleVerticalSlice( travelDurationSeconds: 10, ambushChance: '0.0500', enabled: true, + requiresDiscovery: false, + }, + { + 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, }, ], ['fromLocationId', 'toLocationId'], @@ -265,6 +347,89 @@ export async function seedVisibleVerticalSlice( iconPath: '/images/combat/icons/charred-looter-128.png', lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID, }, + { + 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, + }, ]; const monsterIds = new Map(); @@ -312,6 +477,32 @@ export async function seedVisibleVerticalSlice( ['locationId', 'monsterId'], ); + // 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'], + ); + const existing = await characterRepository.findOneBy({ id: DEMO_CHARACTER_ID, }); diff --git a/apps/api/src/monsters/monster-abilities.ts b/apps/api/src/monsters/monster-abilities.ts index fbf61a1..ac24108 100644 --- a/apps/api/src/monsters/monster-abilities.ts +++ b/apps/api/src/monsters/monster-abilities.ts @@ -26,9 +26,33 @@ export interface MonsterBleedAbility { durationRounds: number; } +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; +} + +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; +} + export interface MonsterAbilities { telegraph?: MonsterTelegraphAbility; bleed?: MonsterBleedAbility; + guard?: MonsterGuardAbility; + enrage?: MonsterEnrageAbility; } export const NO_MONSTER_ABILITIES: MonsterAbilities = {}; diff --git a/apps/api/src/travel/travel.module.ts b/apps/api/src/travel/travel.module.ts index eca8a2b..013ff17 100644 --- a/apps/api/src/travel/travel.module.ts +++ b/apps/api/src/travel/travel.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity'; import { CLOCK, systemClock } from '../shared/clock'; +import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module'; import { Travel } from './entities/travel.entity'; import { TravelController } from './travel.controller'; import { TravelService } from './travel.service'; @@ -16,6 +17,7 @@ import { TravelService } from './travel.service'; LocationConnection, Travel, ]), + WorldDiscoveryModule, ], controllers: [TravelController], providers: [TravelService, { provide: CLOCK, useValue: systemClock }], diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index daa28c4..a1ceb9d 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -7,6 +7,7 @@ import { import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity'; import { Clock } from '../shared/clock'; +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; import { Travel } from './entities/travel.entity'; import { TravelDomainError } from './travel.errors'; import { TravelService } from './travel.service'; @@ -215,16 +216,37 @@ function activeTravel(arrivesAt: Date): Travel { } as Travel; } -function createService(state = createState()) { - const dataSource = new FakeDataSource(state); +function buildService( + options: { state?: FakeState; discovered?: string[] } = {}, +) { + const dataSource = new FakeDataSource(options.state ?? createState()); const clock: Clock = { now: () => new Date(NOW) }; - const service = new TravelService(dataSource as unknown as DataSource, clock); - return { dataSource, service }; + const worldDiscovery = { + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + manager?: unknown, + ) => { + if (manager === undefined) { + throw new Error('isTravelAllowed must be called with a manager'); + } + return Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ); + }, + } as unknown as WorldDiscoveryService; + const service = new TravelService( + dataSource as unknown as DataSource, + clock, + worldDiscovery, + ); + return { dataSource, service, state: dataSource.state }; } describe('TravelService', () => { it('starts travel for an enabled directed connection', async () => { - const { dataSource, service } = createService(); + const { dataSource, service } = buildService(); await expect( service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), @@ -259,7 +281,7 @@ describe('TravelService', () => { it('rejects a target without an enabled connection', async () => { const state = createState(); state.connections[0].enabled = false; - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect( service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), @@ -270,7 +292,7 @@ describe('TravelService', () => { }); it('derives arrivesAt from the injected clock and connection duration', async () => { - const { dataSource, service } = createService(); + const { dataSource, service } = buildService(); const result = await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID); @@ -284,7 +306,7 @@ describe('TravelService', () => { it('rejects a second journey with a stable active-travel error', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); let error: unknown; try { @@ -308,7 +330,7 @@ describe('TravelService', () => { it('returns the current active travel without exposing persistence fields', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); - const { service } = createService(state); + const { service } = buildService({ state }); await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.TRAVELLING, @@ -330,7 +352,7 @@ describe('TravelService', () => { it('does not complete or move the character before arrivesAt', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.001Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.TRAVELLING, @@ -356,7 +378,7 @@ describe('TravelService', () => { it('completes due travel and updates character location atomically', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.COMPLETED, @@ -382,7 +404,7 @@ describe('TravelService', () => { it('rolls back both due-travel updates if either save fails', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); dataSource.failSaveTarget = Character; await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow( @@ -393,4 +415,23 @@ describe('TravelService', () => { ); expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING); }); + + 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 }); + }); }); diff --git a/apps/api/src/travel/travel.service.ts b/apps/api/src/travel/travel.service.ts index 2c99dc4..674690b 100644 --- a/apps/api/src/travel/travel.service.ts +++ b/apps/api/src/travel/travel.service.ts @@ -5,6 +5,7 @@ import { LocationConnection } from '../world/entities/location-connection.entity import { LocationDefinition } from '../world/entities/location-definition.entity'; import { CLOCK } from '../shared/clock'; import type { Clock } from '../shared/clock'; +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; import { Travel } from './entities/travel.entity'; import { characterNotFound, @@ -45,6 +46,7 @@ export class TravelService { constructor( private readonly dataSource: DataSource, @Inject(CLOCK) private readonly clock: Clock, + private readonly worldDiscovery: WorldDiscoveryService, ) {} startTravel( @@ -79,6 +81,18 @@ export class TravelService { throw invalidTravelTarget(); } + // 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(); + } + const originLocation = await locations.findOneBy({ id: character.currentLocationId, }); diff --git a/apps/api/src/world/discovery/character-location-discovery.entity.ts b/apps/api/src/world/discovery/character-location-discovery.entity.ts new file mode 100644 index 0000000..2d26ee9 --- /dev/null +++ b/apps/api/src/world/discovery/character-location-discovery.entity.ts @@ -0,0 +1,46 @@ +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; +} diff --git a/apps/api/src/world/discovery/world-discovery.module.ts b/apps/api/src/world/discovery/world-discovery.module.ts new file mode 100644 index 0000000..d87074d --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.module.ts @@ -0,0 +1,19 @@ +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 {} diff --git a/apps/api/src/world/discovery/world-discovery.service.spec.ts b/apps/api/src/world/discovery/world-discovery.service.spec.ts new file mode 100644 index 0000000..6526441 --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.service.spec.ts @@ -0,0 +1,153 @@ +import { DataSource, Repository } from 'typeorm'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { WorldDiscoveryService } from './world-discovery.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const WATCHPOST_ID = '20000000-0000-4000-8000-000000000003'; +const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; + +interface InsertCall { + values: Record; + orIgnore: boolean; +} + +function buildService(options: { + discoveries?: Array<{ locationId: string }>; + locations?: Array>; + insertCalls?: InsertCall[]; +}): WorldDiscoveryService { + const discoveries = options.discoveries ?? []; + const locations = options.locations ?? []; + const insertCalls = options.insertCalls ?? []; + + const discoveryRepository = { + find: jest.fn().mockResolvedValue(discoveries), + createQueryBuilder: jest.fn(() => { + const builder = { + insert: () => builder, + into: () => builder, + values: (values: Record) => { + insertCalls.push({ values, orIgnore: false }); + return builder; + }, + orIgnore: () => { + insertCalls[insertCalls.length - 1].orIgnore = true; + return builder; + }, + execute: jest + .fn() + .mockResolvedValue({ identifiers: [{ id: 'new-row' }] }), + }; + return builder; + }), + } as unknown as Repository; + + const locationRepository = { + findOneBy: jest.fn(({ key }: { key: string }) => + Promise.resolve(locations.find((location) => location.key === key) ?? null), + ), + } as unknown as Repository; + + const dataSource = { + getRepository: (target: unknown) => + target === CharacterLocationDiscovery + ? discoveryRepository + : locationRepository, + } as unknown as DataSource; + + return new WorldDiscoveryService(dataSource); +} + +describe('WorldDiscoveryService', () => { + it('returns the ids the character has already discovered', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + }); + + const discovered = await service.getDiscoveredLocationIds(CHARACTER_ID); + + expect(discovered.has(ASH_PIT_ID)).toBe(true); + expect(discovered.has(WATCHPOST_ID)).toBe(false); + }); + + it('allows travel down a route that carries no gate', async () => { + const service = buildService({}); + const connection = { + toLocationId: WATCHPOST_ID, + requiresDiscovery: false, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('refuses a gated route the character has not discovered', async () => { + const service = buildService({}); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(false); + }); + + it('allows a gated route once it has been discovered', async () => { + const service = buildService({ discoveries: [{ locationId: ASH_PIT_ID }] }); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('returns the location the first time it is discovered', async () => { + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('returns null when the location was already known', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toBeNull(); + }); + + it('writes the row so a duplicate is ignored rather than thrown', async () => { + const insertCalls: InsertCall[] = []; + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + insertCalls, + }); + + await service.discover(CHARACTER_ID, 'ash-pit'); + + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0].orIgnore).toBe(true); + expect(insertCalls[0].values).toEqual({ + characterId: CHARACTER_ID, + locationId: ASH_PIT_ID, + }); + }); + + it('ignores an unknown location key', async () => { + const service = buildService({ locations: [] }); + + await expect(service.discover(CHARACTER_ID, 'nowhere')).resolves.toBeNull(); + }); +}); diff --git a/apps/api/src/world/discovery/world-discovery.service.ts b/apps/api/src/world/discovery/world-discovery.service.ts new file mode 100644 index 0000000..dbf3a65 --- /dev/null +++ b/apps/api/src/world/discovery/world-discovery.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; + +export interface DiscoveredLocation { + key: string; + name: string; +} + +/** + * Which places a character knows about, and whether a gated route is open to + * them yet (Playable Slice 0.10 §9). + * + * One service rather than a check inlined in `WorldService` and + * `TravelService`: the map must hide exactly what travel refuses, and two + * copies of that rule would drift the moment Slice 0.11 adds a second gate. + */ +@Injectable() +export class WorldDiscoveryService { + constructor(private readonly dataSource: DataSource) {} + + async getDiscoveredLocationIds( + characterId: string, + manager?: EntityManager, + ): Promise> { + const repository = this.discoveries(manager); + const rows = await repository.find({ + where: { characterId }, + select: { locationId: true }, + }); + return new Set(rows.map((row) => row.locationId)); + } + + /** + * Records that the character now knows this place. + * + * Returns the location the first time and `null` afterwards, so a caller can + * tell a fresh reveal from a repeated click without a second query. The + * insert ignores a conflict rather than throwing: the same interaction run + * twice is a normal thing for a player to do (AGENTS.md §30). + */ + async discover( + characterId: string, + locationKey: string, + manager?: EntityManager, + ): Promise { + const locations = manager + ? manager.getRepository(LocationDefinition) + : this.dataSource.getRepository(LocationDefinition); + const location = await locations.findOneBy({ key: locationKey }); + if (!location) { + return null; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + if (known.has(location.id)) { + return null; + } + + await this.discoveries(manager) + .createQueryBuilder() + .insert() + .into(CharacterLocationDiscovery) + .values({ characterId, locationId: location.id }) + .orIgnore() + .execute(); + + return { key: location.key, name: location.name }; + } + + async isTravelAllowed( + characterId: string, + connection: Pick, + manager?: EntityManager, + ): Promise { + if (!connection.requiresDiscovery) { + return true; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + return this.isRouteOpen(known, connection); + } + + /** + * The gating rule itself, given an already-loaded discovery set. + * + * Pure and synchronous so a caller with many connections can load the set + * once and filter in memory, while `isTravelAllowed` stays the convenient + * single-connection entry point. One rule, two callers. + */ + isRouteOpen( + discoveredLocationIds: ReadonlySet, + connection: Pick, + ): boolean { + return ( + !connection.requiresDiscovery || + discoveredLocationIds.has(connection.toLocationId) + ); + } + + private discoveries(manager?: EntityManager) { + return manager + ? manager.getRepository(CharacterLocationDiscovery) + : this.dataSource.getRepository(CharacterLocationDiscovery); + } +} diff --git a/apps/api/src/world/entities/location-connection.entity.ts b/apps/api/src/world/entities/location-connection.entity.ts index 2effd01..01708fe 100644 --- a/apps/api/src/world/entities/location-connection.entity.ts +++ b/apps/api/src/world/entities/location-connection.entity.ts @@ -35,6 +35,14 @@ export class LocationConnection { @Column({ name: 'enabled', type: 'boolean' }) enabled!: boolean; + /** + * 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; + @ManyToOne( () => LocationDefinition, (location) => location.outgoingConnections, diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts index 0dca51c..b72c8a9 100644 --- a/apps/api/src/world/local-location-interaction.spec.ts +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -6,6 +6,7 @@ import { } from '../database/seeds/vertical-slice.constants'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; +import { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import type { LocationPointOfInterestContent } from './local-location.types'; @@ -92,9 +93,51 @@ function createService( } as unknown as Repository, { find: jest.fn() } as unknown as Repository, { find: jest.fn() } as unknown as Repository, + { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService, ); } +/** + * Builds a `WorldService` sitting at the Burned Road with the given points of + * interest, and a stub `WorldDiscoveryService` whose `discover` mirrors the + * real one: it returns the ash pit the first time and `null` once + * `alreadyDiscovered` says the character already knows it. + */ +function buildService(options: { + pointsOfInterest: LocationPointOfInterestContent[]; + alreadyDiscovered?: boolean; +}) { + const currentLocation = location(BURNED_ROAD_ID, options.pointsOfInterest); + const discover = jest.fn().mockResolvedValue( + options.alreadyDiscovered ? null : { key: 'ash-pit', name: 'Ash Pit' }, + ); + const worldDiscovery = { + discover, + isTravelAllowed: () => Promise.resolve(true), + } as unknown as WorldDiscoveryService; + + const service = new WorldService( + { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService, + { + findOne: jest.fn().mockResolvedValue({ + id: CHARACTER_ID, + currentLocationId: currentLocation.id, + currentLocation, + }), + } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, + worldDiscovery, + ); + + return { service, discover }; +} + async function expectRejected(promise: Promise): Promise { await expect(promise).rejects.toBeInstanceOf(WorldDomainError); await expect(promise).rejects.toMatchObject({ @@ -112,6 +155,7 @@ describe('WorldService.runLocalInteraction', () => { interactionKey: 'inspect-tracks', title: 'Suspicious Tracks', text: 'Between the ash and broken stones you make out several fresh bootprints.', + discoveredLocation: null, }); }); @@ -125,6 +169,7 @@ describe('WorldService.runLocalInteraction', () => { title: 'Gate Watch', text: 'Only heard at the South Gate.', img: '/images/npcs/graufurt-gate-watch.png', + discoveredLocation: null, }); }); @@ -171,4 +216,87 @@ describe('WorldService.runLocalInteraction', () => { service.runLocalInteraction(CHARACTER_ID, 'hunt-area'), ); }); + + 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(); + }); }); diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts index f12e817..400ff92 100644 --- a/apps/api/src/world/local-location.types.ts +++ b/apps/api/src/world/local-location.types.ts @@ -55,6 +55,12 @@ export interface LocationPointOfInterestContent { * scout on the Burned Road stays a piece of scenery, Borin does not. */ npcKey?: string; + /** + * 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; } /** @@ -120,6 +126,11 @@ export interface LocationInteractionResultDto { title: string; text: string; img?: string; + /** + * 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; } /** Strips server-only result text before a POI is sent to the client. */ diff --git a/apps/api/src/world/world.module.ts b/apps/api/src/world/world.module.ts index f89e016..008b1a9 100644 --- a/apps/api/src/world/world.module.ts +++ b/apps/api/src/world/world.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { TravelModule } from '../travel/travel.module'; +import { WorldDiscoveryModule } from './discovery/world-discovery.module'; import { LocationConnection } from './entities/location-connection.entity'; import { WorldController } from './world.controller'; import { WorldService } from './world.service'; @@ -17,6 +18,7 @@ import { WorldService } from './world.service'; MonsterDefinition, ]), TravelModule, + WorldDiscoveryModule, ], controllers: [WorldController], providers: [WorldService], diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index bd3c32e..329537c 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -7,6 +7,7 @@ import { } from '../database/seeds/vertical-slice.constants'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; +import { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import type { @@ -16,6 +17,7 @@ import type { import { WorldService } from './world.service'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ { @@ -54,6 +56,7 @@ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ enabled: true, resultTitle: 'Suspicious Tracks', resultText: 'Fresh bootprints lead east.', + discoversLocationKey: 'ash-pit', }, { key: 'sealed-crypt', @@ -183,6 +186,81 @@ function burnedRoad(): LocationDefinition { }; } +const ashPitLocation: LocationDefinition = { + id: ASH_PIT_ID, + key: 'ash-pit', + name: 'Ash Pit', + description: 'A smoldering pit at the edge of the Ashen Fields.', + regionKey: 'ashen-fields', + minRecommendedLevel: 2, + maxRecommendedLevel: 3, + dangerLevel: 2, + isSafe: false, + huntingEnabled: true, + artworkPath: '/assets/locations/ash-pit.webp', + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'HUNTING_GROUND', + localDescription: 'The pit still smolders, day and night.', + localArtworkPath: '/images/backgrounds/ash-pit.png', + localPointsOfInterest: [], + localPrimaryActions: [], + localRewardPreview: [], + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + characters: [], + outgoingConnections: [], + incomingConnections: [], +}; + +/** + * Builds a `WorldService` sitting at the Burned Road, with a stub + * `WorldDiscoveryService` that mirrors the real gate: a connection with + * `requiresDiscovery` is only allowed once its target is in `discovered`. + */ +function buildService( + options: { + connections?: LocationConnection[]; + discovered?: string[]; + } = {}, +) { + const location = burnedRoad(); + const travelService = { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService; + const characters = { + findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)), + } as unknown as Repository; + const connections = { + find: jest.fn().mockResolvedValue(options.connections ?? []), + } as unknown as Repository; + const locationMonsters = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const worldDiscovery = { + discover: jest.fn(), + getDiscoveredLocationIds: jest + .fn() + .mockResolvedValue(new Set(options.discovered ?? [])), + isRouteOpen: ( + discoveredLocationIds: ReadonlySet, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + !connection.requiresDiscovery || + discoveredLocationIds.has(connection.toLocationId), + } as unknown as WorldDiscoveryService; + + const service = new WorldService( + travelService, + characters, + connections, + locationMonsters, + worldDiscovery, + ); + + return { service }; +} + describe('WorldService', () => { it('returns the authoritative current location and only enabled public connections', async () => { const callOrder: string[] = []; @@ -230,11 +308,20 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); const result = await service.getCurrentLocation(CHARACTER_ID); @@ -313,11 +400,20 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); const result = await service.getCurrentLocation(CHARACTER_ID); @@ -349,11 +445,20 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); await expect( @@ -423,6 +528,8 @@ describe('WorldService', () => { ]); expect(JSON.stringify(result)).not.toContain('Fresh bootprints'); expect(JSON.stringify(result)).not.toContain('Still sealed'); + expect(JSON.stringify(result)).not.toContain('discoversLocationKey'); + expect(JSON.stringify(result)).not.toContain('ash-pit'); }); it('derives the encounter preview from the location monster pool', async () => { @@ -458,6 +565,48 @@ describe('WorldService', () => { expect(result.dangerRating).toBeNull(); expect(result.encounterPreview).toEqual([]); }); + + 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'); + }); }); async function loadBurnedRoad() { @@ -486,6 +635,14 @@ async function loadLocation( { find: jest.fn().mockResolvedValue(pool), } as unknown as Repository, + { + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, + discover: jest.fn(), + } as unknown as WorldDiscoveryService, ); return service.getCurrentLocation(CHARACTER_ID); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 17a2d14..9b94c68 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -5,6 +5,7 @@ import { Character } from '../characters/entities/character.entity'; import { calculateDangerRating, DangerRating } from '../hunting/danger-rating'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; +import { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import { @@ -68,6 +69,7 @@ export class WorldService { private readonly connections: Repository, @InjectRepository(LocationMonster) private readonly locationMonsters: Repository, + private readonly worldDiscovery: WorldDiscoveryService, ) {} async getCurrentLocation( @@ -85,6 +87,17 @@ export class WorldService { ? await this.getEncounterPool(location.id) : []; + // Loaded once per request rather than per connection: `isRouteOpen` is + // synchronous, so a location with several gated exits costs one query + // here instead of one per gated connection. + const discoveredLocationIds = + await this.worldDiscovery.getDiscoveredLocationIds(characterId); + const visibleConnections = connections.filter( + (connection) => + connection.enabled && + this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection), + ); + return { id: location.id, key: location.key, @@ -114,17 +127,15 @@ export class WorldService { iconPath: entry.monster.iconPath, })), rewardPreview: location.localRewardPreview, - connections: connections - .filter((connection) => connection.enabled) - .map((connection) => ({ - targetLocation: { - id: connection.toLocation.id, - key: connection.toLocation.key, - name: connection.toLocation.name, - }, - travelDurationSeconds: connection.travelDurationSeconds, - danger: this.toDangerRating(connection.ambushChance), - })), + connections: visibleConnections.map((connection) => ({ + targetLocation: { + id: connection.toLocation.id, + key: connection.toLocation.key, + name: connection.toLocation.name, + }, + travelDurationSeconds: connection.travelDurationSeconds, + danger: this.toDangerRating(connection.ambushChance), + })), possibleMonsters: pool.map((entry) => entry.monster.name), }; } @@ -152,11 +163,21 @@ export class WorldService { throw locationInteractionUnavailable(); } + // 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; + return { interactionKey: poi.key, title: poi.resultTitle ?? poi.title, text: poi.resultText, ...(poi.resultImg === undefined ? {} : { img: poi.resultImg }), + discoveredLocation, }; } diff --git a/apps/web/public/images/backgrounds/Aschengrube.png b/apps/web/public/images/backgrounds/Aschengrube.png new file mode 100644 index 0000000..02fdecb Binary files /dev/null and b/apps/web/public/images/backgrounds/Aschengrube.png differ diff --git a/apps/web/public/images/backgrounds/Wachturm.png b/apps/web/public/images/backgrounds/Wachturm.png new file mode 100644 index 0000000..b64b14d Binary files /dev/null and b/apps/web/public/images/backgrounds/Wachturm.png differ diff --git a/apps/web/public/images/backgrounds/runtime/Aschengrube-960.jpg b/apps/web/public/images/backgrounds/runtime/Aschengrube-960.jpg new file mode 100644 index 0000000..7968379 Binary files /dev/null and b/apps/web/public/images/backgrounds/runtime/Aschengrube-960.jpg differ diff --git a/apps/web/public/images/backgrounds/runtime/Wachturm-960.jpg b/apps/web/public/images/backgrounds/runtime/Wachturm-960.jpg new file mode 100644 index 0000000..f2b030e Binary files /dev/null and b/apps/web/public/images/backgrounds/runtime/Wachturm-960.jpg differ diff --git a/apps/web/public/images/combat/icons/burned-hound-128.png b/apps/web/public/images/combat/icons/burned-hound-128.png new file mode 100644 index 0000000..3c870f2 Binary files /dev/null and b/apps/web/public/images/combat/icons/burned-hound-128.png differ diff --git a/apps/web/public/images/combat/icons/raider-captain-128.png b/apps/web/public/images/combat/icons/raider-captain-128.png new file mode 100644 index 0000000..cbcaad5 Binary files /dev/null and b/apps/web/public/images/combat/icons/raider-captain-128.png differ diff --git a/apps/web/public/images/combat/icons/raider-scout-128.png b/apps/web/public/images/combat/icons/raider-scout-128.png new file mode 100644 index 0000000..5bb310e Binary files /dev/null and b/apps/web/public/images/combat/icons/raider-scout-128.png differ diff --git a/apps/web/public/images/combat/icons/raider-veteran-128.png b/apps/web/public/images/combat/icons/raider-veteran-128.png new file mode 100644 index 0000000..66e55f9 Binary files /dev/null and b/apps/web/public/images/combat/icons/raider-veteran-128.png differ diff --git a/apps/web/public/images/combat/sprites/burned-hound-760.png b/apps/web/public/images/combat/sprites/burned-hound-760.png new file mode 100644 index 0000000..fb73317 Binary files /dev/null and b/apps/web/public/images/combat/sprites/burned-hound-760.png differ diff --git a/apps/web/public/images/combat/sprites/raider-captain-620.png b/apps/web/public/images/combat/sprites/raider-captain-620.png new file mode 100644 index 0000000..3e22ed9 Binary files /dev/null and b/apps/web/public/images/combat/sprites/raider-captain-620.png differ diff --git a/apps/web/public/images/combat/sprites/raider-scout-620.png b/apps/web/public/images/combat/sprites/raider-scout-620.png new file mode 100644 index 0000000..eb8295a Binary files /dev/null and b/apps/web/public/images/combat/sprites/raider-scout-620.png differ diff --git a/apps/web/public/images/combat/sprites/raider-veteran-620.png b/apps/web/public/images/combat/sprites/raider-veteran-620.png new file mode 100644 index 0000000..9487bf5 Binary files /dev/null and b/apps/web/public/images/combat/sprites/raider-veteran-620.png differ diff --git a/apps/web/public/images/monsters/burned-hound.png b/apps/web/public/images/monsters/burned-hound.png new file mode 100644 index 0000000..648c6b5 Binary files /dev/null and b/apps/web/public/images/monsters/burned-hound.png differ diff --git a/apps/web/public/images/monsters/raider-captain.png b/apps/web/public/images/monsters/raider-captain.png new file mode 100644 index 0000000..001d5a7 Binary files /dev/null and b/apps/web/public/images/monsters/raider-captain.png differ diff --git a/apps/web/public/images/monsters/raider-scout.png b/apps/web/public/images/monsters/raider-scout.png new file mode 100644 index 0000000..a2d59b2 Binary files /dev/null and b/apps/web/public/images/monsters/raider-scout.png differ diff --git a/apps/web/public/images/monsters/raider-veteran.png b/apps/web/public/images/monsters/raider-veteran.png new file mode 100644 index 0000000..0edeb7b Binary files /dev/null and b/apps/web/public/images/monsters/raider-veteran.png differ diff --git a/apps/web/public/images/monsters/runtime/burned-hound-560.jpg b/apps/web/public/images/monsters/runtime/burned-hound-560.jpg new file mode 100644 index 0000000..bfa18c0 Binary files /dev/null and b/apps/web/public/images/monsters/runtime/burned-hound-560.jpg differ diff --git a/apps/web/public/images/monsters/runtime/raider-captain-560.jpg b/apps/web/public/images/monsters/runtime/raider-captain-560.jpg new file mode 100644 index 0000000..627afe5 Binary files /dev/null and b/apps/web/public/images/monsters/runtime/raider-captain-560.jpg differ diff --git a/apps/web/public/images/monsters/runtime/raider-scout-560.jpg b/apps/web/public/images/monsters/runtime/raider-scout-560.jpg new file mode 100644 index 0000000..c757bd2 Binary files /dev/null and b/apps/web/public/images/monsters/runtime/raider-scout-560.jpg differ diff --git a/apps/web/public/images/monsters/runtime/raider-veteran-560.jpg b/apps/web/public/images/monsters/runtime/raider-veteran-560.jpg new file mode 100644 index 0000000..80262f9 Binary files /dev/null and b/apps/web/public/images/monsters/runtime/raider-veteran-560.jpg differ diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index c951c3a..23e3661 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -89,6 +89,13 @@ export interface LocationInteractionResult { title: string; text: string; img?: string; + /** + * 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; } export interface CurrentLocationResponse { @@ -170,7 +177,10 @@ export type CombatEventType = | 'STATUS_DAMAGE' | 'STATUS_EXPIRED' | 'COMBAT_WON' - | 'COMBAT_LOST'; + | 'COMBAT_LOST' + | 'GUARD_RAISED' + | 'GUARD_ENDED' + | 'ENRAGED'; export type StatusEffectType = 'BLEED'; export type CombatSide = 'PLAYER' | 'MONSTER'; export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'; @@ -209,6 +219,9 @@ export interface CombatMonster { currentHp: number; artworkPath: string; pendingIntent: CombatMonsterIntent | null; + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; } export interface Combat { diff --git a/apps/web/src/app/core/resume-combat.spec.ts b/apps/web/src/app/core/resume-combat.spec.ts index 7fbc9bc..9134561 100644 --- a/apps/web/src/app/core/resume-combat.spec.ts +++ b/apps/web/src/app/core/resume-combat.spec.ts @@ -25,6 +25,8 @@ const runningCombat: Combat = { currentHp: 30, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null, diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index f7a8c61..92062a7 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -69,6 +69,16 @@

{{ intent }}

} + @if (monsterGuardLabel(); as guard) { +

{{ guard }}

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

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

+ } +
{ expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); }); + 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."); + }); + it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => { const fixture = await setup({ ...activeCombat, diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index d554fc2..d424e97 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -323,6 +323,20 @@ export class CombatPageComponent implements OnInit { return `${combat.monster.name} is winding up a Heavy Strike.`; } + 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; + } + protected logRounds(): CombatLogRound[] { const combat = this.displayed(); if (!combat) { @@ -366,6 +380,18 @@ export class CombatPageComponent implements OnInit { return `${playerName} interrupts ${monsterName}'s attack.`; } + 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.`; + } + const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect'; if (event.type === 'STATUS_APPLIED') { diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index ead0e41..e46093c 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -26,6 +26,8 @@ const startedCombat: Combat = { currentHp: 45, artworkPath: '/images/monsters/ash-rat.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null, diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 2ae54bc..54c9f16 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -88,6 +88,8 @@ const startedCombat: Combat = { currentHp: 75, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, + guardRemainingRounds: null, + enraged: false, }, events: [], rewards: null, diff --git a/apps/web/src/app/features/world/local-location.store.spec.ts b/apps/web/src/app/features/world/local-location.store.spec.ts index 5f8680c..5ecd837 100644 --- a/apps/web/src/app/features/world/local-location.store.spec.ts +++ b/apps/web/src/app/features/world/local-location.store.spec.ts @@ -122,6 +122,38 @@ describe('LocalLocationStore', () => { expect(store.interactionResult()).toBeNull(); }); + it('reloads the location when the interaction reveals a route', async () => { + const load = vi.fn().mockResolvedValue(undefined); + const store = setup( + { + runLocationInteraction: vi.fn().mockReturnValue( + of({ ...trackResult, discoveredLocation: { key: 'ash-pit', name: 'Ash Pit' } }), + ), + }, + { load }, + ); + + await store.runInteraction('inspect-tracks'); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('does not reload the location when nothing new was revealed', async () => { + const load = vi.fn().mockResolvedValue(undefined); + const store = setup( + { + runLocationInteraction: vi.fn().mockReturnValue( + of({ ...trackResult, discoveredLocation: null }), + ), + }, + { load }, + ); + + await store.runInteraction('inspect-tracks'); + + expect(load).not.toHaveBeenCalled(); + }); + it('resolves the hotspot an action mirrors', () => { const store = setup(); diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts index 7aea055..03a9231 100644 --- a/apps/web/src/app/features/world/local-location.store.ts +++ b/apps/web/src/app/features/world/local-location.store.ts @@ -74,9 +74,14 @@ export class LocalLocationStore { this.interactionErrorState.set(null); try { - this.interactionResultState.set( - await firstValueFrom(this.api.runLocationInteraction(interactionKey)), - ); + const result = await firstValueFrom(this.api.runLocationInteraction(interactionKey)); + this.interactionResultState.set(result); + + if (result.discoveredLocation) { + // The connection list is server-filtered, so a fresh reveal only + // shows up after the location is re-read. + await this.load(); + } } catch (error) { this.interactionErrorState.set(this.toErrorMessage(error)); } finally { diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html index 660653d..d7387f5 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html @@ -20,6 +20,11 @@

{{ result.title }}

{{ result.text }}

+ @if (result.discoveredLocation; as discovered) { +

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

+ }
} @else { diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss index 150808f..b4ee03c 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss @@ -63,6 +63,14 @@ color: var(--ar-danger); } +.interaction-panel__discovery { + margin: 0; + color: var(--ar-success); + font-size: 0.95rem; + font-weight: 700; + line-height: 1.55; +} + .interaction-panel__close { justify-self: end; padding: var(--ar-space-2) var(--ar-space-5); diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts index f874d31..735b7ac 100644 --- a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts @@ -2,7 +2,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LocationInteractionPanelComponent } from './location-interaction-panel.component'; async function setup(inputs: { - result?: { interactionKey: string; title: string; text: string; img?: string } | null; + result?: + | { + interactionKey: string; + title: string; + text: string; + img?: string; + discoveredLocation?: { key: string; name: string } | null; + } + | null; error?: string | null; }): Promise<{ fixture: ComponentFixture; @@ -110,4 +118,31 @@ describe('LocationInteractionPanelComponent', () => { expect(element.querySelector('[data-interaction-panel]')).toBeNull(); }); + + 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(); + }); }); diff --git a/apps/web/src/app/features/world/location-page/location-page.component.ts b/apps/web/src/app/features/world/location-page/location-page.component.ts index 0c437ab..b112025 100644 --- a/apps/web/src/app/features/world/location-page/location-page.component.ts +++ b/apps/web/src/app/features/world/location-page/location-page.component.ts @@ -14,6 +14,8 @@ import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.c const RUNTIME_ARTWORK: Readonly> = { '/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg', '/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg', + '/images/backgrounds/Wachturm.png': '/images/backgrounds/runtime/Wachturm-960.jpg', + '/images/backgrounds/Aschengrube.png': '/images/backgrounds/runtime/Aschengrube-960.jpg', }; /** diff --git a/apps/web/src/app/shared/monster-artwork.spec.ts b/apps/web/src/app/shared/monster-artwork.spec.ts index 0c53e13..d381516 100644 --- a/apps/web/src/app/shared/monster-artwork.spec.ts +++ b/apps/web/src/app/shared/monster-artwork.spec.ts @@ -1,4 +1,9 @@ -import { monsterCutoutPath, monsterIconPath, runtimeMonsterArtworkPath } from './monster-artwork'; +import { + combatMonsterSpriteScale, + monsterCutoutPath, + monsterIconPath, + runtimeMonsterArtworkPath, +} from './monster-artwork'; describe('runtimeMonsterArtworkPath', () => { it('returns the optimized JPEG derivative for a known monster artwork path', () => { @@ -42,3 +47,32 @@ describe('monsterIconPath', () => { expect(monsterIconPath('dawnwolf')).toBeUndefined(); }); }); + +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'), + ); + }); +}); diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts index 680cb85..dd54201 100644 --- a/apps/web/src/app/shared/monster-artwork.ts +++ b/apps/web/src/app/shared/monster-artwork.ts @@ -3,6 +3,10 @@ const RUNTIME_MONSTER_ARTWORK: Readonly> = { '/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg', '/images/monsters/wild-road-dog.png': '/images/monsters/runtime/wild-road-dog-560.png', '/images/monsters/charred-looter.png': '/images/monsters/runtime/charred-looter-560.png', + '/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', }; export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined { @@ -16,6 +20,10 @@ const MONSTER_CUTOUT: Readonly> = { 'road-bandit': '/images/combat/sprites/road-bandit-620.png', 'wild-road-dog': '/images/combat/sprites/wild-road-dog-760.png', 'charred-looter': '/images/combat/sprites/charred-looter-620.png', + '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', }; const MONSTER_ICON: Readonly> = { @@ -23,6 +31,10 @@ const MONSTER_ICON: Readonly> = { 'road-bandit': '/images/combat/icons/road-bandit-128.png', 'wild-road-dog': '/images/combat/icons/wild-road-dog-128.png', 'charred-looter': '/images/combat/icons/charred-looter-128.png', + 'raider-scout': '/images/combat/icons/raider-scout-128.png', + 'raider-veteran': '/images/combat/icons/raider-veteran-128.png', + 'burned-hound': '/images/combat/icons/burned-hound-128.png', + 'raider-captain': '/images/combat/icons/raider-captain-128.png', }; // Share of the battlefield height each monster sprite occupies, so a hulking @@ -32,6 +44,10 @@ const COMBAT_MONSTER_SCALE: Readonly> = { 'road-bandit': 0.82, 'wild-road-dog': 0.58, 'charred-looter': 0.86, + 'raider-scout': 0.78, + 'raider-veteran': 0.84, + 'burned-hound': 0.6, + 'raider-captain': 0.9, }; const DEFAULT_MONSTER_SCALE = 0.6; diff --git a/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md b/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md new file mode 100644 index 0000000..0913a7d --- /dev/null +++ b/docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md @@ -0,0 +1,319 @@ +# Slice 0.10 – Implementation Notes + +**Companion to:** `0.10-Abandoned-Watchpost.md` +**Status:** Implemented, verified except the two items listed under +"Outstanding manual verification" below. + +This records what actually got built, and where it differs from the +specification. Read it alongside the slice document, not instead of it. + +--- + +## 1. The discovery model + +Slice 0.10 needed a gate that is not a level check: the route to the Ash Pit +should stay hidden until the player has actually found it (§9). That turned +into three pieces. + +**`character_location_discoveries`** (migration `1798000000000`) is a plain +join table: `character_id`, `location_id`, `discovered_at`, with a unique +index on the pair. It is player state and nothing else — it says which +places a character has found, not which places are gated. A row is written +once and never updated, so the unique index is the whole concurrency story +(the insert uses `ON CONFLICT DO NOTHING` rather than a read-then-write). + +**`requires_discovery`** is a boolean column added to `location_connections`. +This is the deliberate half of the design: whether a route is gated at all is +content, not player state, and it lives on the connection row, not on the +location. That means a place can be reachable by one road and hidden behind +another — the Watchpost → Ash Pit leg carries `requiresDiscovery: true`, and +every other seeded connection, including the return leg from the Ash Pit, +carries `false`. The way back is never gated. + +**`WorldDiscoveryService`** (`apps/api/src/world/discovery/world-discovery.service.ts`) +is the one place that knows how to turn "does this character know about +location X" into a yes/no. It exposes: + +- `getDiscoveredLocationIds(characterId)` — the character's known-location + set, loaded once per caller. +- `discover(characterId, locationKey)` — records a discovery, returns the + location the first time and `null` on a repeat, so a caller can tell a + fresh reveal from a re-click without a second query. +- `isTravelAllowed(characterId, connection)` — the convenient single-connection + check, used by `TravelService`. +- `isRouteOpen(discoveredLocationIds, connection)` — the same rule, but pure + and synchronous over an already-loaded set, used by `WorldService` when it + needs to filter a whole list of connections at once. + +### Why the gate is enforced in two places + +The map has to hide the Ash Pit route before it is discovered +(`WorldService.getCurrentLocation` filters `connections` through +`isRouteOpen`), and travel has to refuse it even if a client somehow requests +it anyway (`TravelService.travelTo` calls `isTravelAllowed` inside the same +transaction that locks the character). Two call sites, not one, because the +map is a hint and travel is the authority — a client cannot be trusted to +only ever request what its own map shows it. + +The risk with two call sites is drift: someone tightens the rule in one and +forgets the other, and either a hidden route becomes travelable or a visible +one becomes untravelable. That risk is closed by having both call sites go +through the same predicate, `isRouteOpen`. `isTravelAllowed` is a two-line +wrapper around it (load the discovery set, call the predicate); `getCurrentLocation` +calls it directly per connection. There is exactly one place that decides +whether a route is open, and both consumers hand it the same discovery set +and the same connection shape (`toLocationId` + `requiresDiscovery`). A +change to the rule cannot land in one caller without landing in the other, +because there is only one caller of the rule itself. + +`WorldService.runLocalInteraction` is the third piece: a point of interest +carrying a `discoversLocationKey` calls `discover()` before it returns its +result text, so the reveal and the narrative beat happen in the same request +(§3.4, §8). The Watchpost's `inspect-watchpost` hotspot is the only POI that +does this in this slice — see §3 below. + +--- + +## 2. `guard` and `enrage`: content-driven combat abilities + +Both are configuration on `Monster.abilities`, read by +`CombatEngineService.resolveMonsterTurn` and `checkEnrage` — no monster-specific +code, the same pattern the existing `telegraph` and `bleed` abilities already +used. + +### guard + +```ts +guard: { roundInterval: number; armorBonus: number; durationRounds: number } +``` + +On a round where `shouldTrigger(guard, round)` fires, the monster raises its +guard instead of attacking: `activeGuard = { remainingRounds, armorBonus }`, +and a `GUARD_RAISED` event is emitted. While active, `effectiveArmor()` adds +`armorBonus` on top of the monster's base armor for damage calculation. +`ageGuard` counts one round off at the start of the monster's turn, *before* +`resolveMonsterTurn` runs, so the round the guard is raised is not the round +it starts expiring — a guard raised with `durationRounds: 2` is still up two +full monster turns later, then drops (`GUARD_ENDED`). + +Shield Bash (the player's existing interrupt action) breaks an active guard +the same way it breaks a pending Heavy Strike: one `INTERRUPT` event even if +it happens to break both at once, because the player made one interruptive +action, not two, followed by a `GUARD_ENDED` event for the guard specifically. + +Configured on Raider Veteran (`roundInterval: 4, armorBonus: 10, durationRounds: 2`) +and Raider Captain (`roundInterval: 3, armorBonus: 12, durationRounds: 2`). + +### enrage + +```ts +enrage: { hpThresholdPercent: number; damageMultiplier: number } +``` + +`checkEnrage` runs at the start of `resolveMonsterTurn`, before the monster +acts. The first time the monster's current HP is at or below +`hpThresholdPercent` of its max HP, `enraged` latches permanently true and an +`ENRAGED` event fires. From then on, every hit the monster lands is scaled by +`damageMultiplier` in `strikePlayer`. It is checked before the monster's own +turn resolves, so the blow that wounded it below the threshold is already +answered in kind that same round. + +Configured on Burned Hound (`hpThresholdPercent: 35, damageMultiplier: 1.4`). + +### Priority inside a monster's turn + +`resolveMonsterTurn` checks, in order, on every round: + +1. **Pending Heavy Strike** — if last round's `telegraph` set + `pendingAction = 'HEAVY_ATTACK'`, it lands now, at the telegraphed + multiplier, and nothing else happens this turn. +2. **Telegraph** — if `shouldTrigger(telegraph, round)`, the monster winds up + (`pendingAction` set, `TELEGRAPH` event, turn ends). +3. **Guard** — if `shouldTrigger(guard, round)`, the monster raises its guard + (`GUARD_RAISED` event, turn ends). +4. **Normal attack** — otherwise the monster strikes normally, with `bleed` + (if configured) applied on top. + +Because a telegraph check happens before the guard check in the same +function, **a telegraph wins when both abilities are due in the same round** +— the monster winds up instead of guarding, and the guard's own interval +simply is not re-checked until its next due round. Raider Veteran's +intervals (telegraph every 3 rounds, guard every 4) were chosen so the two +only actually coincide every twelfth round, keeping this edge case rare +without hiding it. + +`CombatEngineCombatantStats.activeGuard` and `.enraged` are read back into +`CombatMonsterDto.guardRemainingRounds: number | null` and `enraged: boolean` +so the web client can render the guard/enrage banners without any extra +lookup. + +--- + +## 3. Deviations from the slice document + +### No surviving guard NPC (§3) + +§3 lists "Speak with the remaining guard/NPC if present" among the minimum +Watchpost interactions. There is no such NPC in this slice: no portrait +artwork exists for a Watchpost guard, and inventing one purely to satisfy the +checklist would mean shipping a placeholder face the project has no art for. + +Instead, the investigation §8 asks for is an inspectable hotspot — +`inspect-watchpost`, type `INVESTIGATE` — that delivers the §8 clue text +directly and triggers the Ash Pit discovery. The Watchpost also has a second, +flavour-only hotspot (`search-guard-quarters`) that gestures at the missing +guard without personifying them: "a duty roster with every name scratched +out but one." The location is not empty of story, it just tells it through +place rather than through a person §3 has no art budget for. + +### The crossed raider artwork (design decision D7) + +The hand-painted art files `art/enemies/raider-scout.png` and +`art/enemies/raider-veteran.png` are, by their content, swapped relative to +their filenames: the file named *scout* depicts the heavier, plated, +spear-carrying figure, and the file named *veteran* depicts the leaner one. + +Rather than force the Veteran's guard-and-telegraph mechanics onto the art +that reads as a light skirmisher, the web-facing keys are crossed at +generation time: the runtime key `raider-scout` is derived from +`art/enemies/raider-veteran.png`, and `raider-veteran` from +`art/enemies/raider-scout.png`. This is deliberate and recorded at the point +it happens, in `tools/derive-monster-assets.ps1`: + +```powershell +# 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. +``` + +Approved by the project owner. The generated files under +`apps/web/public/images/...` are named correctly for their in-game role; only +the source art's own filenames are crossed. + +--- + +## 4. The Ash Pit stub + +The Ash Pit (`key: 'ash-pit'`) exists in this slice only as a destination the +discovery gate can point at — the place §8's clue promises, reachable once +found, but not yet a location with content of its own. Concretely: + +- `huntingEnabled: false` — no encounter pool. +- `locationType: 'TRANSITION'`. +- One point of interest: a `MAP` hotspot back to the world map. Nothing to + investigate, nothing to fight, nothing to trade. +- Real location artwork (`Aschengrube.png`) and a description, so arriving + there does not feel like a broken link — it feels like a threshold. + +This matches the slice document's own scope: §9 asks only that the route +become discoverable and travelable, and §13 explicitly rules a second region +out of Slice 0.10. Slice 0.11 (`0.11-Ash-Pit-and-Ashen-Band-Captain.md`) is +where the Ash Pit gets an encounter pool, its own trade goods, and the +Captain of the Ashen Band as an area boss — everything this slice's stub +deliberately left out. + +--- + +## 5. `tools/derive-monster-assets.ps1` + +Generates, per monster, the four web assets the game actually serves from +the hand-painted source art in `art/enemies` and `art/backgrounds`: + +- `apps/web/public/images/monsters/.png` — full painted artwork +- `apps/web/public/images/monsters/runtime/-560.jpg` — downscaled web copy +- `apps/web/public/images/combat/sprites/-.png` — background-free + combat cutout +- `apps/web/public/images/combat/icons/-128.png` — medallion icon, + cropped to frame the head (crop window tuned per monster) + +Plus the two background plates (`Wachturm.png`, `Aschengrube.png`) and their +downscaled runtime copies. + +The generated output is committed, so the script is not part of any build or +CI step. It only needs to be re-run when the **source art changes** — a new +or replaced file under `art/enemies` or `art/backgrounds`, a re-crop, or a +correction to the crossed-key mapping in §3 above. It is safe to re-run at +any time: it overwrites only its own generated output and touches nothing +else. The Raider Captain's icon is the one exception the script itself +documents — it resizes the hand-made `PluendererhauptmannIcon.png` rather +than generating a crop, because authored art beats a generated one, but it +still resizes it to 128×128 rather than shipping the 1254×1254 source +verbatim. + +--- + +## 6. Known gaps + +Carried over from the per-task reviews in the SDD ledger — real, but judged +not worth blocking the slice on. Grouped rather than listed one by one. + +**Untested edge cases in the guard/enrage engine.** No test pins the exact +HP threshold boundary for enrage (`currentHp === threshold`, only +strictly-above and strictly-below are covered); no test covers Shield Bash +breaking a pending Heavy Strike *and* an active guard in the same action +(the single-`INTERRUPT` branch is verified only by inspection, see §2 above); +and a Shield Bash that drives the monster below its enrage threshold delays +the enrage latch by one round, because `resolveMonsterTurn` — and therefore +`checkEnrage` — is skipped on an interrupted turn. This is the engine's +existing skip-on-interrupt behavior, not new to this slice, but it was +previously undocumented. + +**Weak coverage on data, not code.** No test protects the encounter-pool +weights or the Ash Pit legs' `travelDurationSeconds` / `ambushChance` values +— a mistyped weight or ambush chance would pass every test unnoticed. No +test pins the absence of `discoversLocationKey` on the client-facing POI +payload; the DTO's field whitelist makes leakage structurally impossible +today, but a future spread-based refactor could reintroduce it silently. + +**Loose assertions on generated assets and events.** The four new +monster-artwork tests assert `toBeDefined()` on registry entries rather than +exact paths, and never touch the filesystem — a registration pointing at a +missing file would still pass. The `GUARD_RAISED` event's `amount` payload +(the guard's `durationRounds`) is never asserted, only its `type`. A latent +bug in `LocalLocationStore.runInteraction`, noted while wiring the discovery +reveal through: when an interaction discovers a location, the store +re-`load()`s so the newly-visible connection appears; if that reload throws, +its rejection lands in the same `catch` that already set a successful +`interactionResultState`, so `interactionErrorState` ends up set behind a +non-null result the template never surfaces. Untested and invisible today, +but a trap for a future consumer of `interactionError()`. + +None of these were judged to change behavior a player can hit; they are +seams a future slice's tests should tighten, most likely whichever slice +next touches the combat engine or the seed's encounter-pool weights. + +--- + +## 7. Outstanding manual verification + +Everything below could not be run in the environment this slice was built +and verified in: `.env` is gitignored and absent from this worktree, so +`DATABASE_URL` is unset and no PostgreSQL instance is reachable. Neither the +API nor the web dev server was started, and no migration or seed command was +run. The project owner must do both of the following before treating this +slice as done: + +**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 a second time with no +duplicate-key error and no duplicated rows (AGENTS.md §8). + +**2. Walk the loop in the browser**, with the app started +(`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. diff --git a/docs/playable-slices/0.10-Abandoned-Watchpost.md b/docs/playable-slices/0.10-Abandoned-Watchpost.md index a98ba35..ae01715 100644 --- a/docs/playable-slices/0.10-Abandoned-Watchpost.md +++ b/docs/playable-slices/0.10-Abandoned-Watchpost.md @@ -226,16 +226,85 @@ Graufurt ## 12. Acceptance Criteria -- [ ] Abandoned Watchpost exists as a full playable location. -- [ ] Travel from Burned Road works with server-authoritative timing. -- [ ] Location has a stronger, distinct encounter pool. -- [ ] At least one stronger enemy combines previously learned mechanics. -- [ ] Both HIDE and RAIDER_TROPHY carrying systems matter. -- [ ] Tier-1 equipment progression is meaningfully improved here. -- [ ] Story/investigation points toward the Ash Pit. -- [ ] Ash Pit route can be discovered without a level gate. -- [ ] Existing merchant/reputation loop continues to work. -- [ ] All player-facing content is English. +- [x] Abandoned Watchpost exists as a full playable location. +- [x] Travel from Burned Road works with server-authoritative timing. +- [x] Location has a stronger, distinct encounter pool. +- [x] At least one stronger enemy combines previously learned mechanics. +- [x] Both HIDE and RAIDER_TROPHY carrying systems matter. +- [x] Tier-1 equipment progression is meaningfully improved here. +- [x] Story/investigation points toward the Ash Pit. +- [x] Ash Pit route can be discovered without a level gate. +- [x] Existing merchant/reputation loop continues to work. +- [x] All player-facing content is English. + +### Verification status + +Every criterion above is supported by evidence from the automated test suite, +the build, or the seeded content itself — no criterion here needed the +running app to confirm structurally: + +1. **Full playable location** — seeded as an `OUTPOST` with four points of + interest (hunt, investigate, search, map-out) and its own encounter pool + (`vertical-slice.seed.spec.ts`: "seeds the watchpost as a huntable + outpost"); local content in `local-location.content.ts`. +2. **Server-authoritative travel** — the Burned Road ↔ Watchpost connection + is seeded both ways at 15 s / 10 % ambush (`"connects the burned road and + the watchpost both ways without a gate"`); `TravelService` computes + `arrivesAt` server-side and is covered generically by + `travel.service.spec.ts`. +3. **Stronger, distinct pool** — `"gives the watchpost its own encounter + pool"` seeds exactly Road Bandit, Raider Scout, Raider Veteran, Burned + Hound and the rare Raider Captain; `"marks only the captain as a rare + encounter"` confirms the rarity split. +4. **An enemy combining learned mechanics** — the Raider Veteran carries both + `telegraph` (existing, from the Burned Road) and the new `guard` + (`"arms the veteran with a telegraph and a guard on different + cadences"`); the priority between them is covered in + `combat-engine.service.spec.ts`. +5. **Both bag categories matter** — Scorched Hide is seeded `HIDE`, Raider + Warband Mark is seeded `RAIDER_TROPHY` + (`vertical-slice.seed.spec.ts`), and both categories were already + load-bearing bag mechanics before this slice (Slice 0.7.5/0.9). +6. **Tier-1 equipment improved** — the Raider Veteran's own loot table adds + Plunderer Gloves, Reinforced Leather Jacket and Watchman's Leggings on top + of the Raider Warband Mark, and the Raider Scout carries Bandit Hood at a + raised chance (`item-content.ts`); the Road Bandit's own table (already + present) is left untouched at the same values + (`"leaves the road bandit loot table untouched"` pins Bandit Blade at + `0.1800`), so the Watchpost's gear opportunities are additive, not a + rebalance of the Burned Road. +7. **Investigation points to the Ash Pit** — the `inspect-watchpost` hotspot + carries the §8 clue text verbatim and `discoversLocationKey: 'ash-pit'` + (`"points the watchpost investigation at the ash pit"`). +8. **Ash Pit discoverable without a level gate** — the gate is + `requiresDiscovery`, not `minRecommendedLevel`; `WorldDiscoveryService` + contains no level check at all. Covered end to end: the hotspot writes the + discovery (`local-location-interaction.spec.ts`: `"discovers the route the + hotspot points at"`), the map hides/reveals it + (`world.service.spec.ts`: `"hides a gated connection until the character + has discovered it"` / `"shows a gated connection once it has been + discovered"`), and travel itself refuses/allows it + (`world-discovery.service.spec.ts` and `travel.service.spec.ts`, both: + `"refuses a gated route the character has not discovered"` / + `"allows a gated route once it has been discovered"`). +9. **Merchant/reputation loop continues** — both new trade goods have + exchange rules paying Silver and regional reputation, and pay more than + their Burned Road equivalents (`"lets Borin buy both watchpost trade + goods"`, `"pays more for watchpost goods than for road goods"`). No new + code path grants Silver, reputation or Renown directly from a kill; the + pack-wide rule (README.md) that only the exchange grants those was already + enforced before this slice and nothing in Slice 0.10 bypasses it. +10. **English content** — every string seeded for the Watchpost and Ash Pit + (descriptions, hotspot titles and result text, monster flavour text) was + read during this review and is English. + +What this status does **not** cover, because it cannot be produced by static +evidence: actually applying migration `1798000000000` to a real PostgreSQL +database, confirming the second `db:seed` run is idempotent against real +constraints, and a hand-played pass through the loop in a browser. Those are +listed precisely in the implementation notes +(`0.10-Abandoned-Watchpost-implementation-notes.md`, §7) as outstanding work +for the project owner. --- diff --git a/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md b/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md new file mode 100644 index 0000000..e039e13 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md @@ -0,0 +1,3261 @@ +# Playable Slice 0.10 — Abandoned Watchpost Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the Abandoned Watchpost as a full playable location with its own encounter pool, trade goods and equipment progression, plus the first non-level world gate: an Ash Pit route the player has to discover. + +**Architecture:** Three genuinely new mechanisms — a per-character discovery table gating individual travel connections, two content-driven combat abilities (`guard`, `enrage`) added to the ability set the engine already reads, and a re-runnable PowerShell script that derives per-monster web assets from the committed art. Everything else is seed content that flows through systems that already exist. + +**Tech Stack:** NestJS 11, TypeORM, PostgreSQL, Jest (API); Angular 20 standalone components, Karma/Jasmine (web); PowerShell + System.Drawing for asset derivation. + +**Spec:** `docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md` + +## Global Constraints + +- All player-facing content is **English** (slice §12, AGENTS §33). +- Server is authoritative: every gate enforced in the API, never only in the UI (AGENTS §5). +- No normal enemy grants Silver, XP or reputation directly (slice §6). Silver reaches the player only through Borin's exchange. +- No `requiredLevel` gating anywhere in this slice (slice §2, §9). +- Combat stays deterministic: fixed cadences, no hidden rolls (AGENTS §10). +- Monster behaviour is content, never an engine branch on a monster key (AGENTS §9). +- Seeds are idempotent — re-running must never duplicate rows (AGENTS §8). +- Do not retune existing Burned Road content. This slice adds; it does not rebalance (AGENTS §39). +- API tests: `npm test --workspace=@ashen-realms/api`. Web tests: `npm test --workspace=@ashen-realms/web`. +- Every task ends with a commit. Commit messages follow the repo's Conventional Commits style (`feat(api):`, `test(seed):`, `docs:`). + +### Stable IDs used throughout this plan + +```text +ABANDONED_WATCHPOST_ID 20000000-0000-4000-8000-000000000003 +ASH_PIT_ID 20000000-0000-4000-8000-000000000004 +RAIDER_SCOUT_MONSTER_ID 30000000-0000-4000-8000-000000000005 +BURNED_HOUND_MONSTER_ID 30000000-0000-4000-8000-000000000006 +RAIDER_VETERAN_MONSTER_ID 30000000-0000-4000-8000-000000000007 +RAIDER_CAPTAIN_MONSTER_ID 30000000-0000-4000-8000-000000000008 +scorched-hide item 50000000-0000-4000-8000-000000000010 +raider-warband-mark item 50000000-0000-4000-8000-000000000011 +RAIDER_SCOUT_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000005 +BURNED_HOUND_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000006 +RAIDER_VETERAN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000007 +RAIDER_CAPTAIN_LOOT_TABLE_ID 60000000-0000-4000-8000-000000000008 +``` + +--- + +### Task 1: Migration — discovery table, connection gate, combat event types + +**Files:** +- Create: `apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts` +- Test: `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: table `character_location_discoveries`, column `location_connections.requires_discovery`, enum values `GUARD_RAISED` / `GUARD_ENDED` / `ENRAGED` on `combat_event_type_enum`. Class name `CreateAbandonedWatchpost1798000000000`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts`: + +```ts +import 'reflect-metadata'; +import { QueryRunner } from 'typeorm'; +import { CreateAbandonedWatchpost1798000000000 } from './1798000000000-CreateAbandonedWatchpost'; + +/** + * The migration writes multi-line SQL, so every assertion below reads it with + * runs of whitespace collapsed -- the same harness the other migration specs + * use, so reindenting a statement never breaks a test that still describes + * the right schema. + */ +function collapse(statements: string[]): string { + return statements.map((sql) => sql.replace(/\s+/g, ' ').trim()).join('\n'); +} + +async function runUp(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + await new CreateAbandonedWatchpost1798000000000().up(queryRunner); + return collapse(query.mock.calls.map(([sql]) => sql as string)); +} + +async function runDown(): Promise { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + const migration = new CreateAbandonedWatchpost1798000000000(); + await migration.up(queryRunner); + const upCount = query.mock.calls.length; + await migration.down(queryRunner); + return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string)); +} + +describe('CreateAbandonedWatchpost1798000000000', () => { + it('creates the discovery table', async () => { + const joined = await runUp(); + + expect(joined).toContain('CREATE TABLE "character_location_discoveries"'); + }); + + it('lets a character discover a location only once', async () => { + const joined = await runUp(); + + // The unique index, not a disabled button, is what makes a repeated + // investigation harmless (AGENTS.md §30). + expect(joined).toContain( + 'CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")', + ); + }); + + it('cascades discoveries away with their character and location', async () => { + const joined = await runUp(); + + expect(joined).toContain( + 'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE', + ); + expect(joined).toContain( + 'FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE', + ); + }); + + it('adds an ungated-by-default discovery flag to connections', async () => { + const joined = await runUp(); + + // Default false: every route that exists today stays walkable. + expect(joined).toContain( + 'ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false', + ); + }); + + it('extends the combat event enum with the two new mechanics', async () => { + const joined = await runUp(); + + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + expect(joined).toContain( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + }); + + it('reverses the table and the column', async () => { + const joined = await runDown(); + + expect(joined).toContain('DROP TABLE "character_location_discoveries"'); + expect(joined).toContain( + 'ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"', + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- create-abandoned-watchpost` +Expected: FAIL — `Cannot find module './1798000000000-CreateAbandonedWatchpost'` + +- [ ] **Step 3: Write the migration** + +Create `apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts`: + +```ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Schema for the Abandoned Watchpost (Playable Slice 0.10 §9, §5). + * + * Two unrelated-looking things in one migration because they arrive with one + * slice: the world gate that hides the Ash Pit route until it is found, and + * the two combat event types the Raider Veteran's new mechanics emit. + * + * `character_location_discoveries` is player state and nothing else -- which + * location is gated at all is content, and lives on the connection + * (AGENTS.md §7). A connection carrying its own gate means a place can be + * reachable by one road and hidden behind another. + */ +export class CreateAbandonedWatchpost1798000000000 + implements MigrationInterface +{ + name = 'CreateAbandonedWatchpost1798000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "character_location_discoveries" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "character_id" uuid NOT NULL, + "location_id" uuid NOT NULL, + "discovered_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_character_location_discoveries" PRIMARY KEY ("id"), + CONSTRAINT "FK_character_location_discoveries_character" + FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE, + CONSTRAINT "FK_character_location_discoveries_location" + FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")`, + ); + + await queryRunner.query( + `ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false`, + ); + + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_character_location_discoveries_pair"`, + ); + await queryRunner.query(`DROP TABLE "character_location_discoveries"`); + await queryRunner.query( + `ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"`, + ); + + // Postgres cannot drop an enum value, so the type is rebuilt -- the same + // tradeoff migration 1790 already makes. Fails if any row uses one of the + // new values, which is the expected shape of a dev rollback. + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`, + ); + await queryRunner.query(`DROP TYPE "combat_event_type_enum"`); + await queryRunner.query( + `CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED', 'COMBAT_WON', 'COMBAT_LOST')`, + ); + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`, + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- create-abandoned-watchpost` +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/database/migrations/1798000000000-CreateAbandonedWatchpost.ts apps/api/src/database/migrations/create-abandoned-watchpost.migration.spec.ts +git commit -m "feat(api): add schema for watchpost discovery and guard events" +``` + +--- + +### Task 2: Combat engine — the `guard` ability + +**Files:** +- Modify: `apps/api/src/monsters/monster-abilities.ts` +- Modify: `apps/api/src/combat/combat-event-type.enum.ts` +- Modify: `apps/api/src/combat/combat-engine.types.ts` +- Modify: `apps/api/src/combat/combat-engine.service.ts` +- Test: `apps/api/src/combat/combat-engine.service.spec.ts` + +**Interfaces:** +- Consumes: `calculateDamage(attacker, targetArmor, multiplier)` from `combat-damage.ts`. +- Produces: `MonsterGuardAbility { roundInterval, armorBonus, durationRounds }` on `MonsterAbilities.guard`; `CombatEngineCombatantStats.activeGuard?: { remainingRounds: number; armorBonus: number }`; event types `GUARD_RAISED`, `GUARD_ENDED`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/combat/combat-engine.service.spec.ts`, inside the top-level `describe`: + +```ts +const VETERAN_ABILITIES: MonsterAbilities = { + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, +}; + +describe('guard', () => { + it('raises its guard instead of attacking on the interval round', () => { + const state = baseState({ + round: 4, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { attack: 14, armor: 10, abilities: VETERAN_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.player.currentHp).toBe(100); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.GUARD_RAISED, + ); + expect(result.state.monster.stats.activeGuard).toEqual({ + remainingRounds: 2, + armorBonus: 10, + }); + }); + + it('adds the guard bonus to the armor the player has to cut through', () => { + const guarded = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(guarded, { + action: CombatAction.ATTACK, + }); + + // 14 raw damage against armor 20 instead of 10: 14*60/80 = 10.5 -> 11. + const damage = result.events.find( + (event) => event.type === CombatEventType.DAMAGE, + ); + expect(damage?.amount).toBe(11); + }); + + it('drops the guard when its rounds run out', () => { + const state = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 1, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.GUARD_ENDED, + ); + }); + + it('lets Shield Bash break an active guard', () => { + const state = baseState({ + round: 5, + monster: withAbilities(VETERAN_ABILITIES, { + currentHp: 120, + maxHp: 120, + stats: { + attack: 14, + armor: 10, + abilities: VETERAN_ABILITIES, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.SHIELD_BASH, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + const types = result.events.map((event) => event.type); + expect(types).toContain(CombatEventType.INTERRUPT); + expect(types).toContain(CombatEventType.GUARD_ENDED); + }); + + it('lets a telegraph win when both are due in the same round', () => { + const collidingAbilities: MonsterAbilities = { + telegraph: { roundInterval: 2, damageMultiplier: 1.6 }, + guard: { roundInterval: 2, armorBonus: 10, durationRounds: 2 }, + }; + const state = baseState({ + round: 2, + monster: withAbilities(collidingAbilities, { + currentHp: 120, + maxHp: 120, + stats: { attack: 14, armor: 10, abilities: collidingAbilities }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + }); + + it('leaves a monster without the ability exactly as it was', () => { + const state = baseState({ round: 4 }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.activeGuard).toBeUndefined(); + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.GUARD_RAISED, + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: FAIL — `Property 'guard' does not exist on type 'MonsterAbilities'` and `GUARD_RAISED` missing from `CombatEventType` + +- [ ] **Step 3: Add the ability type** + +In `apps/api/src/monsters/monster-abilities.ts`, add before `MonsterAbilities`: + +```ts +export interface MonsterGuardAbility { + /** + * The monster forgoes its attack on every round divisible by this and + * covers instead, raising its armor for `durationRounds`. SHIELD_BASH + * breaks it, the same answer the telegraph already taught (Playable Slice + * 0.10 §5). + */ + roundInterval: number; + armorBonus: number; + durationRounds: number; +} +``` + +and extend the interface: + +```ts +export interface MonsterAbilities { + telegraph?: MonsterTelegraphAbility; + bleed?: MonsterBleedAbility; + guard?: MonsterGuardAbility; +} +``` + +- [ ] **Step 4: Add the event types** + +In `apps/api/src/combat/combat-event-type.enum.ts`, add to the enum after `INTERRUPT`: + +```ts + GUARD_RAISED = 'GUARD_RAISED', + GUARD_ENDED = 'GUARD_ENDED', +``` + +- [ ] **Step 5: Add the engine state field** + +In `apps/api/src/combat/combat-engine.types.ts`, add to `CombatEngineCombatantStats` after `pendingAction`: + +```ts + // Monster-only: set when it covers, cleared when the rounds run out or a + // Shield Bash breaks it. Its bonus is added to the monster's armor while + // it lasts (Playable Slice 0.10 §7). + activeGuard?: { remainingRounds: number; armorBonus: number }; +``` + +- [ ] **Step 6: Implement the engine behaviour** + +In `apps/api/src/combat/combat-engine.service.ts`: + +Add the import: + +```ts +import type { + MonsterBleedAbility, + MonsterGuardAbility, + MonsterTelegraphAbility, +} from '../monsters/monster-abilities'; +``` + +Add a helper that reads the monster's effective armor, and use it in both places +the player's blow is calculated (`resolvePlayerStrike` and `resolveShieldBash`), +replacing `monster.stats.armor`: + +```ts + /** Armor the monster actually presents this round, guard included. */ + private effectiveArmor(combatant: CombatEngineCombatant): number { + return combatant.stats.armor + (combatant.stats.activeGuard?.armorBonus ?? 0); + } +``` + +```ts + const damage = calculateDamage( + player.stats, + this.effectiveArmor(monster), + multiplier, + ); +``` + +In `resolveShieldBash`, extend the interrupt block so it also breaks a guard: + +```ts + let interrupted = false; + if (monster.stats.pendingAction) { + monster.stats.pendingAction = undefined; + interrupted = true; + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }); + } + + // A guard is a prepared stance like a wind-up, and the bash answers both. + // Only one INTERRUPT is emitted even when the bash breaks both at once -- + // the player made one interruption, not two. + if (monster.stats.activeGuard) { + monster.stats.activeGuard = undefined; + if (!interrupted) { + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }); + } + interrupted = true; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_ENDED, + }); + } +``` + +In `resolveMonsterTurn`, insert the guard branch after the telegraph branch and +before the normal strike: + +```ts + if (this.shouldTrigger(abilities.guard, round)) { + const guard = abilities.guard as MonsterGuardAbility; + monster.stats.activeGuard = { + remainingRounds: guard.durationRounds, + armorBonus: guard.armorBonus, + }; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_RAISED, + amount: guard.durationRounds, + }); + return; + } +``` + +Widen the `shouldTrigger` signature to accept the new ability: + +```ts + private shouldTrigger( + ability: + | MonsterTelegraphAbility + | MonsterBleedAbility + | MonsterGuardAbility + | undefined, + round: number, + ): boolean { +``` + +Age the guard **before** the monster acts. In `finishRound`, immediately before +the `if (!interrupted)` block, add: + +```ts + this.ageGuard(monster, events); +``` + +Order matters here. The player's blow for this round was already calculated +against the guard by the time `finishRound` runs, and the guard is raised inside +`resolveMonsterTurn` — which comes after this line. So a guard raised on round N +is first aged on round N+1, and `durationRounds: 2` means it turns aside the +player's next two attacks. Ageing after `resolveMonsterTurn` instead would eat a +round off the guard the moment it went up. + +Placing it before the `if (!interrupted)` guard also means the counter runs down +even on a round the monster was interrupted out of, so a Shield Bash on a +telegraph never accidentally extends an unrelated guard. + +Add the method: + +```ts + /** + * Counts one round off an active guard, and drops it when it runs out. + * + * Called before the monster acts, so the guard it raises this round is not + * immediately aged: `durationRounds: 2` turns aside the player's next two + * attacks. + */ + private ageGuard( + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + ): void { + const guard = monster.stats.activeGuard; + if (!guard) { + return; + } + + const remainingRounds = guard.remainingRounds - 1; + if (remainingRounds > 0) { + monster.stats.activeGuard = { ...guard, remainingRounds }; + return; + } + + monster.stats.activeGuard = undefined; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.GUARD_ENDED, + }); + } +``` + +Finally, clear the guard on death alongside `pendingAction` in `finishRound`: + +```ts + const defeatedMonster = { + ...monster, + stats: { + ...monster.stats, + pendingAction: undefined, + activeGuard: undefined, + }, + }; +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: PASS — all previously existing tests plus the six new `guard` tests + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts +git commit -m "feat(api): let monsters raise a breakable defensive guard" +``` + +--- + +### Task 3: Combat engine — the `enrage` ability + +**Files:** +- Modify: `apps/api/src/monsters/monster-abilities.ts` +- Modify: `apps/api/src/combat/combat-event-type.enum.ts` +- Modify: `apps/api/src/combat/combat-engine.types.ts` +- Modify: `apps/api/src/combat/combat-engine.service.ts` +- Test: `apps/api/src/combat/combat-engine.service.spec.ts` + +**Interfaces:** +- Consumes: `MonsterAbilities` from Task 2. +- Produces: `MonsterEnrageAbility { hpThresholdPercent, damageMultiplier }` on `MonsterAbilities.enrage`; `CombatEngineCombatantStats.enraged?: boolean`; event type `ENRAGED`. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/api/src/combat/combat-engine.service.spec.ts`: + +```ts +const HOUND_ABILITIES: MonsterAbilities = { + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, +}; + +describe('enrage', () => { + it('turns aggressive once its HP crosses the threshold', () => { + // 80 max HP, 40 left; a 14-damage blow lands it on 26, below 35%. + const state = baseState({ + round: 1, + player: { + currentHp: 100, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 0 }, + }, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 40, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBe(true); + expect(result.events.map((event) => event.type)).toContain( + CombatEventType.ENRAGED, + ); + // The same round's reply already hits harder: 12 * 1.4 = 16.8 -> 17. + const monsterHit = result.events.find( + (event) => + event.type === CombatEventType.DAMAGE && + event.source === Combatant.MONSTER, + ); + expect(monsterHit?.amount).toBe(17); + }); + + it('stays quiet above the threshold', () => { + const state = baseState({ + round: 1, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 80, + maxHp: 80, + stats: { attack: 12, armor: 0, abilities: HOUND_ABILITIES }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.ATTACK, + }); + + expect(result.state.monster.stats.enraged).toBeUndefined(); + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + }); + + it('announces the change only once', () => { + const state = baseState({ + round: 2, + monster: withAbilities(HOUND_ABILITIES, { + currentHp: 20, + maxHp: 80, + stats: { + attack: 12, + armor: 0, + abilities: HOUND_ABILITIES, + enraged: true, + }, + }), + }); + + const result = new CombatEngineService().resolveAction(state, { + action: CombatAction.DEFEND, + }); + + expect(result.events.map((event) => event.type)).not.toContain( + CombatEventType.ENRAGED, + ); + expect(result.state.monster.stats.enraged).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: FAIL — `Property 'enrage' does not exist on type 'MonsterAbilities'` + +- [ ] **Step 3: Add the ability type** + +In `apps/api/src/monsters/monster-abilities.ts`: + +```ts +export interface MonsterEnrageAbility { + /** + * Once its HP first falls to or below this share of maximum, the monster + * hits harder for the rest of the fight. One deterministic state change, + * never reversed and never rolled (Playable Slice 0.10 §5). + */ + hpThresholdPercent: number; + damageMultiplier: number; +} +``` + +and add `enrage?: MonsterEnrageAbility;` to `MonsterAbilities`. + +- [ ] **Step 4: Add the event type and state field** + +In `apps/api/src/combat/combat-event-type.enum.ts` add `ENRAGED = 'ENRAGED',` after `GUARD_ENDED`. + +In `apps/api/src/combat/combat-engine.types.ts` add to `CombatEngineCombatantStats`: + +```ts + // Monster-only: latched the first time its HP crosses the enrage threshold. + enraged?: boolean; +``` + +- [ ] **Step 5: Implement the engine behaviour** + +In `apps/api/src/combat/combat-engine.service.ts`, add the check at the top of +`resolveMonsterTurn`, before the pending-action branch — the monster notices its +wounds before it decides what to do: + +```ts + this.checkEnrage(monster, events); +``` + +Add the method: + +```ts + /** + * Latches the enraged state the first time the monster's HP crosses its + * threshold. Checked before it acts, so the blow that wounded it is already + * answered in kind. + */ + private checkEnrage( + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + ): void { + const enrage = monster.stats.abilities?.enrage; + if (!enrage || monster.stats.enraged) { + return; + } + + const threshold = (monster.maxHp * enrage.hpThresholdPercent) / 100; + if (monster.currentHp > threshold) { + return; + } + + monster.stats.enraged = true; + events.push({ + source: Combatant.MONSTER, + target: Combatant.MONSTER, + type: CombatEventType.ENRAGED, + }); + } +``` + +Apply the multiplier where the monster strikes. In `strikePlayer`, fold it into +the multiplier passed in: + +```ts + private strikePlayer( + monster: CombatEngineCombatant, + player: CombatEngineCombatant, + multiplier: number, + events: CombatEngineEvent[], + ): void { + const enrageMultiplier = monster.stats.enraged + ? (monster.stats.abilities?.enrage?.damageMultiplier ?? 1) + : 1; + const damage = calculateDamage( + monster.stats, + player.stats.armor, + multiplier * enrageMultiplier, + ); + player.currentHp = Math.max(0, player.currentHp - damage); + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + } +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat-engine.service` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/monsters/monster-abilities.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts +git commit -m "feat(api): let wounded monsters turn aggressive" +``` + +--- + +### Task 4: Expose guard and enrage through the combat API + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Test: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CombatEngineCombatantStats.activeGuard` / `.enraged` from Tasks 2 and 3. +- Produces: `CombatMonsterDto.guardRemainingRounds: number | null` and `CombatMonsterDto.enraged: boolean`. + +Note: `combat.monsterState` is assigned straight from `result.state.monster.stats`, +so both fields already persist. Only the DTO needs work. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/api/src/combat/combat.service.spec.ts`, in the describe block that +covers the combat DTO: + +```ts + it('reports an active guard and an enraged monster to the client', async () => { + const { service, combat } = await startCombatFixture(); + combat.monsterState = { + ...combat.monsterState, + activeGuard: { remainingRounds: 2, armorBonus: 10 }, + enraged: true, + }; + + const dto = await service.getCombat(CHARACTER_ID, combat.id); + + expect(dto.monster.guardRemainingRounds).toBe(2); + expect(dto.monster.enraged).toBe(true); + }); + + it('reports no guard when the monster is not covering', async () => { + const { service, combat } = await startCombatFixture(); + + const dto = await service.getCombat(CHARACTER_ID, combat.id); + + expect(dto.monster.guardRemainingRounds).toBeNull(); + expect(dto.monster.enraged).toBe(false); + }); +``` + +If the existing spec has no `startCombatFixture` helper, use whatever fixture +factory the file already provides to obtain a started combat and its `Combat` +row; the two assertions are the point, not the helper's name. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- combat.service` +Expected: FAIL — `Property 'guardRemainingRounds' does not exist on type 'CombatMonsterDto'` + +- [ ] **Step 3: Extend the DTO** + +In `apps/api/src/combat/combat.service.ts`, add to `CombatMonsterDto`: + +```ts + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; +``` + +and in `toCombatDto`'s `monster` block, after `pendingIntent`: + +```ts + guardRemainingRounds: + combat.monsterState.activeGuard?.remainingRounds ?? null, + enraged: combat.monsterState.enraged ?? false, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- combat.service` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(api): report monster guard and enrage state in the combat DTO" +``` + +--- + +### Task 5: `WorldDiscoveryService` and its entity + +**Files:** +- Create: `apps/api/src/world/discovery/character-location-discovery.entity.ts` +- Create: `apps/api/src/world/discovery/world-discovery.service.ts` +- Create: `apps/api/src/world/discovery/world-discovery.module.ts` +- Test: `apps/api/src/world/discovery/world-discovery.service.spec.ts` +- Modify: `apps/api/src/world/entities/location-connection.entity.ts` + +**Interfaces:** +- Consumes: `LocationConnection`, `LocationDefinition`. +- Produces: + - `LocationConnection.requiresDiscovery: boolean` + - `class CharacterLocationDiscovery { id, characterId, locationId, discoveredAt }` + - `WorldDiscoveryService.getDiscoveredLocationIds(characterId): Promise>` + - `WorldDiscoveryService.discover(characterId, locationKey, manager?): Promise` where `DiscoveredLocation = { key: string; name: string }` + - `WorldDiscoveryService.isTravelAllowed(characterId, connection, manager?): Promise` + - `WorldDiscoveryModule` exporting the service + +Its own module, not a provider inside `WorldModule`: `WorldModule` already +imports `TravelModule`, and `TravelService` needs this service too. A shared +leaf module is what keeps that from becoming a cycle. + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/src/world/discovery/world-discovery.service.spec.ts`: + +```ts +import { DataSource, Repository } from 'typeorm'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { WorldDiscoveryService } from './world-discovery.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const WATCHPOST_ID = '20000000-0000-4000-8000-000000000003'; +const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; + +interface InsertCall { + values: Record; + orIgnore: boolean; +} + +function buildService(options: { + discoveries?: Array<{ locationId: string }>; + locations?: Array>; + insertCalls?: InsertCall[]; +}): WorldDiscoveryService { + const discoveries = options.discoveries ?? []; + const locations = options.locations ?? []; + const insertCalls = options.insertCalls ?? []; + + const discoveryRepository = { + find: jest.fn().mockResolvedValue(discoveries), + createQueryBuilder: jest.fn(() => { + const builder = { + insert: () => builder, + into: () => builder, + values: (values: Record) => { + insertCalls.push({ values, orIgnore: false }); + return builder; + }, + orIgnore: () => { + insertCalls[insertCalls.length - 1].orIgnore = true; + return builder; + }, + execute: jest + .fn() + .mockResolvedValue({ identifiers: [{ id: 'new-row' }] }), + }; + return builder; + }), + } as unknown as Repository; + + const locationRepository = { + findOneBy: jest.fn(({ key }: { key: string }) => + Promise.resolve(locations.find((location) => location.key === key) ?? null), + ), + } as unknown as Repository; + + const dataSource = { + getRepository: (target: unknown) => + target === CharacterLocationDiscovery + ? discoveryRepository + : locationRepository, + } as unknown as DataSource; + + return new WorldDiscoveryService(dataSource); +} + +describe('WorldDiscoveryService', () => { + it('returns the ids the character has already discovered', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + }); + + const discovered = await service.getDiscoveredLocationIds(CHARACTER_ID); + + expect(discovered.has(ASH_PIT_ID)).toBe(true); + expect(discovered.has(WATCHPOST_ID)).toBe(false); + }); + + it('allows travel down a route that carries no gate', async () => { + const service = buildService({}); + const connection = { + toLocationId: WATCHPOST_ID, + requiresDiscovery: false, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('refuses a gated route the character has not discovered', async () => { + const service = buildService({}); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(false); + }); + + it('allows a gated route once it has been discovered', async () => { + const service = buildService({ discoveries: [{ locationId: ASH_PIT_ID }] }); + const connection = { + toLocationId: ASH_PIT_ID, + requiresDiscovery: true, + } as LocationConnection; + + await expect( + service.isTravelAllowed(CHARACTER_ID, connection), + ).resolves.toBe(true); + }); + + it('returns the location the first time it is discovered', async () => { + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('returns null when the location was already known', async () => { + const service = buildService({ + discoveries: [{ locationId: ASH_PIT_ID }], + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + }); + + await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toBeNull(); + }); + + it('writes the row so a duplicate is ignored rather than thrown', async () => { + const insertCalls: InsertCall[] = []; + const service = buildService({ + locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }], + insertCalls, + }); + + await service.discover(CHARACTER_ID, 'ash-pit'); + + expect(insertCalls).toHaveLength(1); + expect(insertCalls[0].orIgnore).toBe(true); + expect(insertCalls[0].values).toEqual({ + characterId: CHARACTER_ID, + locationId: ASH_PIT_ID, + }); + }); + + it('ignores an unknown location key', async () => { + const service = buildService({ locations: [] }); + + await expect(service.discover(CHARACTER_ID, 'nowhere')).resolves.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/api -- world-discovery.service` +Expected: FAIL — `Cannot find module './character-location-discovery.entity'` + +- [ ] **Step 3: Create the entity** + +Create `apps/api/src/world/discovery/character-location-discovery.entity.ts`: + +```ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; + +/** + * A place this character knows about (Playable Slice 0.10 §9). + * + * Player state, not content: which routes are gated at all lives on the + * connection. A row here is written once and never updated, so the unique + * pair is the whole concurrency story (AGENTS.md §30). + */ +@Entity({ name: 'character_location_discoveries' }) +@Index( + 'IDX_character_location_discoveries_pair', + ['characterId', 'locationId'], + { unique: true }, +) +export class CharacterLocationDiscovery { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ name: 'character_id', type: 'uuid' }) + characterId!: string; + + @Column({ name: 'location_id', type: 'uuid' }) + locationId!: string; + + @CreateDateColumn({ name: 'discovered_at', type: 'timestamptz' }) + discoveredAt!: Date; + + @ManyToOne(() => Character, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'character_id' }) + character!: Character; + + @ManyToOne(() => LocationDefinition, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'location_id' }) + location!: LocationDefinition; +} +``` + +- [ ] **Step 4: Add the connection column** + +In `apps/api/src/world/entities/location-connection.entity.ts`, after `enabled`: + +```ts + /** + * When true this route only exists for a character who has discovered its + * target (Playable Slice 0.10 §9). Default false: every route that existed + * before this slice stays open. + */ + @Column({ name: 'requires_discovery', type: 'boolean', default: false }) + requiresDiscovery!: boolean; +``` + +- [ ] **Step 5: Create the service** + +Create `apps/api/src/world/discovery/world-discovery.service.ts`: + +```ts +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { LocationConnection } from '../entities/location-connection.entity'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; + +export interface DiscoveredLocation { + key: string; + name: string; +} + +/** + * Which places a character knows about, and whether a gated route is open to + * them yet (Playable Slice 0.10 §9). + * + * One service rather than a check inlined in `WorldService` and + * `TravelService`: the map must hide exactly what travel refuses, and two + * copies of that rule would drift the moment Slice 0.11 adds a second gate. + */ +@Injectable() +export class WorldDiscoveryService { + constructor(private readonly dataSource: DataSource) {} + + async getDiscoveredLocationIds( + characterId: string, + manager?: EntityManager, + ): Promise> { + const repository = this.discoveries(manager); + const rows = await repository.find({ + where: { characterId }, + select: { locationId: true }, + }); + return new Set(rows.map((row) => row.locationId)); + } + + /** + * Records that the character now knows this place. + * + * Returns the location the first time and `null` afterwards, so a caller can + * tell a fresh reveal from a repeated click without a second query. The + * insert ignores a conflict rather than throwing: the same interaction run + * twice is a normal thing for a player to do (AGENTS.md §30). + */ + async discover( + characterId: string, + locationKey: string, + manager?: EntityManager, + ): Promise { + const locations = manager + ? manager.getRepository(LocationDefinition) + : this.dataSource.getRepository(LocationDefinition); + const location = await locations.findOneBy({ key: locationKey }); + if (!location) { + return null; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + if (known.has(location.id)) { + return null; + } + + await this.discoveries(manager) + .createQueryBuilder() + .insert() + .into(CharacterLocationDiscovery) + .values({ characterId, locationId: location.id }) + .orIgnore() + .execute(); + + return { key: location.key, name: location.name }; + } + + async isTravelAllowed( + characterId: string, + connection: Pick, + manager?: EntityManager, + ): Promise { + if (!connection.requiresDiscovery) { + return true; + } + + const known = await this.getDiscoveredLocationIds(characterId, manager); + return known.has(connection.toLocationId); + } + + private discoveries(manager?: EntityManager) { + return manager + ? manager.getRepository(CharacterLocationDiscovery) + : this.dataSource.getRepository(CharacterLocationDiscovery); + } +} +``` + +- [ ] **Step 6: Create the module** + +Create `apps/api/src/world/discovery/world-discovery.module.ts`: + +```ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LocationDefinition } from '../entities/location-definition.entity'; +import { CharacterLocationDiscovery } from './character-location-discovery.entity'; +import { WorldDiscoveryService } from './world-discovery.service'; + +/** + * A leaf module on purpose. `WorldModule` already imports `TravelModule`, and + * both need this service; giving it its own module is what keeps that from + * becoming a circular import. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([CharacterLocationDiscovery, LocationDefinition]), + ], + providers: [WorldDiscoveryService], + exports: [WorldDiscoveryService], +}) +export class WorldDiscoveryModule {} +``` + +- [ ] **Step 7: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/api -- world-discovery.service` +Expected: PASS (8 tests) + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/world/discovery apps/api/src/world/entities/location-connection.entity.ts +git commit -m "feat(api): track which locations a character has discovered" +``` + +--- + +### Task 6: Enforce the gate in travel + +**Files:** +- Modify: `apps/api/src/travel/travel.service.ts` +- Modify: `apps/api/src/travel/travel.module.ts` +- Test: `apps/api/src/travel/travel.service.spec.ts` + +**Interfaces:** +- Consumes: `WorldDiscoveryService.isTravelAllowed` from Task 5. +- Produces: `TravelService` constructor gains a third parameter `worldDiscovery: WorldDiscoveryService`. Every existing test that constructs it directly must be updated. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/api/src/travel/travel.service.spec.ts`: + +```ts + it('refuses a gated route the character has not discovered', async () => { + const { service, state } = buildService(); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).rejects.toBeInstanceOf(TravelDomainError); + expect(state.travels).toHaveLength(0); + }); + + it('allows a gated route once it has been discovered', async () => { + const { service, state } = buildService({ discovered: [BURNED_ROAD_ID] }); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).resolves.toMatchObject({ status: TravelStatus.TRAVELLING }); + }); +``` + +Extend the file's existing `buildService` helper to accept +`{ discovered?: string[] }` and pass a stub as the new third constructor +argument: + +```ts + const worldDiscovery = { + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ), + } as unknown as WorldDiscoveryService; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- travel.service` +Expected: FAIL — the gated route still starts a travel + +- [ ] **Step 3: Inject the service and enforce the rule** + +In `apps/api/src/travel/travel.service.ts`, add the import and the constructor +parameter: + +```ts +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; +``` + +```ts + constructor( + private readonly dataSource: DataSource, + @Inject(CLOCK) private readonly clock: Clock, + private readonly worldDiscovery: WorldDiscoveryService, + ) {} +``` + +In `startTravel`, immediately after the existing `if (!connection) { throw +invalidTravelTarget(); }` block: + +```ts + // The map already hides an undiscovered route, but the map is not what + // decides. A gated target is refused here too, inside the same + // transaction that locks the character (AGENTS.md §5). + const allowed = await this.worldDiscovery.isTravelAllowed( + characterId, + connection, + manager, + ); + if (!allowed) { + throw invalidTravelTarget(); + } +``` + +- [ ] **Step 4: Register the module** + +In `apps/api/src/travel/travel.module.ts`, add `WorldDiscoveryModule` to +`imports`: + +```ts +import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module'; +``` + +```ts + imports: [ + TypeOrmModule.forFeature([ + Character, + LocationDefinition, + LocationConnection, + Travel, + ]), + WorldDiscoveryModule, + ], +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- travel` +Expected: PASS — including every pre-existing travel test + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/travel/travel.service.ts apps/api/src/travel/travel.module.ts apps/api/src/travel/travel.service.spec.ts +git commit -m "feat(api): refuse travel down an undiscovered route" +``` + +--- + +### Task 7: Hide the route on the map and discover it from a hotspot + +**Files:** +- Modify: `apps/api/src/world/local-location.types.ts` +- Modify: `apps/api/src/world/world.service.ts` +- Modify: `apps/api/src/world/world.module.ts` +- Test: `apps/api/src/world/world.service.spec.ts` +- Test: `apps/api/src/world/local-location-interaction.spec.ts` + +**Interfaces:** +- Consumes: `WorldDiscoveryService` from Task 5. +- Produces: + - `LocationPointOfInterestContent.discoversLocationKey?: string` + - `LocationInteractionResultDto.discoveredLocation: DiscoveredLocation | null` + - `WorldService` constructor gains `worldDiscovery: WorldDiscoveryService` as its final parameter. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/world/world.service.spec.ts`: + +```ts + it('hides a gated connection until the character has discovered it', async () => { + const { service } = buildService({ + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(0); + }); + + it('shows a gated connection once it has been discovered', async () => { + const { service } = buildService({ + discovered: [ASH_PIT_ID], + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(1); + expect(location.connections[0].targetLocation.key).toBe('ash-pit'); + }); +``` + +Add to `apps/api/src/world/local-location-interaction.spec.ts`: + +```ts + it('discovers the route the hotspot points at', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + actionLabel: 'Inspect', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultTitle: 'The Watchpost', + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(discover).toHaveBeenCalledWith(CHARACTER_ID, 'ash-pit'); + expect(result.discoveredLocation).toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('reports no discovery the second time the hotspot is used', async () => { + const { service } = buildService({ + alreadyDiscovered: true, + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(result.discoveredLocation).toBeNull(); + expect(result.text).toBe('Fresh tracks lead east.'); + }); + + it('reports no discovery for a hotspot that reveals nothing', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'search-quarters', + title: 'Guard Quarters', + type: 'SEARCH', + iconKey: 'search', + xPercent: 20, + yPercent: 60, + enabled: true, + resultText: 'Nothing but ash.', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'search-quarters', + ); + + expect(discover).not.toHaveBeenCalled(); + expect(result.discoveredLocation).toBeNull(); + }); +``` + +In both spec files, extend the existing service factory to build and pass a +`WorldDiscoveryService` stub as the final constructor argument: + +```ts + const discover = jest.fn().mockResolvedValue( + options.alreadyDiscovered ? null : { key: 'ash-pit', name: 'Ash Pit' }, + ); + const worldDiscovery = { + discover, + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ), + } as unknown as WorldDiscoveryService; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- world` +Expected: FAIL — `Object literal may only specify known properties, and 'discoversLocationKey' does not exist` + +- [ ] **Step 3: Extend the content and transport types** + +In `apps/api/src/world/local-location.types.ts`, add to +`LocationPointOfInterestContent`: + +```ts + /** + * Names a location this hotspot reveals (Playable Slice 0.10 §9). Setting it + * turns a read-only reveal into a piece of world progress, which is why the + * interaction endpoint writes as well as reads. + */ + discoversLocationKey?: string; +``` + +and to `LocationInteractionResultDto`: + +```ts + /** + * Set only on the interaction that reveals a route for the first time, so + * the UI can say so once instead of on every repeat. + */ + discoveredLocation: { key: string; name: string } | null; +``` + +`toPointOfInterestDto` needs no change: `discoversLocationKey` is server-only, +exactly like `resultText`. + +- [ ] **Step 4: Wire the service** + +In `apps/api/src/world/world.service.ts`: + +```ts +import { WorldDiscoveryService } from './discovery/world-discovery.service'; +``` + +Add the constructor parameter after `locationMonsters`: + +```ts + private readonly worldDiscovery: WorldDiscoveryService, +``` + +Replace the `connections` mapping in `getCurrentLocation` so the gate is applied +before the map ever sees the route. Load the connections, then: + +```ts + const visibleConnections: LocationConnection[] = []; + for (const connection of connections) { + if (await this.worldDiscovery.isTravelAllowed(characterId, connection)) { + visibleConnections.push(connection); + } + } +``` + +and map `visibleConnections` instead of `connections.filter(...)` in the +response. + +In `runLocalInteraction`, after the `poi` guard and before the return: + +```ts + // A hotspot that reveals a route writes before it speaks. Idempotent by + // the unique pair, so a second click simply reports nothing new. + const discoveredLocation = poi.discoversLocationKey + ? await this.worldDiscovery.discover( + characterId, + poi.discoversLocationKey, + ) + : null; +``` + +and add `discoveredLocation,` to the returned object. + +- [ ] **Step 5: Register the module** + +In `apps/api/src/world/world.module.ts` add `WorldDiscoveryModule` to `imports`: + +```ts +import { WorldDiscoveryModule } from './discovery/world-discovery.module'; +``` + +```ts + TravelModule, + WorldDiscoveryModule, +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- world` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/world/local-location.types.ts apps/api/src/world/world.service.ts apps/api/src/world/world.module.ts apps/api/src/world/world.service.spec.ts apps/api/src/world/local-location-interaction.spec.ts +git commit -m "feat(api): reveal the ash pit route from the watchpost hotspot" +``` + +--- + +### Task 8: Trade goods, loot tables and exchange rules + +**Files:** +- Modify: `apps/api/src/database/seeds/item.constants.ts` +- Modify: `apps/api/src/database/seeds/item-content.ts` +- Modify: `apps/api/src/database/seeds/npc-content.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: `item()` and `entry()` helpers in `item-content.ts`, `SeedExchangeRule` in `npc-content.ts`. +- Produces: item keys `scorched-hide`, `raider-warband-mark`; loot-table constants `RAIDER_SCOUT_LOOT_TABLE_ID`, `BURNED_HOUND_LOOT_TABLE_ID`, `RAIDER_VETERAN_LOOT_TABLE_ID`, `RAIDER_CAPTAIN_LOOT_TABLE_ID`; four new `LOOT_TABLES` rows and their entries; two new `EXCHANGE_RULES`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`: + +```ts + it('gives each new trade good the carrying category it belongs to', async () => { + const { repositories } = await runSeed(); + const items = repositories.get(ItemDefinition)!.rows; + + const hide = items.find((row) => row.key === 'scorched-hide'); + const mark = items.find((row) => row.key === 'raider-warband-mark'); + + expect(hide?.lootCategory).toBe('HIDE'); + expect(mark?.lootCategory).toBe('RAIDER_TROPHY'); + }); + + it('lets Borin buy both watchpost trade goods', async () => { + const { repositories } = await runSeed(); + const rules = repositories.get(ExchangeRule)!.rows; + + const hideRule = rules.find( + (row) => row.inputItemId === ITEM_IDS['scorched-hide'], + ); + const markRule = rules.find( + (row) => row.inputItemId === ITEM_IDS['raider-warband-mark'], + ); + + expect(hideRule).toMatchObject({ silverReward: 12, regionReputationReward: 4 }); + expect(markRule).toMatchObject({ silverReward: 20, regionReputationReward: 7 }); + }); + + it('pays more for watchpost goods than for road goods', async () => { + const { repositories } = await runSeed(); + const rules = repositories.get(ExchangeRule)!.rows; + const silverFor = (key: keyof typeof ITEM_IDS) => + rules.find((row) => row.inputItemId === ITEM_IDS[key])?.silverReward; + + // The longer trip has to pay, or §10's loop has no pull (slice §6). + expect(silverFor('scorched-hide')).toBeGreaterThan(silverFor('tough-hide')!); + expect(silverFor('raider-warband-mark')).toBeGreaterThan( + silverFor('bandit-insignia')!, + ); + }); + + it('guarantees the captain trophy and offers its focused drop', async () => { + const { repositories } = await runSeed(); + const entries = repositories + .get(LootTableEntry)! + .rows.filter((row) => row.lootTableId === RAIDER_CAPTAIN_LOOT_TABLE_ID); + + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['raider-warband-mark']) + ?.dropChance, + ).toBe('1.0000'); + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['borderwatch-sigil']) + ?.dropChance, + ).toBe('0.2000'); + }); + + it('leaves the road bandit loot table untouched', async () => { + const { repositories } = await runSeed(); + const entries = repositories + .get(LootTableEntry)! + .rows.filter((row) => row.lootTableId === ROAD_BANDIT_LOOT_TABLE_ID); + + // This slice adds a pool entry for the bandit, not a rebalance + // (AGENTS.md §39). + expect(entries).toHaveLength(4); + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['bandit-blade']) + ?.dropChance, + ).toBe('0.1800'); + }); +``` + +Use whatever `runSeed()` / repository-lookup helper the file already defines; +the assertions are the point. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `'scorched-hide' is not assignable to ItemKey` + +- [ ] **Step 3: Add the item and loot-table ids** + +In `apps/api/src/database/seeds/item.constants.ts`, add to `ITEM_IDS` after +`'charred-raider-insignia'`: + +```ts + 'scorched-hide': '50000000-0000-4000-8000-000000000010', + 'raider-warband-mark': '50000000-0000-4000-8000-000000000011', +``` + +and append the four table ids: + +```ts +// Playable Slice 0.10 §6: one table per Watchpost enemy, same rule as the +// Burned Road -- each enemy owns its guaranteed trade good. +export const RAIDER_SCOUT_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000005'; +export const BURNED_HOUND_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000006'; +export const RAIDER_VETERAN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000007'; +export const RAIDER_CAPTAIN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000008'; +``` + +- [ ] **Step 4: Add the trade goods** + +In `apps/api/src/database/seeds/item-content.ts`, import the four new table ids +and append to `ITEM_DEFINITIONS`: + +```ts + // Watchpost trade goods (Playable Slice 0.10 §6). One per carrying + // category, so both the Hide Bag and the Trophy Pouch matter at this + // location and a player who owns neither runs into the capacity of 1. + item( + 'scorched-hide', + 'Scorched Hide', + 'Hound hide burned hard as bark, the cracks in it still warm.', + ItemType.TRADE_GOOD, + null, + ItemRarity.COMMON, + {}, + LootCategory.HIDE, + ), + item( + 'raider-warband-mark', + 'Raider Warband Mark', + 'A watchpost tally-token, re-stamped with the mark of the band that took it.', + ItemType.TROPHY, + null, + ItemRarity.COMMON, + {}, + LootCategory.RAIDER_TROPHY, + ), +``` + +- [ ] **Step 5: Add the loot tables** + +Append to `LOOT_TABLES` in the same file: + +```ts + { + id: RAIDER_SCOUT_LOOT_TABLE_ID, + key: 'raider-scout-loot', + name: 'Raider Scout Loot', + }, + { + id: BURNED_HOUND_LOOT_TABLE_ID, + key: 'burned-hound-loot', + name: 'Burned Hound Loot', + }, + { + id: RAIDER_VETERAN_LOOT_TABLE_ID, + key: 'raider-veteran-loot', + name: 'Raider Veteran Loot', + }, + { + id: RAIDER_CAPTAIN_LOOT_TABLE_ID, + key: 'raider-captain-loot', + name: 'Raider Captain Loot', + }, +``` + +and to `LOOT_TABLE_ENTRIES`: + +```ts + // Watchpost (Playable Slice 0.10 §7). The Tier-1 pieces already exist in + // content; what this location changes is how often they show up. The + // Charred Captain's Pendant stays out -- it belongs to the Slice 0.11 boss, + // not to this elite. + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.6000'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-blade', 2, '0.2500'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-hood', 3, '0.1800'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'scorched-hide', 1, '0.6000'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'ash-boots', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.7000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-gloves', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 3, '0.2000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'guardsman-legs', 4, '0.2200'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '1.0000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'guardsman-legs', 3, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.2000'), +``` + +- [ ] **Step 6: Add the exchange rules** + +Append to `EXCHANGE_RULES` in `apps/api/src/database/seeds/npc-content.ts`: + +```ts + // Watchpost goods (Playable Slice 0.10 §10). Priced above the road tier so + // the longer trip pays, and below the rare Charred Raider Insignia so the + // rare drop stays the best thing in the region. + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['scorched-hide'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 12, + regionReputationReward: 4, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 5, + enabled: true, + }, + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['raider-warband-mark'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 20, + regionReputationReward: 7, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 6, + enabled: true, + }, +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/database/seeds/item.constants.ts apps/api/src/database/seeds/item-content.ts apps/api/src/database/seeds/npc-content.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts +git commit -m "feat(seed): add the watchpost trade goods and their loot tables" +``` + +--- + +### Task 9: Seed the locations, monsters and encounter pool + +**Files:** +- Modify: `apps/api/src/database/seeds/vertical-slice.constants.ts` +- Modify: `apps/api/src/database/seeds/local-location.content.ts` +- Modify: `apps/api/src/database/seeds/vertical-slice.seed.ts` +- Test: `apps/api/src/database/seeds/vertical-slice.seed.spec.ts` + +**Interfaces:** +- Consumes: loot-table constants from Task 8, `MonsterAbilities` from Tasks 2–3, `LocalLocationContent` from `local-location.content.ts`. +- Produces: constants `ABANDONED_WATCHPOST_ID`, `ASH_PIT_ID`, `RAIDER_SCOUT_MONSTER_ID`, `BURNED_HOUND_MONSTER_ID`, `RAIDER_VETERAN_MONSTER_ID`, `RAIDER_CAPTAIN_MONSTER_ID`; exports `ABANDONED_WATCHPOST_LOCAL_CONTENT`, `ASH_PIT_LOCAL_CONTENT`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/api/src/database/seeds/vertical-slice.seed.spec.ts`: + +```ts + it('seeds the watchpost as a huntable outpost', async () => { + const { repositories } = await runSeed(); + const locations = repositories.get(LocationDefinition)!.rows; + + const watchpost = locations.find( + (row) => row.key === 'abandoned-watchpost', + ); + + expect(watchpost).toMatchObject({ + locationType: 'OUTPOST', + huntingEnabled: true, + isSafe: false, + regionKey: 'ashen-fields', + }); + }); + + it('connects the burned road and the watchpost both ways without a gate', async () => { + const { repositories } = await runSeed(); + const connections = repositories.get(LocationConnection)!.rows; + + const outbound = connections.find( + (row) => + row.fromLocationId === BURNED_ROAD_ID && + row.toLocationId === ABANDONED_WATCHPOST_ID, + ); + const inbound = connections.find( + (row) => + row.fromLocationId === ABANDONED_WATCHPOST_ID && + row.toLocationId === BURNED_ROAD_ID, + ); + + expect(outbound).toMatchObject({ + travelDurationSeconds: 15, + ambushChance: '0.1000', + requiresDiscovery: false, + }); + expect(inbound).toMatchObject({ + travelDurationSeconds: 15, + requiresDiscovery: false, + }); + }); + + it('gates the outbound ash pit route and leaves the way back open', async () => { + const { repositories } = await runSeed(); + const connections = repositories.get(LocationConnection)!.rows; + + const outbound = connections.find( + (row) => + row.fromLocationId === ABANDONED_WATCHPOST_ID && + row.toLocationId === ASH_PIT_ID, + ); + const inbound = connections.find( + (row) => + row.fromLocationId === ASH_PIT_ID && + row.toLocationId === ABANDONED_WATCHPOST_ID, + ); + + expect(outbound?.requiresDiscovery).toBe(true); + // Whoever got there must always be able to leave. + expect(inbound?.requiresDiscovery).toBe(false); + }); + + it('points the watchpost investigation at the ash pit', async () => { + const { repositories } = await runSeed(); + const watchpost = repositories + .get(LocationDefinition)! + .rows.find((row) => row.key === 'abandoned-watchpost'); + + const poi = ( + watchpost?.localPointsOfInterest as Array> + ).find((entry) => entry.key === 'inspect-watchpost'); + + expect(poi?.discoversLocationKey).toBe('ash-pit'); + }); + + it('gives the watchpost its own encounter pool', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ABANDONED_WATCHPOST_ID); + + expect(pool.map((row) => row.monsterId).sort()).toEqual( + [ + RAIDER_SCOUT_MONSTER_ID, + BURNED_HOUND_MONSTER_ID, + ROAD_BANDIT_MONSTER_ID, + RAIDER_VETERAN_MONSTER_ID, + RAIDER_CAPTAIN_MONSTER_ID, + ].sort(), + ); + }); + + it('leaves the burned road pool exactly as it was', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === BURNED_ROAD_ID); + + expect(pool).toHaveLength(4); + }); + + it('marks only the captain as a rare encounter', async () => { + const { repositories } = await runSeed(); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ABANDONED_WATCHPOST_ID); + + const rare = pool.filter((row) => row.encounterType === 'RARE'); + expect(rare).toHaveLength(1); + expect(rare[0].monsterId).toBe(RAIDER_CAPTAIN_MONSTER_ID); + }); + + it('arms the veteran with a telegraph and a guard on different cadences', async () => { + const { repositories } = await runSeed(); + const veteran = repositories + .get(MonsterDefinition)! + .rows.find((row) => row.key === 'raider-veteran'); + + expect(veteran?.abilities).toEqual({ + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, + }); + }); + + it('gives the hound bleeding and a low-HP rage', async () => { + const { repositories } = await runSeed(); + const hound = repositories + .get(MonsterDefinition)! + .rows.find((row) => row.key === 'burned-hound'); + + expect(hound?.abilities).toEqual({ + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, + }); + }); + + it('seeds the ash pit as a stub with nothing to hunt yet', async () => { + const { repositories } = await runSeed(); + const ashPit = repositories + .get(LocationDefinition)! + .rows.find((row) => row.key === 'ash-pit'); + const pool = repositories + .get(LocationMonster)! + .rows.filter((row) => row.locationId === ASH_PIT_ID); + + expect(ashPit?.huntingEnabled).toBe(false); + expect(pool).toHaveLength(0); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: FAIL — `ABANDONED_WATCHPOST_ID is not defined` + +- [ ] **Step 3: Add the constants** + +Append to `apps/api/src/database/seeds/vertical-slice.constants.ts`: + +```ts +export const ABANDONED_WATCHPOST_ID = '20000000-0000-4000-8000-000000000003'; +export const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; +export const RAIDER_SCOUT_MONSTER_ID = '30000000-0000-4000-8000-000000000005'; +export const BURNED_HOUND_MONSTER_ID = '30000000-0000-4000-8000-000000000006'; +export const RAIDER_VETERAN_MONSTER_ID = '30000000-0000-4000-8000-000000000007'; +export const RAIDER_CAPTAIN_MONSTER_ID = '30000000-0000-4000-8000-000000000008'; +``` + +- [ ] **Step 4: Author the local content** + +Append to `apps/api/src/database/seeds/local-location.content.ts`: + +```ts +export const ABANDONED_WATCHPOST_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'OUTPOST', + localDescription: + 'A border tower the Watch gave up on. The palisade still stands, the gate does not, and someone has been sleeping here who was never posted here.', + localArtworkPath: '/images/backgrounds/Wachturm.png', + // Anchored to painted detail in `Wachturm.png`: the open ground before the + // tower, the tower base itself, the collapsed lean-to on the left, and the + // track leading off to the right. + localPointsOfInterest: [ + { + key: 'hunt-area', + title: 'Hunting Ground', + actionLabel: 'Begin Hunt', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 62, + yPercent: 38, + enabled: true, + }, + { + key: 'inspect-watchpost', + title: 'The Watchpost', + actionLabel: 'Inspect', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 44, + yPercent: 46, + enabled: true, + resultTitle: 'The Watchpost', + // Quoted from Playable Slice 0.10 §8. + resultText: + "The raiders weren't using the watchpost as shelter. They were using it to watch the road. Fresh tracks lead east, toward the old ash excavation.", + discoversLocationKey: 'ash-pit', + }, + { + key: 'search-guard-quarters', + title: "Guards' Quarters", + actionLabel: 'Search', + type: 'SEARCH', + iconKey: 'search', + xPercent: 18, + yPercent: 68, + enabled: true, + resultTitle: "Guards' Quarters", + resultText: + 'Straw, a cold hearth, and a duty roster with every name scratched out but one. Nothing here is worth carrying.', + }, + { + key: 'east-road', + title: 'Track East', + actionLabel: 'To Map', + type: 'MAP', + iconKey: 'map', + xPercent: 88, + yPercent: 74, + enabled: true, + }, + ], + localPrimaryActions: [ + { + key: 'start-hunt', + label: 'Begin Hunt', + description: 'Hunt in this area', + type: 'HUNT', + iconKey: 'hunt', + enabled: true, + }, + { + key: 'inspect-watchpost', + label: 'Inspect the watchpost', + description: 'Find clues', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-watchpost', + }, + { + key: 'search-quarters', + label: "Search the quarters", + description: 'Find loot', + type: 'SEARCH', + iconKey: 'search', + enabled: true, + poiKey: 'search-guard-quarters', + }, + { + key: 'open-map', + label: 'To Map', + description: 'Change area', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + // Same rule as the Burned Road: only categories the loot tables here + // actually back, and no Silver or experience, because a normal kill grants + // neither (slice §6). + localRewardPreview: [ + { key: 'equipment', label: 'Equipment', iconKey: 'equipment' }, + { key: 'material', label: 'Trade Goods', iconKey: 'material' }, + ], +}; + +/** + * A stub (Playable Slice 0.10 §9, design decision D2). + * + * The discovered route needs a real destination for the gate to mean anything, + * so the Ash Pit exists as a place you can stand -- and nothing more. Slice + * 0.11 gives it an encounter pool, an elite and its own hotspots. + */ +export const ASH_PIT_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'TRANSITION', + localDescription: + 'The old ash excavation drops away in terraces, grey on grey. Something down there is still being worked.', + localArtworkPath: '/images/backgrounds/Aschengrube.png', + localPointsOfInterest: [ + { + key: 'pit-rim', + title: 'Back Along the Track', + actionLabel: 'To Map', + type: 'MAP', + iconKey: 'map', + xPercent: 20, + yPercent: 76, + enabled: true, + }, + ], + localPrimaryActions: [ + { + key: 'open-map', + label: 'To Map', + description: 'Change area', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + localRewardPreview: [], +}; +``` + +- [ ] **Step 5: Seed the locations and connections** + +In `apps/api/src/database/seeds/vertical-slice.seed.ts`, extend the imports and +append two entries to the `locations` array: + +```ts + { + id: ABANDONED_WATCHPOST_ID, + key: 'abandoned-watchpost', + name: 'Abandoned Watchpost', + description: + 'A border tower the Watch gave up on. Whoever holds it now is watching the road, not guarding it.', + regionKey: 'ashen-fields', + minRecommendedLevel: 2, + maxRecommendedLevel: 3, + dangerLevel: 2, + isSafe: false, + huntingEnabled: true, + artworkPath: '/images/backgrounds/Wachturm.png', + ...ABANDONED_WATCHPOST_LOCAL_CONTENT, + }, + { + id: ASH_PIT_ID, + key: 'ash-pit', + name: 'Ash Pit', + description: + 'The old ash excavation east of the watchpost, cut in terraces and still being worked by someone.', + regionKey: 'ashen-fields', + minRecommendedLevel: 3, + maxRecommendedLevel: 4, + dangerLevel: 3, + isSafe: false, + huntingEnabled: false, + artworkPath: '/images/backgrounds/Aschengrube.png', + ...ASH_PIT_LOCAL_CONTENT, + }, +``` + +Resolve the ids after the loop, beside the existing two: + +```ts + const watchpostId = + locationIds.get('abandoned-watchpost') ?? ABANDONED_WATCHPOST_ID; + const ashPitId = locationIds.get('ash-pit') ?? ASH_PIT_ID; +``` + +Add four rows to the existing `connectionRepository.upsert` array. Every +pre-existing row needs `requiresDiscovery: false` added explicitly so the upsert +writes the column on a re-seed: + +```ts + { + fromLocationId: burnedRoadId, + toLocationId: watchpostId, + travelDurationSeconds: 15, + ambushChance: '0.1000', + enabled: true, + requiresDiscovery: false, + }, + { + fromLocationId: watchpostId, + toLocationId: burnedRoadId, + travelDurationSeconds: 15, + ambushChance: '0.1000', + enabled: true, + requiresDiscovery: false, + }, + // The one gated route in the game (slice §9). Discovery, not a level, + // is what opens it -- and the way back is never gated, so a character + // who walked in can always walk out. + { + fromLocationId: watchpostId, + toLocationId: ashPitId, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + }, + { + fromLocationId: ashPitId, + toLocationId: watchpostId, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: false, + }, +``` + +- [ ] **Step 6: Seed the monsters** + +Append to the `monsters` array in the same file: + +```ts + { + id: RAIDER_SCOUT_MONSTER_ID, + key: 'raider-scout', + name: 'Raider Scout', + monsterCategory: MonsterCategory.HUMANOID, + level: 2, + maxHp: 70, + attack: 10, + armor: 3, + flavorText: + 'Light on their feet and already backing away from the fight they started.', + // The farming target: no mechanic at all, so the pool has somewhere + // for a player to breathe between the harder fights (slice §5). + abilities: {}, + artworkPath: '/images/monsters/raider-scout.png', + iconPath: '/images/combat/icons/raider-scout-128.png', + lootTableId: RAIDER_SCOUT_LOOT_TABLE_ID, + }, + { + id: BURNED_HOUND_MONSTER_ID, + key: 'burned-hound', + name: 'Burned Hound', + monsterCategory: MonsterCategory.BEAST, + level: 3, + maxHp: 80, + attack: 12, + armor: 2, + flavorText: + 'The fire took its coat and left the cracks glowing underneath.', + // Bleeding on a tighter cadence than the road hound, plus a rage that + // punishes a player who lets the fight run long (slice §5). + abilities: { + bleed: { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }, + enrage: { hpThresholdPercent: 35, damageMultiplier: 1.4 }, + }, + artworkPath: '/images/monsters/burned-hound.png', + iconPath: '/images/combat/icons/burned-hound-128.png', + lootTableId: BURNED_HOUND_LOOT_TABLE_ID, + }, + { + id: RAIDER_VETERAN_MONSTER_ID, + key: 'raider-veteran', + name: 'Raider Veteran', + monsterCategory: MonsterCategory.HUMANOID, + level: 3, + maxHp: 120, + attack: 14, + armor: 10, + flavorText: + 'Plated, patient, and entirely willing to wait behind their guard.', + // The fight this location is built around: the telegraph the player + // already knows, plus a guard that answers to the same Shield Bash. + // Intervals 3 and 4 so the two only collide every twelfth round. + abilities: { + telegraph: { roundInterval: 3, damageMultiplier: 1.6 }, + guard: { roundInterval: 4, armorBonus: 10, durationRounds: 2 }, + }, + artworkPath: '/images/monsters/raider-veteran.png', + iconPath: '/images/combat/icons/raider-veteran-128.png', + lootTableId: RAIDER_VETERAN_LOOT_TABLE_ID, + }, + { + id: RAIDER_CAPTAIN_MONSTER_ID, + key: 'raider-captain', + name: 'Raider Captain', + monsterCategory: MonsterCategory.HUMANOID, + level: 4, + maxHp: 160, + attack: 17, + armor: 12, + flavorText: + 'Whoever gave the order to watch this road is standing in front of you.', + // The elite (slice §5): the veteran's two mechanics on tighter + // cadences and better stats, not a third subsystem. Deliberately not + // the Captain of the Ashen Band -- that boss belongs to Slice 0.11. + abilities: { + telegraph: { roundInterval: 2, damageMultiplier: 1.7 }, + guard: { roundInterval: 3, armorBonus: 12, durationRounds: 2 }, + }, + artworkPath: '/images/monsters/raider-captain.png', + iconPath: '/images/combat/icons/raider-captain-128.png', + lootTableId: RAIDER_CAPTAIN_LOOT_TABLE_ID, + }, +``` + +- [ ] **Step 7: Seed the encounter pool** + +After the existing Burned Road `locationMonsterRepository.upsert`, add: + +```ts + // Watchpost roster (slice §4). The Road Bandit is reused deliberately: it + // bridges the two locations and keeps the Raider Insignia economy + // connected. Its definition and loot table are untouched. + const watchpostPool: ReadonlyArray<{ + key: string; + weight: number; + encounterType: EncounterType; + }> = [ + { key: 'raider-scout', weight: 35, encounterType: EncounterType.NORMAL }, + { key: 'burned-hound', weight: 28, encounterType: EncounterType.NORMAL }, + { key: 'road-bandit', weight: 20, encounterType: EncounterType.NORMAL }, + { key: 'raider-veteran', weight: 14, encounterType: EncounterType.NORMAL }, + { key: 'raider-captain', weight: 3, encounterType: EncounterType.RARE }, + ]; + + await locationMonsterRepository.upsert( + watchpostPool.map(({ key, weight, encounterType }) => ({ + locationId: watchpostId, + monsterId: monsterIds.get(key) as string, + weight, + encounterType, + enabled: true, + })), + ['locationId', 'monsterId'], + ); +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/api -- vertical-slice.seed` +Expected: PASS + +- [ ] **Step 9: Run the whole API suite** + +Run: `npm test --workspace=@ashen-realms/api` +Expected: PASS — no regressions anywhere + +- [ ] **Step 10: Commit** + +```bash +git add apps/api/src/database/seeds/vertical-slice.constants.ts apps/api/src/database/seeds/local-location.content.ts apps/api/src/database/seeds/vertical-slice.seed.ts apps/api/src/database/seeds/vertical-slice.seed.spec.ts +git commit -m "feat(seed): open the abandoned watchpost and the route beyond it" +``` + +--- + +### Task 10: Derive the web assets for the new content + +**Files:** +- Create: `tools/derive-monster-assets.ps1` +- Create (generated, committed): `apps/web/public/images/monsters/{raider-scout,raider-veteran,burned-hound,raider-captain}.png`, their `runtime/*-560.jpg`, `apps/web/public/images/combat/sprites/*.png`, `apps/web/public/images/combat/icons/*-128.png` +- Create (copied): `apps/web/public/images/backgrounds/{Wachturm.png,Aschengrube.png}` and `backgrounds/runtime/{Wachturm-960.jpg,Aschengrube-960.jpg}` +- Modify: `apps/web/src/app/shared/monster-artwork.ts` +- Test: `apps/web/src/app/shared/monster-artwork.spec.ts` + +**Interfaces:** +- Consumes: art committed in `2435d25`. +- Produces: the four asset paths per monster key, registered in `monster-artwork.ts`. + +Note the deliberate crossing (design D7): the file named `raider-scout.png` +depicts the heavier plated figure and becomes the **Veteran**; `raider-veteran.png` +depicts the leaner one and becomes the **Scout**. + +- [ ] **Step 1: Write the failing test** + +Add to `apps/web/src/app/shared/monster-artwork.spec.ts`: + +```ts +describe('watchpost monsters', () => { + const keys = ['raider-scout', 'raider-veteran', 'burned-hound', 'raider-captain']; + + it('has a cutout for every watchpost monster', () => { + for (const key of keys) { + expect(monsterCutoutPath(key)).toBeDefined(); + } + }); + + it('has an icon for every watchpost monster', () => { + for (const key of keys) { + expect(monsterIconPath(key)).toBeDefined(); + } + }); + + it('has a runtime derivative for every watchpost artwork', () => { + for (const key of keys) { + expect(runtimeMonsterArtworkPath(`/images/monsters/${key}.png`)).toBeDefined(); + } + }); + + it('scales the captain larger than the hound', () => { + // A hulking elite and a low-slung dog must not share a silhouette height. + expect(combatMonsterSpriteScale('raider-captain')).toBeGreaterThan( + combatMonsterSpriteScale('burned-hound'), + ); + }); +}); +``` + +Add `combatMonsterSpriteScale` to the file's imports. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: FAIL — `monsterCutoutPath('raider-scout')` is `undefined` + +- [ ] **Step 3: Write the derivation script** + +Create `tools/derive-monster-assets.ps1`: + +```powershell +# tools/derive-monster-assets.ps1 +# Derives the web assets each monster needs from the hand-made art in +# art/enemies and art/backgrounds. +# +# Per monster it writes four files: +# apps/web/public/images/monsters/.png full painted artwork +# apps/web/public/images/monsters/runtime/-560.jpg downscaled for the web +# apps/web/public/images/combat/sprites/-.png background-free cutout +# apps/web/public/images/combat/icons/-128.png medallion icon +# +# The generated files are committed, so this only needs re-running when the +# source art changes. Re-runnable: it overwrites its own output and touches +# nothing else. +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $PSScriptRoot +$artEnemies = Join-Path $root 'art\enemies' +$artCutouts = Join-Path $artEnemies 'transparent-background' +$artBackgrounds = Join-Path $root 'art\backgrounds' +$webImages = Join-Path $root 'apps\web\public\images' + +foreach ($dir in @( + (Join-Path $webImages 'monsters\runtime'), + (Join-Path $webImages 'combat\sprites'), + (Join-Path $webImages 'combat\icons'), + (Join-Path $webImages 'backgrounds\runtime'))) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} + +function Save-Scaled { + param( + [string] $SourcePath, + [string] $TargetPath, + [int] $TargetWidth, + [string] $Format, + [switch] $Opaque + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $height = [int][Math]::Round($img.Height * $TargetWidth / $img.Width) + $bitmap = New-Object System.Drawing.Bitmap $TargetWidth, $height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + if ($Opaque) { + $graphics.Clear([System.Drawing.Color]::Black) + } + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage($img, 0, 0, $TargetWidth, $height) + $graphics.Dispose() + if ($Format -eq 'jpg') { + $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | + Where-Object { $_.MimeType -eq 'image/jpeg' } + $params = New-Object System.Drawing.Imaging.EncoderParameters 1 + $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter( + [System.Drawing.Imaging.Encoder]::Quality, 82) + $bitmap.Save($TargetPath, $codec, $params) + } + else { + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +function Save-Icon { + param( + [string] $SourcePath, + [string] $TargetPath, + # Crop window as fractions of the source, chosen per monster so the icon + # lands on the head rather than on whatever the centre happens to be. + [double] $CropX, [double] $CropY, [double] $CropSize + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $side = [int]([Math]::Min($img.Width, $img.Height) * $CropSize) + $x = [int]($img.Width * $CropX) + $y = [int]($img.Height * $CropY) + $bitmap = New-Object System.Drawing.Bitmap 128, 128 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage( + $img, + (New-Object System.Drawing.Rectangle 0, 0, 128, 128), + (New-Object System.Drawing.Rectangle $x, $y, $side, $side), + [System.Drawing.GraphicsUnit]::Pixel) + $graphics.Dispose() + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +# `source` is the painted file, `cutout` the background-free one. +# +# NOTE the deliberate crossing on the first two rows: the file named +# raider-scout depicts the heavier, plated, spear-carrying figure and is the +# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10 +# design decision D7. +$monsters = @( + @{ key = 'raider-scout'; source = 'raider-veteran'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'raider-veteran'; source = 'raider-scout'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'burned-hound'; source = 'burned-hound'; spriteHeight = 760; cropX = 0.06; cropY = 0.12; cropSize = 0.34 }, + @{ key = 'raider-captain'; source = 'Pluendererhauptmann'; spriteHeight = 620; cropX = 0.36; cropY = 0.02; cropSize = 0.28 } +) + +foreach ($monster in $monsters) { + $key = $monster.key + $painted = Join-Path $artEnemies "$($monster.source).png" + $cutout = Join-Path $artCutouts "$($monster.source).png" + + Copy-Item -Force $painted (Join-Path $webImages "monsters\$key.png") + Write-Host "wrote $key.png" + + Save-Scaled -SourcePath $painted ` + -TargetPath (Join-Path $webImages "monsters\runtime\$key-560.jpg") ` + -TargetWidth 560 -Format 'jpg' -Opaque + + Save-Scaled -SourcePath $cutout ` + -TargetPath (Join-Path $webImages "combat\sprites\$key-$($monster.spriteHeight).png") ` + -TargetWidth $monster.spriteHeight -Format 'png' + + Save-Icon -SourcePath $cutout ` + -TargetPath (Join-Path $webImages "combat\icons\$key-128.png") ` + -CropX $monster.cropX -CropY $monster.cropY -CropSize $monster.cropSize +} + +# The Raider Captain has a hand-made icon. Authored art beats a crop. +Copy-Item -Force (Join-Path $artEnemies 'PluendererhauptmannIcon.png') ` + (Join-Path $webImages 'combat\icons\raider-captain-128.png') +Write-Host 'wrote raider-captain-128.png (hand-made)' + +foreach ($background in 'Wachturm', 'Aschengrube') { + $source = Join-Path $artBackgrounds "$background.png" + Copy-Item -Force $source (Join-Path $webImages "backgrounds\$background.png") + Write-Host "wrote $background.png" + Save-Scaled -SourcePath $source ` + -TargetPath (Join-Path $webImages "backgrounds\runtime\$background-960.jpg") ` + -TargetWidth 960 -Format 'jpg' -Opaque +} +``` + +- [ ] **Step 4: Run the script** + +Run: `powershell -ExecutionPolicy Bypass -File tools/derive-monster-assets.ps1` +Expected: 22 "wrote …" lines, no errors. + +Then open the four generated `*-128.png` icons and confirm each frames the +monster's head. If one is off, adjust that monster's `cropX` / `cropY` / +`cropSize` in the script and re-run — the script is re-runnable by design. + +- [ ] **Step 5: Register the assets** + +In `apps/web/src/app/shared/monster-artwork.ts`, add to each map: + +```ts + '/images/monsters/raider-scout.png': '/images/monsters/runtime/raider-scout-560.jpg', + '/images/monsters/raider-veteran.png': '/images/monsters/runtime/raider-veteran-560.jpg', + '/images/monsters/burned-hound.png': '/images/monsters/runtime/burned-hound-560.jpg', + '/images/monsters/raider-captain.png': '/images/monsters/runtime/raider-captain-560.jpg', +``` + +```ts + 'raider-scout': '/images/combat/sprites/raider-scout-620.png', + 'raider-veteran': '/images/combat/sprites/raider-veteran-620.png', + 'burned-hound': '/images/combat/sprites/burned-hound-760.png', + 'raider-captain': '/images/combat/sprites/raider-captain-620.png', +``` + +```ts + 'raider-scout': '/images/combat/icons/raider-scout-128.png', + 'raider-veteran': '/images/combat/icons/raider-veteran-128.png', + 'burned-hound': '/images/combat/icons/burned-hound-128.png', + 'raider-captain': '/images/combat/icons/raider-captain-128.png', +``` + +```ts + 'raider-scout': 0.78, + 'raider-veteran': 0.84, + 'burned-hound': 0.6, + 'raider-captain': 0.9, +``` + +Also register the two new backgrounds in +`apps/web/src/app/features/world/location-page/location-page.component.ts`: + +```ts + '/images/backgrounds/Wachturm.png': '/images/backgrounds/runtime/Wachturm-960.jpg', + '/images/backgrounds/Aschengrube.png': '/images/backgrounds/runtime/Aschengrube-960.jpg', +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add tools/derive-monster-assets.ps1 apps/web/public/images apps/web/src/app/shared/monster-artwork.ts apps/web/src/app/shared/monster-artwork.spec.ts apps/web/src/app/features/world/location-page/location-page.component.ts +git commit -m "feat(web): derive and register the watchpost artwork" +``` + +--- + +### Task 11: Show guard and enrage in combat + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.scss` +- Test: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `CombatMonsterDto.guardRemainingRounds` / `.enraged` from Task 4; event types from Tasks 2–3. +- Produces: `CombatMonster.guardRemainingRounds: number | null`, `CombatMonster.enraged: boolean`, `CombatEventType` union extended with `'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'`. + +- [ ] **Step 1: Write the failing tests** + +Add to `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +The file already has an `activeCombat` fixture object and an +`async function setup(combat)` that returns the `ComponentFixture`. It runs on +**vitest** (`vi.fn`), not Jasmine. First add the two new fields to +`activeCombat.monster` so the existing tests still compile: + +```ts + pendingIntent: null, + guardRemainingRounds: null, + enraged: false, +``` + +Then add the tests inside the existing `describe`: + +```ts + const veteran: Combat = { + ...activeCombat, + monster: { ...activeCombat.monster, name: 'Raider Veteran' }, + }; + + it('announces a raised guard with the rounds it still covers', async () => { + const fixture = await setup({ + ...veteran, + monster: { ...veteran.monster, guardRemainingRounds: 2 }, + }); + + const banner: HTMLElement | null = fixture.nativeElement.querySelector( + '[data-combat-guard]', + ); + expect(banner?.textContent).toContain('Raider Veteran'); + expect(banner?.textContent).toContain('2'); + }); + + it('says nothing about a guard when the monster is open', async () => { + const fixture = await setup(activeCombat); + + expect( + fixture.nativeElement.querySelector('[data-combat-guard]'), + ).toBeNull(); + }); + + it('marks an enraged monster', async () => { + const fixture = await setup({ + ...activeCombat, + monster: { ...activeCombat.monster, enraged: true }, + }); + + expect( + fixture.nativeElement.querySelector('[data-combat-enraged]'), + ).not.toBeNull(); + }); + + it('reads the new events back in the log', async () => { + const fixture = await setup({ + ...veteran, + events: [ + { round: 1, sequence: 1, type: 'GUARD_RAISED', source: 'MONSTER', target: 'MONSTER', amount: 2 }, + { round: 1, sequence: 2, type: 'ENRAGED', source: 'MONSTER', target: 'MONSTER' }, + { round: 2, sequence: 1, type: 'GUARD_ENDED', source: 'MONSTER', target: 'MONSTER' }, + ], + }); + + const log = ( + fixture.nativeElement as HTMLElement + ).querySelector('.combat__log')?.textContent; + expect(log).toContain('Raider Veteran raises its guard.'); + expect(log).toContain('Raider Veteran turns savage.'); + expect(log).toContain("Raider Veteran's guard drops."); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: FAIL — `'guardRemainingRounds' does not exist in type 'CombatMonster'` + +- [ ] **Step 3: Extend the client models** + +In `apps/web/src/app/core/api/game-api.models.ts`, add to `CombatMonster`: + +```ts + /** Rounds the monster's raised guard still covers, or null when open. */ + guardRemainingRounds: number | null; + enraged: boolean; +``` + +and add `| 'GUARD_RAISED' | 'GUARD_ENDED' | 'ENRAGED'` to the `CombatEventType` +union. + +Both fields are required, matching the API DTO. Four spec files build a +`CombatMonster` literal and will stop compiling until each gets +`guardRemainingRounds: null, enraged: false` added to its fixture: + +```text +apps/web/src/app/core/resume-combat.spec.ts +apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +apps/web/src/app/features/combat/combat.store.spec.ts +apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +``` + +- [ ] **Step 4: Add the component logic** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`, add +two accessors next to `monsterIntentLabel`: + +```ts + protected monsterGuardLabel(): string | null { + const combat = this.displayed(); + const rounds = combat?.monster.guardRemainingRounds; + if (!combat || !rounds) { + return null; + } + const roundWord = rounds === 1 ? 'round' : 'rounds'; + return `${combat.monster.name} is covering — ${rounds} ${roundWord}. Shield Bash breaks it.`; + } + + protected monsterIsEnraged(): boolean { + return this.displayed()?.monster.enraged ?? false; + } +``` + +and add three branches to the log formatter, beside the `TELEGRAPH` branch: + +```ts + if (event.type === 'GUARD_RAISED') { + return `${monsterName} raises its guard.`; + } + + if (event.type === 'GUARD_ENDED') { + return `${monsterName}'s guard drops.`; + } + + if (event.type === 'ENRAGED') { + return `${monsterName} turns savage.`; + } +``` + +- [ ] **Step 5: Add the markup** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, +below the existing telegraph paragraph: + +```html + @if (monsterGuardLabel(); as guard) { +

{{ guard }}

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

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

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

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

+ } +``` + +Style `.interaction__discovery` in the component's SCSS using the accent token +the panel already uses for emphasis. Do not add a new colour. + +- [ ] **Step 5: Refresh the map after a discovery** + +In `apps/web/src/app/features/world/local-location.store.ts`, after an +interaction resolves, reload the current location when the result carries a +discovery — the new route has to appear without a manual refresh: + +```ts + if (result.discoveredLocation) { + // The connection list is server-filtered, so a fresh reveal only shows + // up after the location is re-read. + await this.loadCurrentLocation(); + } +``` + +Use whatever the store's existing reload method is called. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 7: Run the whole web suite** + +Run: `npm test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/world +git commit -m "feat(web): announce a newly discovered route" +``` + +--- + +### Task 13: Verify end-to-end, build, and document + +**Files:** +- Create: `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md` +- Modify: `docs/playable-slices/0.10-Abandoned-Watchpost.md` (tick the acceptance criteria) + +**Interfaces:** +- Consumes: everything above. +- Produces: a record of what was built and where it deviates from the slice document. + +- [ ] **Step 1: Run the migration and seed against a real database** + +```bash +npm run db:migrate +npm run db:seed +npm run db:seed +``` + +Expected: the migration applies cleanly; the seed runs twice with no duplicate-key +error and no duplicated rows (AGENTS §8). + +- [ ] **Step 2: Walk the loop in the browser** + +Start the app (`npm run dev:api` and `npm run dev:web`) and confirm by hand: + +1. The Burned Road shows a route to the Abandoned Watchpost; travelling takes ~15 s. +2. The Watchpost map shows **no** Ash Pit route. +3. Inspecting the watchpost reveals the §8 clue and announces the new route. +4. The Ash Pit route now appears and can be travelled. +5. A hunt at the Watchpost only offers the five monsters from its own pool. +6. A Raider Veteran fight shows the guard banner; Shield Bash breaks it. +7. A Burned Hound below 35 % HP shows the enrage banner and hits harder. +8. Scorched Hide and Raider Warband Mark both drop and both sell to Borin. + +- [ ] **Step 3: Run lint and build** + +```bash +npm run lint --workspace=@ashen-realms/api +npm run build +``` + +Expected: both pass. Fix anything they surface before continuing. + +- [ ] **Step 4: Run the full test suite** + +```bash +npm test +``` + +Expected: PASS across both workspaces. + +- [ ] **Step 5: Write the implementation notes** + +Create `docs/playable-slices/0.10-Abandoned-Watchpost-implementation-notes.md` +following the shape of the Slice 0.9 notes. Record at minimum: + +- the discovery model (table, connection column, service) and why the gate is + enforced in two places +- `guard` and `enrage` as content abilities, with their exact configuration and + the telegraph-wins priority rule +- the Ash Pit stub and what Slice 0.11 is expected to add to it +- deviations from the slice document: no surviving-guard NPC (§3, no portrait + art exists — the investigation is a hotspot instead), and the crossed raider + artwork files (design D7) +- the new `tools/derive-monster-assets.ps1` and when it needs re-running + +- [ ] **Step 6: Tick the acceptance criteria** + +In `docs/playable-slices/0.10-Abandoned-Watchpost.md` §12, change each `- [ ]` +to `- [x]`. If any criterion is genuinely unmet, leave it unticked and say why +in the implementation notes rather than ticking it anyway. + +- [ ] **Step 7: Commit** + +```bash +git add docs/playable-slices +git commit -m "docs: record the Slice 0.10 watchpost implementation and its deviations" +``` + +--- + +## Self-Review Notes + +Spec coverage check, section by section: + +| Spec section | Task | +|---|---| +| §3 Discovery data + service | 1, 5 | +| §3.3 Two call sites | 6, 7 | +| §3.4 POI discovery | 7, 9 | +| §4.1 Locations | 9 | +| §4.2 Connections | 9 | +| §4.3 Local view | 9 | +| §5 Encounter pool | 9 | +| §6.1 Trade goods + exchange | 8 | +| §6.2 Loot tables | 8 | +| §7.1–7.2 Guard | 2 | +| §7.3 Enrage | 3 | +| §7.4 Contract changes | 1, 2, 3, 4 | +| §8 Frontend | 11, 12 | +| §9 Artwork | 10 | +| §10 Tests | woven through every task | +| §11 Out of scope | 9 (Ash Pit stays a stub), 13 (documented) | diff --git a/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md b/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md new file mode 100644 index 0000000..35c02fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-slice-0.10-abandoned-watchpost-design.md @@ -0,0 +1,410 @@ +# Playable Slice 0.10 — Abandoned Watchpost — Design + +**Date:** 2026-08-23 +**Slice document:** `docs/playable-slices/0.10-Abandoned-Watchpost.md` +**Depends on:** Slice 0.9 (quests, bags), 0.8.5 (reputation-gated offers), 0.7.5 (loot categories) +**Status:** Approved design, ready for implementation planning + +--- + +## 1. What this slice adds + +A second hunting location beyond the Burned Road, with a stronger encounter +pool, its own trade goods, and the first piece of world progression that is not +a level number: a route the player has to *discover* before they can walk it. + +Three things are genuinely new to the codebase. Everything else is content. + +1. **Per-character world discovery.** There is no such state today — + `location_connections.enabled` is a single global boolean, and + `TravelService.startTravel` reads nothing else. A gated route needs + player-owned state. +2. **Two combat abilities**, `guard` and `enrage`, added to the content-driven + ability set the engine already reads (`telegraph`, `bleed`). +3. **A derived-asset script for monsters.** Four new enemies need four + derivatives each, and no script produces them today. + +--- + +## 2. Decisions taken during design + +| # | Decision | Rejected alternative | +|---|---|---| +| D1 | Discovery lives in its own table `character_location_discoveries`, gated by a `requires_discovery` column on the connection | A generic per-character key/value flag table; the Ash Pit route still needs a target location, so the extra indirection buys nothing | +| D2 | The Ash Pit is seeded in 0.10 as a minimal stub location | Deferring the route to 0.11, which would leave §11 and §12 unmet | +| D3 | `guard` is a timed armor increase that skips the monster's attack and is broken by `SHIELD_BASH` | A permanent low-HP stance (no player answer); a second damage-reduction axis beside armor | +| D4 | The investigation is a POI that writes the discovery — no new NPC, no new quest | A follow-up quest chain (more content than the slice needs); a surviving-guard NPC (no portrait art exists) | +| D5 | Two new trade goods, priced above the Burned Road tier | Reusing existing goods, which leaves §10 unmet and makes the longer trip pointless | +| D6 | The filter lives in a dedicated `WorldDiscoveryService` | Inline checks in `WorldService` and `TravelService`, which would duplicate the rule | +| D7 | `raider-scout.png` and `raider-veteran.png` are swapped when copied: the file named *scout* depicts the heavier, plated, spear-carrying figure and becomes the Veteran | Following the filenames, which would contradict §5's role descriptions | + +--- + +## 3. Discovery subsystem + +### 3.1 Data + +New table, player state, no content: + +```text +character_location_discoveries + id uuid pk + character_id uuid → characters(id) ON DELETE CASCADE + location_id uuid → location_definitions(id) ON DELETE CASCADE + discovered_at timestamptz + UNIQUE (character_id, location_id) +``` + +New column on content: + +```text +location_connections.requires_discovery boolean NOT NULL DEFAULT false +``` + +The connection carries its own gate. A location stays free of routing rules, so +a place can be reachable by one road and gated on another. + +### 3.2 `WorldDiscoveryService` + +Lives in the `world` module. Three methods, no HTTP and no combat knowledge: + +- `getDiscoveredLocationIds(characterId): Promise>` +- `discover(characterId, locationKey, manager?): Promise` + — inserts `ON CONFLICT DO NOTHING`, returns the location on first discovery + and `null` when it was already known, so callers can tell a fresh reveal from + a repeat (§30: duplicate requests must be safe). +- `isTravelAllowed(characterId, connection, manager?): Promise` — + `true` when `requiresDiscovery` is false or the target is already discovered. + +The optional `manager` lets `TravelService` run the check inside its existing +transaction rather than on a second connection. + +### 3.3 Two call sites + +**Display** — `WorldService.getCurrentLocation` filters `connections` through +`isTravelAllowed`. An undiscovered route never appears. The Angular map reads +exactly this array, so the frontend needs no change for the gate. + +**Enforcement** — `TravelService.startTravel` applies the same rule inside its +transaction, right after it resolves the connection, and throws the existing +`INVALID_TRAVEL_TARGET`. Without this the gate would be UI decoration; AGENTS §5 +requires the server to own it. + +### 3.4 How discovery happens + +`LocationPointOfInterestContent` gains an optional field: + +```ts +discoversLocationKey?: string; +``` + +`WorldService.runLocalInteraction` calls `discover(...)` before returning, and +the result grows one field: + +```ts +discoveredLocation: { key: string; name: string } | null; +``` + +Non-null only on the interaction that first reveals the route. The endpoint is +no longer read-only — deliberate, and idempotent by the unique constraint. + +The interaction panel renders a short "New route discovered — Ash Pit" line when +the field is set. + +--- + +## 4. World content + +### 4.1 Locations + +**Abandoned Watchpost** (`abandoned-watchpost`) + +| Field | Value | +|---|---| +| region | `ashen-fields`, "Ashen Fields", Tier 1 | +| locationType | `OUTPOST` | +| recommended | 2–3 | +| dangerLevel | 2 | +| isSafe / huntingEnabled | false / true | +| artwork | `/images/backgrounds/Wachturm.png` | + +**Ash Pit stub** (`ash-pit`) + +Seeded so the discovered route has a real target and §11 is testable. +`locationType: 'TRANSITION'`, `huntingEnabled: false`, `isSafe: false`, +`dangerLevel: 3`, artwork `/images/backgrounds/Aschengrube.png`, empty POI and +primary-action sets apart from a `MAP` entry, empty reward preview. Slice 0.11 +fills it in. + +### 4.2 Connections + +| From | To | Duration | Ambush | requiresDiscovery | +|---|---|---|---|---| +| Burned Road | Abandoned Watchpost | 15 s | 0.1000 | false | +| Abandoned Watchpost | Burned Road | 15 s | 0.1000 | false | +| Abandoned Watchpost | Ash Pit | 20 s | 0.1500 | **true** | +| Ash Pit | Abandoned Watchpost | 20 s | 0.1500 | false | + +No level requirement anywhere (§2, §9). The return leg from the Ash Pit is +ungated — a player who got there must always be able to leave. + +### 4.3 Local view + +Four points of interest on `Wachturm.png`: + +| Key | Type | Purpose | +|---|---|---| +| `hunt-area` | HUNT | opens the hunt | +| `inspect-watchpost` | INVESTIGATE | carries `discoversLocationKey: 'ash-pit'` and the §8 clue text | +| `search-guard-quarters` | SEARCH | flavour only, no reward | +| `east-road` | MAP | back to the map | + +The clue text is quoted from §8: + +> "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." + +Primary actions mirror the Burned Road's four-entry bar: Begin Hunt, Inspect the +watchpost (`poiKey: inspect-watchpost`), Search the quarters, To Map. + +Reward preview: Equipment, Trade Goods. No Silver, no experience — normal kills +grant neither (§6). + +--- + +## 5. Encounter pool + +Only these five entries are attached to the Watchpost, so the pool is its own +(§11). + +| Monster | Key | Cat. | Lvl | HP | Atk | Armor | Weight | Type | Abilities | +|---|---|---|---|---|---|---|---|---|---| +| Raider Scout | `raider-scout` | HUMANOID | 2 | 70 | 10 | 3 | 35 | NORMAL | — | +| Burned Hound | `burned-hound` | BEAST | 3 | 80 | 12 | 2 | 28 | NORMAL | `bleed`, `enrage` | +| Road Bandit | `road-bandit` | HUMANOID | 2 | 75 | 9 | 5 | 20 | NORMAL | `telegraph` | +| Raider Veteran | `raider-veteran` | HUMANOID | 3 | 120 | 14 | 10 | 14 | NORMAL | `telegraph`, `guard` | +| Raider Captain | `raider-captain` | HUMANOID | 4 | 160 | 17 | 12 | 3 | RARE | `telegraph`, `guard` | + +The Road Bandit is reused deliberately: §4 names it, it bridges the two +locations, and it keeps the Raider Insignia economy connected. Its row above +restates the values it already has — this slice adds a pool entry for it and +changes nothing about the monster. + +Ability configuration: + +```text +burned-hound bleed { roundInterval: 2, damagePerRound: 6, durationRounds: 2 } + enrage { hpThresholdPercent: 35, damageMultiplier: 1.4 } +raider-veteran telegraph { roundInterval: 3, damageMultiplier: 1.6 } + guard { roundInterval: 4, armorBonus: 10, durationRounds: 2 } +raider-captain telegraph { roundInterval: 2, damageMultiplier: 1.7 } + guard { roundInterval: 3, armorBonus: 12, durationRounds: 2 } +``` + +The Veteran's intervals (3 and 4) are chosen so the two abilities collide only +every twelfth round; when they do, the telegraph wins by fixed priority. + +--- + +## 6. Loot and economy + +### 6.1 New trade goods + +| Key | Name | Category | Type | Silver | Region rep | +|---|---|---|---|---|---| +| `scorched-hide` | Scorched Hide | HIDE | TRADE_GOOD | 12 | 4 | +| `raider-warband-mark` | Raider Warband Mark | RAIDER_TROPHY | TROPHY | 20 | 7 | + +Both get an `ExchangeRule` on Borin's profile (`sortOrder` 5 and 6, faction +`border-guard`, milestone `FIRST_TRADE_MILESTONE_KEY`, no conditions). Prices sit +above the Burned Road tier (Ashen Pelt 5, Tough Hide 8, Raider Insignia 14, +Charred Raider Insignia 30) so the longer trip pays, without passing the rare +Charred Raider Insignia. + +Both carrying systems matter here (§6, §12): the Burned Hound feeds HIDE, the +three raiders feed RAIDER_TROPHY. A player without a Trophy Pouch hits the +bagless capacity of 1 and is pushed back to Borin. + +### 6.2 Loot tables + +One table per monster (the established pattern). Every table has a guaranteed +trade good and independent equipment rolls. + +| Table | Entries | +|---|---| +| `raider-scout-loot` | `raider-warband-mark` 0.6000, `bandit-blade` 0.2500, `bandit-hood` 0.1800 | +| `burned-hound-loot` | `scorched-hide` 0.6000, `ash-boots` 0.1500 | +| `raider-veteran-loot` | `raider-warband-mark` 0.7000, `raider-gloves` 0.1500, `reinforced-leather-jacket` 0.2000, `guardsman-legs` 0.2200 | +| `raider-captain-loot` | `raider-warband-mark` 1.0000, `reinforced-leather-jacket` 0.3000, `guardsman-legs` 0.3000, `borderwatch-sigil` 0.2000 | + +The Road Bandit keeps its existing table unchanged — retuning it would change +Burned Road balance, which this slice was not asked to touch (AGENTS §39). + +All five §7 items are already seeded; this slice raises their availability +rather than adding equipment. `borderwatch-sigil` is the Captain's focused +desirable drop (§5). + +--- + +## 7. Combat engine + +### 7.1 New content abilities + +```ts +export interface MonsterGuardAbility { + roundInterval: number; + armorBonus: number; + durationRounds: number; +} + +export interface MonsterEnrageAbility { + hpThresholdPercent: number; + damageMultiplier: number; +} +``` + +Both hang off `MonsterAbilities`. The engine keeps branching on configuration, +never on a monster key (AGENTS §9). + +### 7.2 Guard + +State lives on the monster's stats, beside `pendingAction`: + +```ts +activeGuard?: { remainingRounds: number; armorBonus: number }; +``` + +- When `guard` triggers, the monster raises its guard **instead of attacking** + and emits `GUARD_RAISED`. +- While active, `armorBonus` is added to the monster's armor in every + `calculateDamage` call against it. +- The counter decrements at the end of each round; at zero the field is cleared + and `GUARD_ENDED` is emitted. +- `SHIELD_BASH` clears it exactly as it clears a telegraph, emitting `INTERRUPT` + and `GUARD_ENDED`. This is the same lesson the player already learned. + +Priority in `resolveMonsterTurn`, top to bottom: + +1. resolve a pending Heavy Strike +2. raise a telegraph +3. raise a guard +4. normal attack (plus `bleed` if due) + +### 7.3 Enrage + +When the monster's HP first falls to or below `hpThresholdPercent` of its +maximum, `enraged: true` is set on its stats and `ENRAGED` is emitted once. From +then on `damageMultiplier` applies to every strike it makes, including a +telegraphed one. It never expires and never re-triggers — one deterministic +state change, no hidden roll. + +### 7.4 Contract changes + +- `CombatEventType` gains `GUARD_RAISED`, `GUARD_ENDED`, `ENRAGED`; a migration + appends them to `combat_event_type_enum` following migration 1790's pattern. +- `CombatMonsterDto` gains `guardRemainingRounds: number | null` and + `enraged: boolean`. + +--- + +## 8. Frontend + +Only the combat screen changes. + +- A guard badge next to the existing telegraph indicator, showing the remaining + rounds, and an enrage badge on the monster. +- Combat-log lines for the three new event types. +- `monster-artwork.ts` gains cutout, icon, runtime and sprite-scale entries for + the four new keys. +- The interaction panel shows the discovered-route line when + `discoveredLocation` is set. + +The map, travel panel and local view need no change — they already render +whatever `connections` and `pointsOfInterest` the API returns. + +--- + +## 9. Artwork + +Sources committed in `2435d25`: + +| Web key | Full art | Cutout | Icon | +|---|---|---|---| +| `raider-scout` | `art/enemies/raider-veteran.png` | transparent variant | cropped from cutout | +| `raider-veteran` | `art/enemies/raider-scout.png` | transparent variant | cropped from cutout | +| `burned-hound` | `art/enemies/burned-hound.png` | transparent variant | cropped from cutout | +| `raider-captain` | `art/enemies/Pluendererhauptmann.png` | transparent variant | `art/enemies/PluendererhauptmannIcon.png` | + +The first two rows are crossed on purpose (D7). + +Backgrounds: `art/backgrounds/Wachturm.png` and `art/backgrounds/Aschengrube.png` +copy to `apps/web/public/images/backgrounds/`, with 960px runtime JPEGs beside +them. + +New script `tools/derive-monster-assets.ps1`, written in the same style as +`tools/extract-item-icons.ps1` (System.Drawing, re-runnable, overwrites only its +own output). Per monster it produces: + +```text +apps/web/public/images/monsters/.png +apps/web/public/images/monsters/runtime/-560.jpg +apps/web/public/images/combat/sprites/-.png +apps/web/public/images/combat/icons/-128.png +``` + +The runtime derivative is a JPEG because the source it comes from carries its +own painted background. The existing `-560.png` entries are the exceptions, not +the rule, and `monster-artwork.ts` records the real extension per key either +way. + +Crop rectangles for the icons are authored per monster in the script, not +guessed at runtime. Where a hand-made icon exists it wins over a crop. + +--- + +## 10. Tests + +Mapped to slice §11. + +**Engine (unit, deterministic)** +- guard raises armor for exactly `durationRounds` and then clears +- the round a guard goes up deals no damage to the player +- `SHIELD_BASH` breaks an active guard and emits `INTERRUPT` + `GUARD_ENDED` +- telegraph beats guard when both are due in the same round +- enrage fires exactly once at the threshold and persists to the end of combat +- a monster with no new abilities behaves exactly as before + +**Discovery** +- `getCurrentLocation` omits the Ash Pit route before discovery and includes it + after +- `startTravel` to the Ash Pit throws `INVALID_TRAVEL_TARGET` before discovery + and succeeds after +- running the investigation twice inserts one row and returns + `discoveredLocation: null` the second time +- the return leg from the Ash Pit is never gated + +**Seed** +- the Watchpost pool contains exactly its five entries and no Burned Road + location row is touched +- each new trade good carries the right `LootCategory` +- no Watchpost monster grants Silver or reputation directly +- both new goods have an exchange rule on Borin's profile +- re-running the seed produces no duplicates + +**Migration** +- the discovery table, its unique constraint and both foreign keys exist +- `requires_discovery` exists with default false +- the three new enum values exist on `combat_event_type_enum` + +**Frontend** +- the combat store renders guard and enrage badges from the DTO +- the interaction panel shows the discovered-route line only when the field is + set + +--- + +## 11. Out of scope + +Per slice §13: no second region, no crafting, no procedural events, no stealth, +no large dialogue trees, no area boss. The Ash Pit stays a stub with no encounter +pool — Slice 0.11 owns it. diff --git a/tools/derive-monster-assets.ps1 b/tools/derive-monster-assets.ps1 new file mode 100644 index 0000000..9fcc26a --- /dev/null +++ b/tools/derive-monster-assets.ps1 @@ -0,0 +1,154 @@ +# tools/derive-monster-assets.ps1 +# Derives the web assets each monster needs from the hand-made art in +# art/enemies and art/backgrounds. +# +# Per monster it writes four files: +# apps/web/public/images/monsters/.png full painted artwork +# apps/web/public/images/monsters/runtime/-560.jpg downscaled for the web +# apps/web/public/images/combat/sprites/-.png background-free cutout +# apps/web/public/images/combat/icons/-128.png medallion icon +# +# The generated files are committed, so this only needs re-running when the +# source art changes. Re-runnable: it overwrites its own output and touches +# nothing else. +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$root = Split-Path -Parent $PSScriptRoot +$artEnemies = Join-Path $root 'art\enemies' +$artCutouts = Join-Path $artEnemies 'transparent-background' +$artBackgrounds = Join-Path $root 'art\backgrounds' +$webImages = Join-Path $root 'apps\web\public\images' + +foreach ($dir in @( + (Join-Path $webImages 'monsters\runtime'), + (Join-Path $webImages 'combat\sprites'), + (Join-Path $webImages 'combat\icons'), + (Join-Path $webImages 'backgrounds\runtime'))) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} + +function Save-Scaled { + param( + [string] $SourcePath, + [string] $TargetPath, + [int] $TargetWidth, + [string] $Format, + [switch] $Opaque + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $height = [int][Math]::Round($img.Height * $TargetWidth / $img.Width) + $bitmap = New-Object System.Drawing.Bitmap $TargetWidth, $height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + if ($Opaque) { + $graphics.Clear([System.Drawing.Color]::Black) + } + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage($img, 0, 0, $TargetWidth, $height) + $graphics.Dispose() + if ($Format -eq 'jpg') { + $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | + Where-Object { $_.MimeType -eq 'image/jpeg' } + $params = New-Object System.Drawing.Imaging.EncoderParameters 1 + $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter( + [System.Drawing.Imaging.Encoder]::Quality, 82) + $bitmap.Save($TargetPath, $codec, $params) + } + else { + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + } + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +function Save-Icon { + param( + [string] $SourcePath, + [string] $TargetPath, + # Crop window as fractions of the source, chosen per monster so the icon + # lands on the head rather than on whatever the centre happens to be. + [double] $CropX, [double] $CropY, [double] $CropSize + ) + $img = [System.Drawing.Image]::FromFile($SourcePath) + try { + $side = [int]([Math]::Min($img.Width, $img.Height) * $CropSize) + $x = [int]($img.Width * $CropX) + $y = [int]($img.Height * $CropY) + $bitmap = New-Object System.Drawing.Bitmap 128, 128 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $graphics.DrawImage( + $img, + (New-Object System.Drawing.Rectangle 0, 0, 128, 128), + (New-Object System.Drawing.Rectangle $x, $y, $side, $side), + [System.Drawing.GraphicsUnit]::Pixel) + $graphics.Dispose() + $bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + } + finally { + $img.Dispose() + } + Write-Host "wrote $(Split-Path -Leaf $TargetPath)" +} + +# `source` is the painted file, `cutout` the background-free one. +# +# NOTE the deliberate crossing on the first two rows: the file named +# raider-scout depicts the heavier, plated, spear-carrying figure and is the +# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10 +# design decision D7. +$monsters = @( + @{ key = 'raider-scout'; source = 'raider-veteran'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'raider-veteran'; source = 'raider-scout'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 }, + @{ key = 'burned-hound'; source = 'burned-hound'; spriteHeight = 760; cropX = 0.06; cropY = 0.12; cropSize = 0.34 }, + @{ key = 'raider-captain'; source = 'Pluendererhauptmann'; spriteHeight = 620; cropX = 0.36; cropY = 0.02; cropSize = 0.28 } +) + +foreach ($monster in $monsters) { + $key = $monster.key + $painted = Join-Path $artEnemies "$($monster.source).png" + $cutout = Join-Path $artCutouts "$($monster.source).png" + + Copy-Item -Force $painted (Join-Path $webImages "monsters\$key.png") + Write-Host "wrote $key.png" + + Save-Scaled -SourcePath $painted ` + -TargetPath (Join-Path $webImages "monsters\runtime\$key-560.jpg") ` + -TargetWidth 560 -Format 'jpg' -Opaque + + Save-Scaled -SourcePath $cutout ` + -TargetPath (Join-Path $webImages "combat\sprites\$key-$($monster.spriteHeight).png") ` + -TargetWidth $monster.spriteHeight -Format 'png' + + # The Raider Captain's icon comes from hand-made art below, not a generated + # crop of the body art, so skip the crop step for that key here. + if ($key -ne 'raider-captain') { + 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 generated +# crop, but it still has to ship at icon size: resize the 1254x1254 source +# down to 128x128 rather than copying it verbatim. It's already a square +# portrait, so a straight bicubic resize preserves the authored composition +# with no re-cropping. +Save-Scaled -SourcePath (Join-Path $artEnemies 'PluendererhauptmannIcon.png') ` + -TargetPath (Join-Path $webImages 'combat\icons\raider-captain-128.png') ` + -TargetWidth 128 -Format 'png' + +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 +}