Merge branch 'slice/0.10-abandoned-watchpost'

This commit is contained in:
Bastian Wagner
2026-08-23 17:18:03 +02:00
72 changed files with 6595 additions and 97 deletions

3
.gitignore vendored
View File

@@ -14,4 +14,5 @@ coverage/
.DS_Store
.worktrees/
.worktrees/
.superpowers/

View File

@@ -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);
});
});
});

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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(

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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<void> {
await queryRunner.query(`
CREATE TABLE "character_location_discoveries" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"character_id" uuid NOT NULL,
"location_id" uuid NOT NULL,
"discovered_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_location_discoveries" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_location_discoveries_character"
FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE,
CONSTRAINT "FK_character_location_discoveries_location"
FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")`,
);
await queryRunner.query(
`ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "IDX_character_location_discoveries_pair"`,
);
await queryRunner.query(`DROP TABLE "character_location_discoveries"`);
await queryRunner.query(
`ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"`,
);
// Postgres cannot drop an enum value, so the type is rebuilt -- the same
// tradeoff migration 1790 already makes. Fails if any row uses one of the
// new values, which is the expected shape of a dev rollback.
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`,
);
await queryRunner.query(`DROP TYPE "combat_event_type_enum"`);
await queryRunner.query(
`CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'HEAL', 'DEFEND', 'TELEGRAPH', 'INTERRUPT', 'STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED', 'COMBAT_WON', 'COMBAT_LOST')`,
);
await queryRunner.query(
`ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`,
);
}
}

View File

@@ -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<string> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateAbandonedWatchpost1798000000000().up(queryRunner);
return collapse(query.mock.calls.map(([sql]) => sql as string));
}
async function runDown(): Promise<string> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateAbandonedWatchpost1798000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return collapse(query.mock.calls.slice(upCount).map(([sql]) => sql as string));
}
describe('CreateAbandonedWatchpost1798000000000', () => {
it('creates the discovery table', async () => {
const joined = await runUp();
expect(joined).toContain('CREATE TABLE "character_location_discoveries"');
});
it('lets a character discover a location only once', async () => {
const joined = await runUp();
// The unique index, not a disabled button, is what makes a repeated
// investigation harmless (AGENTS.md §30).
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_character_location_discoveries_pair" ON "character_location_discoveries" ("character_id", "location_id")',
);
});
it('cascades discoveries away with their character and location', async () => {
const joined = await runUp();
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
expect(joined).toContain(
'FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE CASCADE',
);
});
it('adds an ungated-by-default discovery flag to connections', async () => {
const joined = await runUp();
// Default false: every route that exists today stays walkable.
expect(joined).toContain(
'ALTER TABLE "location_connections" ADD COLUMN "requires_discovery" boolean NOT NULL DEFAULT false',
);
});
it('extends the combat event enum with the two new mechanics', async () => {
const joined = await runUp();
expect(joined).toContain(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_RAISED'`,
);
expect(joined).toContain(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'GUARD_ENDED'`,
);
expect(joined).toContain(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'ENRAGED'`,
);
});
it('reverses the table and the column', async () => {
const joined = await runDown();
expect(joined).toContain('DROP TABLE "character_location_discoveries"');
expect(joined).toContain(
'ALTER TABLE "location_connections" DROP COLUMN "requires_discovery"',
);
});
});

View File

@@ -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'),
];

View File

@@ -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';

View File

@@ -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: [],
};

View File

@@ -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,
},
];

View File

@@ -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';

View File

@@ -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<string, unknown>;
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<Record<string, unknown>>
).find((entry) => entry.key === 'inspect-watchpost');
expect(poi?.discoversLocationKey).toBe('ash-pit');
});
it('gives the watchpost its own encounter pool', async () => {
const 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);
});
});

View File

@@ -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<string, string>();
@@ -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<string, string>();
@@ -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,
});

View File

@@ -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 = {};

View File

@@ -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 }],

View File

@@ -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 });
});
});

View File

@@ -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,
});

View File

@@ -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;
}

View File

@@ -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 {}

View File

@@ -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<string, unknown>;
orIgnore: boolean;
}
function buildService(options: {
discoveries?: Array<{ locationId: string }>;
locations?: Array<Partial<LocationDefinition>>;
insertCalls?: InsertCall[];
}): WorldDiscoveryService {
const discoveries = options.discoveries ?? [];
const locations = options.locations ?? [];
const insertCalls = options.insertCalls ?? [];
const discoveryRepository = {
find: jest.fn().mockResolvedValue(discoveries),
createQueryBuilder: jest.fn(() => {
const builder = {
insert: () => builder,
into: () => builder,
values: (values: Record<string, unknown>) => {
insertCalls.push({ values, orIgnore: false });
return builder;
},
orIgnore: () => {
insertCalls[insertCalls.length - 1].orIgnore = true;
return builder;
},
execute: jest
.fn()
.mockResolvedValue({ identifiers: [{ id: 'new-row' }] }),
};
return builder;
}),
} as unknown as Repository<CharacterLocationDiscovery>;
const locationRepository = {
findOneBy: jest.fn(({ key }: { key: string }) =>
Promise.resolve(locations.find((location) => location.key === key) ?? null),
),
} as unknown as Repository<LocationDefinition>;
const dataSource = {
getRepository: (target: unknown) =>
target === CharacterLocationDiscovery
? discoveryRepository
: locationRepository,
} as unknown as DataSource;
return new WorldDiscoveryService(dataSource);
}
describe('WorldDiscoveryService', () => {
it('returns the ids the character has already discovered', async () => {
const service = buildService({
discoveries: [{ locationId: ASH_PIT_ID }],
});
const discovered = await service.getDiscoveredLocationIds(CHARACTER_ID);
expect(discovered.has(ASH_PIT_ID)).toBe(true);
expect(discovered.has(WATCHPOST_ID)).toBe(false);
});
it('allows travel down a route that carries no gate', async () => {
const service = buildService({});
const connection = {
toLocationId: WATCHPOST_ID,
requiresDiscovery: false,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(true);
});
it('refuses a gated route the character has not discovered', async () => {
const service = buildService({});
const connection = {
toLocationId: ASH_PIT_ID,
requiresDiscovery: true,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(false);
});
it('allows a gated route once it has been discovered', async () => {
const service = buildService({ discoveries: [{ locationId: ASH_PIT_ID }] });
const connection = {
toLocationId: ASH_PIT_ID,
requiresDiscovery: true,
} as LocationConnection;
await expect(
service.isTravelAllowed(CHARACTER_ID, connection),
).resolves.toBe(true);
});
it('returns the location the first time it is discovered', async () => {
const service = buildService({
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
});
await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toEqual({
key: 'ash-pit',
name: 'Ash Pit',
});
});
it('returns null when the location was already known', async () => {
const service = buildService({
discoveries: [{ locationId: ASH_PIT_ID }],
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
});
await expect(service.discover(CHARACTER_ID, 'ash-pit')).resolves.toBeNull();
});
it('writes the row so a duplicate is ignored rather than thrown', async () => {
const insertCalls: InsertCall[] = [];
const service = buildService({
locations: [{ id: ASH_PIT_ID, key: 'ash-pit', name: 'Ash Pit' }],
insertCalls,
});
await service.discover(CHARACTER_ID, 'ash-pit');
expect(insertCalls).toHaveLength(1);
expect(insertCalls[0].orIgnore).toBe(true);
expect(insertCalls[0].values).toEqual({
characterId: CHARACTER_ID,
locationId: ASH_PIT_ID,
});
});
it('ignores an unknown location key', async () => {
const service = buildService({ locations: [] });
await expect(service.discover(CHARACTER_ID, 'nowhere')).resolves.toBeNull();
});
});

View File

@@ -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<Set<string>> {
const repository = this.discoveries(manager);
const rows = await repository.find({
where: { characterId },
select: { locationId: true },
});
return new Set(rows.map((row) => row.locationId));
}
/**
* Records that the character now knows this place.
*
* Returns the location the first time and `null` afterwards, so a caller can
* tell a fresh reveal from a repeated click without a second query. The
* insert ignores a conflict rather than throwing: the same interaction run
* twice is a normal thing for a player to do (AGENTS.md §30).
*/
async discover(
characterId: string,
locationKey: string,
manager?: EntityManager,
): Promise<DiscoveredLocation | null> {
const locations = manager
? manager.getRepository(LocationDefinition)
: this.dataSource.getRepository(LocationDefinition);
const location = await locations.findOneBy({ key: locationKey });
if (!location) {
return null;
}
const known = await this.getDiscoveredLocationIds(characterId, manager);
if (known.has(location.id)) {
return null;
}
await this.discoveries(manager)
.createQueryBuilder()
.insert()
.into(CharacterLocationDiscovery)
.values({ characterId, locationId: location.id })
.orIgnore()
.execute();
return { key: location.key, name: location.name };
}
async isTravelAllowed(
characterId: string,
connection: Pick<LocationConnection, 'toLocationId' | 'requiresDiscovery'>,
manager?: EntityManager,
): Promise<boolean> {
if (!connection.requiresDiscovery) {
return true;
}
const known = await this.getDiscoveredLocationIds(characterId, manager);
return 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<string>,
connection: Pick<LocationConnection, 'toLocationId' | 'requiresDiscovery'>,
): boolean {
return (
!connection.requiresDiscovery ||
discoveredLocationIds.has(connection.toLocationId)
);
}
private discoveries(manager?: EntityManager) {
return manager
? manager.getRepository(CharacterLocationDiscovery)
: this.dataSource.getRepository(CharacterLocationDiscovery);
}
}

View File

@@ -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,

View File

@@ -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<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
{
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<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
worldDiscovery,
);
return { service, discover };
}
async function expectRejected(promise: Promise<unknown>): Promise<void> {
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();
});
});

View File

@@ -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. */

View File

@@ -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],

View File

@@ -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<Character>;
const connections = {
find: jest.fn().mockResolvedValue(options.connections ?? []),
} as unknown as Repository<LocationConnection>;
const locationMonsters = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationMonster>;
const worldDiscovery = {
discover: jest.fn(),
getDiscoveredLocationIds: jest
.fn()
.mockResolvedValue(new Set(options.discovered ?? [])),
isRouteOpen: (
discoveredLocationIds: ReadonlySet<string>,
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<LocationMonster>;
const worldDiscovery = {
getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()),
isRouteOpen: (
_discoveredLocationIds: ReadonlySet<string>,
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<LocationMonster>;
const worldDiscovery = {
getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()),
isRouteOpen: (
_discoveredLocationIds: ReadonlySet<string>,
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<LocationMonster>;
const worldDiscovery = {
getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()),
isRouteOpen: (
_discoveredLocationIds: ReadonlySet<string>,
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<LocationMonster>,
{
getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()),
isRouteOpen: (
_discoveredLocationIds: ReadonlySet<string>,
connection: { requiresDiscovery: boolean },
) => !connection.requiresDiscovery,
discover: jest.fn(),
} as unknown as WorldDiscoveryService,
);
return service.getCurrentLocation(CHARACTER_ID);

View File

@@ -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<LocationConnection>,
@InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>,
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,
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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 {

View File

@@ -25,6 +25,8 @@ const runningCombat: Combat = {
currentHp: 30,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [],
rewards: null,

View File

@@ -69,6 +69,16 @@
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
}
@if (monsterGuardLabel(); as guard) {
<p class="combat__telegraph combat__guard" data-combat-guard role="status">{{ guard }}</p>
}
@if (monsterIsEnraged()) {
<p class="combat__telegraph combat__enraged" data-combat-enraged role="status">
{{ combat.monster.name }} is enraged.
</p>
}
<div class="combat__field">
<div
class="sprite sprite--player"

View File

@@ -413,7 +413,7 @@
z-index: 2;
margin: 0;
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-gold);
border: 1px solid currentColor;
border-radius: var(--ar-radius-sm);
background: rgb(9 11 13 / 0.85);
color: var(--ar-gold);
@@ -423,6 +423,18 @@
text-align: center;
}
// The guard and enrage banners are the same shape and role as the telegraph
// above -- same class, same rule -- only the accent changes. data-combat-guard
// / data-combat-enraged stay on the elements purely as test hooks; styling
// keys off the semantic modifier classes instead.
.combat__guard {
color: var(--ar-blue);
}
.combat__enraged {
color: var(--ar-danger);
}
.action {
position: relative;
inline-size: clamp(6.5rem, 11vw, 8.5rem);
@@ -709,46 +721,7 @@
justify-content: flex-start;
}
/* ---------- ongoing effects ---------- */
/* Sits directly under the health bar it is eating away at, so the cause of
the drain is next to the number that drops (spec §4). */
.statuses {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--ar-border);
border-radius: 0.15rem;
background: rgb(0 0 0 / 0.35);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
}
/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and
the shape carries the meaning on its own. */
.status__glyph {
inline-size: 0.55rem;
block-size: 0.7rem;
background: currentcolor;
clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%);
}
.status--bleed {
border-color: rgb(158 46 42 / 0.75);
color: #d8736c;
}
.fighter--monster .fighter__meter {
.fighter--monster .fighter__meter {
justify-items: start;
text-align: start;
}

View File

@@ -37,6 +37,8 @@ const activeCombat: Combat = {
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
@@ -197,6 +199,61 @@ describe('CombatPageComponent', () => {
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,

View File

@@ -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') {

View File

@@ -26,6 +26,8 @@ const startedCombat: Combat = {
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [],
rewards: null,

View File

@@ -88,6 +88,8 @@ const startedCombat: Combat = {
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
pendingIntent: null,
guardRemainingRounds: null,
enraged: false,
},
events: [],
rewards: null,

View File

@@ -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();

View File

@@ -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 {

View File

@@ -20,6 +20,11 @@
<div class="interaction-panel__copy">
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
@if (result.discoveredLocation; as discovered) {
<p class="interaction-panel__discovery" data-discovered-location role="status">
New route discovered: {{ discovered.name }}.
</p>
}
</div>
</div>
} @else {

View File

@@ -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);

View File

@@ -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<LocationInteractionPanelComponent>;
@@ -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();
});
});

View File

@@ -14,6 +14,8 @@ import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.c
const RUNTIME_ARTWORK: Readonly<Record<string, string>> = {
'/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',
};
/**

View File

@@ -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'),
);
});
});

View File

@@ -3,6 +3,10 @@ const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
'/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<Record<string, string>> = {
'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<Record<string, string>> = {
@@ -23,6 +31,10 @@ const MONSTER_ICON: Readonly<Record<string, string>> = {
'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<Record<string, number>> = {
'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;

View File

@@ -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/<key>.png` — full painted artwork
- `apps/web/public/images/monsters/runtime/<key>-560.jpg` — downscaled web copy
- `apps/web/public/images/combat/sprites/<key>-<height>.png` — background-free
combat cutout
- `apps/web/public/images/combat/icons/<key>-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.

View File

@@ -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.
---

File diff suppressed because it is too large Load Diff

View File

@@ -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<Set<string>>`
- `discover(characterId, locationKey, manager?): Promise<LocationSummary | null>`
— 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<boolean>`
`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 | 23 |
| 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/<key>.png
apps/web/public/images/monsters/runtime/<key>-560.jpg
apps/web/public/images/combat/sprites/<key>-<height>.png
apps/web/public/images/combat/icons/<key>-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.

View File

@@ -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/<key>.png full painted artwork
# apps/web/public/images/monsters/runtime/<key>-560.jpg downscaled for the web
# apps/web/public/images/combat/sprites/<key>-<h>.png background-free cutout
# apps/web/public/images/combat/icons/<key>-128.png medallion icon
#
# The generated files are committed, so this only needs re-running when the
# source art changes. Re-runnable: it overwrites its own output and touches
# nothing else.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = Split-Path -Parent $PSScriptRoot
$artEnemies = Join-Path $root 'art\enemies'
$artCutouts = Join-Path $artEnemies 'transparent-background'
$artBackgrounds = Join-Path $root 'art\backgrounds'
$webImages = Join-Path $root 'apps\web\public\images'
foreach ($dir in @(
(Join-Path $webImages 'monsters\runtime'),
(Join-Path $webImages 'combat\sprites'),
(Join-Path $webImages 'combat\icons'),
(Join-Path $webImages 'backgrounds\runtime'))) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
function Save-Scaled {
param(
[string] $SourcePath,
[string] $TargetPath,
[int] $TargetWidth,
[string] $Format,
[switch] $Opaque
)
$img = [System.Drawing.Image]::FromFile($SourcePath)
try {
$height = [int][Math]::Round($img.Height * $TargetWidth / $img.Width)
$bitmap = New-Object System.Drawing.Bitmap $TargetWidth, $height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
if ($Opaque) {
$graphics.Clear([System.Drawing.Color]::Black)
}
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.DrawImage($img, 0, 0, $TargetWidth, $height)
$graphics.Dispose()
if ($Format -eq 'jpg') {
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
Where-Object { $_.MimeType -eq 'image/jpeg' }
$params = New-Object System.Drawing.Imaging.EncoderParameters 1
$params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter(
[System.Drawing.Imaging.Encoder]::Quality, 82)
$bitmap.Save($TargetPath, $codec, $params)
}
else {
$bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
$bitmap.Dispose()
}
finally {
$img.Dispose()
}
Write-Host "wrote $(Split-Path -Leaf $TargetPath)"
}
function Save-Icon {
param(
[string] $SourcePath,
[string] $TargetPath,
# Crop window as fractions of the source, chosen per monster so the icon
# lands on the head rather than on whatever the centre happens to be.
[double] $CropX, [double] $CropY, [double] $CropSize
)
$img = [System.Drawing.Image]::FromFile($SourcePath)
try {
$side = [int]([Math]::Min($img.Width, $img.Height) * $CropSize)
$x = [int]($img.Width * $CropX)
$y = [int]($img.Height * $CropY)
$bitmap = New-Object System.Drawing.Bitmap 128, 128
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.DrawImage(
$img,
(New-Object System.Drawing.Rectangle 0, 0, 128, 128),
(New-Object System.Drawing.Rectangle $x, $y, $side, $side),
[System.Drawing.GraphicsUnit]::Pixel)
$graphics.Dispose()
$bitmap.Save($TargetPath, [System.Drawing.Imaging.ImageFormat]::Png)
$bitmap.Dispose()
}
finally {
$img.Dispose()
}
Write-Host "wrote $(Split-Path -Leaf $TargetPath)"
}
# `source` is the painted file, `cutout` the background-free one.
#
# NOTE the deliberate crossing on the first two rows: the file named
# raider-scout depicts the heavier, plated, spear-carrying figure and is the
# Veteran; raider-veteran depicts the leaner one and is the Scout. Slice 0.10
# design decision D7.
$monsters = @(
@{ key = 'raider-scout'; source = 'raider-veteran'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 },
@{ key = 'raider-veteran'; source = 'raider-scout'; spriteHeight = 620; cropX = 0.40; cropY = 0.04; cropSize = 0.30 },
@{ key = 'burned-hound'; source = 'burned-hound'; spriteHeight = 760; cropX = 0.06; cropY = 0.12; cropSize = 0.34 },
@{ key = 'raider-captain'; source = 'Pluendererhauptmann'; spriteHeight = 620; cropX = 0.36; cropY = 0.02; cropSize = 0.28 }
)
foreach ($monster in $monsters) {
$key = $monster.key
$painted = Join-Path $artEnemies "$($monster.source).png"
$cutout = Join-Path $artCutouts "$($monster.source).png"
Copy-Item -Force $painted (Join-Path $webImages "monsters\$key.png")
Write-Host "wrote $key.png"
Save-Scaled -SourcePath $painted `
-TargetPath (Join-Path $webImages "monsters\runtime\$key-560.jpg") `
-TargetWidth 560 -Format 'jpg' -Opaque
Save-Scaled -SourcePath $cutout `
-TargetPath (Join-Path $webImages "combat\sprites\$key-$($monster.spriteHeight).png") `
-TargetWidth $monster.spriteHeight -Format 'png'
# 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
}