Merge branch 'slice/0.10-abandoned-watchpost'
3
.gitignore
vendored
@@ -14,4 +14,5 @@ coverage/
|
||||
|
||||
.DS_Store
|
||||
|
||||
.worktrees/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
19
apps/api/src/world/discovery/world-discovery.module.ts
Normal 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 {}
|
||||
153
apps/api/src/world/discovery/world-discovery.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
108
apps/api/src/world/discovery/world-discovery.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
BIN
apps/web/public/images/backgrounds/Aschengrube.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/Wachturm.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/backgrounds/runtime/Aschengrube-960.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/web/public/images/backgrounds/runtime/Wachturm-960.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/web/public/images/combat/icons/burned-hound-128.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/web/public/images/combat/icons/raider-captain-128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/images/combat/icons/raider-scout-128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/combat/icons/raider-veteran-128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/images/combat/sprites/burned-hound-760.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
apps/web/public/images/combat/sprites/raider-captain-620.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
apps/web/public/images/combat/sprites/raider-scout-620.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
apps/web/public/images/combat/sprites/raider-veteran-620.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
apps/web/public/images/monsters/burned-hound.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/images/monsters/raider-captain.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/web/public/images/monsters/raider-scout.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/monsters/raider-veteran.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
apps/web/public/images/monsters/runtime/burned-hound-560.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-captain-560.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-scout-560.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/public/images/monsters/runtime/raider-veteran-560.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,8 @@ const runningCombat: Combat = {
|
||||
currentHp: 30,
|
||||
artworkPath: '/images/enemies/RoadBandit.png',
|
||||
pendingIntent: null,
|
||||
guardRemainingRounds: null,
|
||||
enraged: false,
|
||||
},
|
||||
events: [],
|
||||
rewards: null,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -26,6 +26,8 @@ const startedCombat: Combat = {
|
||||
currentHp: 45,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
pendingIntent: null,
|
||||
guardRemainingRounds: null,
|
||||
enraged: false,
|
||||
},
|
||||
events: [],
|
||||
rewards: null,
|
||||
|
||||
@@ -88,6 +88,8 @@ const startedCombat: Combat = {
|
||||
currentHp: 75,
|
||||
artworkPath: '/images/enemies/RoadBandit.png',
|
||||
pendingIntent: null,
|
||||
guardRemainingRounds: null,
|
||||
enraged: false,
|
||||
},
|
||||
events: [],
|
||||
rewards: null,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
3261
docs/superpowers/plans/2026-08-23-slice-0.10-abandoned-watchpost.md
Normal 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 | 2–3 |
|
||||
| dangerLevel | 2 |
|
||||
| isSafe / huntingEnabled | false / true |
|
||||
| artwork | `/images/backgrounds/Wachturm.png` |
|
||||
|
||||
**Ash Pit stub** (`ash-pit`)
|
||||
|
||||
Seeded so the discovered route has a real target and §11 is testable.
|
||||
`locationType: 'TRANSITION'`, `huntingEnabled: false`, `isSafe: false`,
|
||||
`dangerLevel: 3`, artwork `/images/backgrounds/Aschengrube.png`, empty POI and
|
||||
primary-action sets apart from a `MAP` entry, empty reward preview. Slice 0.11
|
||||
fills it in.
|
||||
|
||||
### 4.2 Connections
|
||||
|
||||
| From | To | Duration | Ambush | requiresDiscovery |
|
||||
|---|---|---|---|---|
|
||||
| Burned Road | Abandoned Watchpost | 15 s | 0.1000 | false |
|
||||
| Abandoned Watchpost | Burned Road | 15 s | 0.1000 | false |
|
||||
| Abandoned Watchpost | Ash Pit | 20 s | 0.1500 | **true** |
|
||||
| Ash Pit | Abandoned Watchpost | 20 s | 0.1500 | false |
|
||||
|
||||
No level requirement anywhere (§2, §9). The return leg from the Ash Pit is
|
||||
ungated — a player who got there must always be able to leave.
|
||||
|
||||
### 4.3 Local view
|
||||
|
||||
Four points of interest on `Wachturm.png`:
|
||||
|
||||
| Key | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `hunt-area` | HUNT | opens the hunt |
|
||||
| `inspect-watchpost` | INVESTIGATE | carries `discoversLocationKey: 'ash-pit'` and the §8 clue text |
|
||||
| `search-guard-quarters` | SEARCH | flavour only, no reward |
|
||||
| `east-road` | MAP | back to the map |
|
||||
|
||||
The clue text is quoted from §8:
|
||||
|
||||
> "The raiders weren't using the watchpost as shelter. They were using it to
|
||||
> watch the road. Fresh tracks lead east, toward the old ash excavation."
|
||||
|
||||
Primary actions mirror the Burned Road's four-entry bar: Begin Hunt, Inspect the
|
||||
watchpost (`poiKey: inspect-watchpost`), Search the quarters, To Map.
|
||||
|
||||
Reward preview: Equipment, Trade Goods. No Silver, no experience — normal kills
|
||||
grant neither (§6).
|
||||
|
||||
---
|
||||
|
||||
## 5. Encounter pool
|
||||
|
||||
Only these five entries are attached to the Watchpost, so the pool is its own
|
||||
(§11).
|
||||
|
||||
| Monster | Key | Cat. | Lvl | HP | Atk | Armor | Weight | Type | Abilities |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Raider Scout | `raider-scout` | HUMANOID | 2 | 70 | 10 | 3 | 35 | NORMAL | — |
|
||||
| Burned Hound | `burned-hound` | BEAST | 3 | 80 | 12 | 2 | 28 | NORMAL | `bleed`, `enrage` |
|
||||
| Road Bandit | `road-bandit` | HUMANOID | 2 | 75 | 9 | 5 | 20 | NORMAL | `telegraph` |
|
||||
| Raider Veteran | `raider-veteran` | HUMANOID | 3 | 120 | 14 | 10 | 14 | NORMAL | `telegraph`, `guard` |
|
||||
| Raider Captain | `raider-captain` | HUMANOID | 4 | 160 | 17 | 12 | 3 | RARE | `telegraph`, `guard` |
|
||||
|
||||
The Road Bandit is reused deliberately: §4 names it, it bridges the two
|
||||
locations, and it keeps the Raider Insignia economy connected. Its row above
|
||||
restates the values it already has — this slice adds a pool entry for it and
|
||||
changes nothing about the monster.
|
||||
|
||||
Ability configuration:
|
||||
|
||||
```text
|
||||
burned-hound bleed { roundInterval: 2, damagePerRound: 6, durationRounds: 2 }
|
||||
enrage { hpThresholdPercent: 35, damageMultiplier: 1.4 }
|
||||
raider-veteran telegraph { roundInterval: 3, damageMultiplier: 1.6 }
|
||||
guard { roundInterval: 4, armorBonus: 10, durationRounds: 2 }
|
||||
raider-captain telegraph { roundInterval: 2, damageMultiplier: 1.7 }
|
||||
guard { roundInterval: 3, armorBonus: 12, durationRounds: 2 }
|
||||
```
|
||||
|
||||
The Veteran's intervals (3 and 4) are chosen so the two abilities collide only
|
||||
every twelfth round; when they do, the telegraph wins by fixed priority.
|
||||
|
||||
---
|
||||
|
||||
## 6. Loot and economy
|
||||
|
||||
### 6.1 New trade goods
|
||||
|
||||
| Key | Name | Category | Type | Silver | Region rep |
|
||||
|---|---|---|---|---|---|
|
||||
| `scorched-hide` | Scorched Hide | HIDE | TRADE_GOOD | 12 | 4 |
|
||||
| `raider-warband-mark` | Raider Warband Mark | RAIDER_TROPHY | TROPHY | 20 | 7 |
|
||||
|
||||
Both get an `ExchangeRule` on Borin's profile (`sortOrder` 5 and 6, faction
|
||||
`border-guard`, milestone `FIRST_TRADE_MILESTONE_KEY`, no conditions). Prices sit
|
||||
above the Burned Road tier (Ashen Pelt 5, Tough Hide 8, Raider Insignia 14,
|
||||
Charred Raider Insignia 30) so the longer trip pays, without passing the rare
|
||||
Charred Raider Insignia.
|
||||
|
||||
Both carrying systems matter here (§6, §12): the Burned Hound feeds HIDE, the
|
||||
three raiders feed RAIDER_TROPHY. A player without a Trophy Pouch hits the
|
||||
bagless capacity of 1 and is pushed back to Borin.
|
||||
|
||||
### 6.2 Loot tables
|
||||
|
||||
One table per monster (the established pattern). Every table has a guaranteed
|
||||
trade good and independent equipment rolls.
|
||||
|
||||
| Table | Entries |
|
||||
|---|---|
|
||||
| `raider-scout-loot` | `raider-warband-mark` 0.6000, `bandit-blade` 0.2500, `bandit-hood` 0.1800 |
|
||||
| `burned-hound-loot` | `scorched-hide` 0.6000, `ash-boots` 0.1500 |
|
||||
| `raider-veteran-loot` | `raider-warband-mark` 0.7000, `raider-gloves` 0.1500, `reinforced-leather-jacket` 0.2000, `guardsman-legs` 0.2200 |
|
||||
| `raider-captain-loot` | `raider-warband-mark` 1.0000, `reinforced-leather-jacket` 0.3000, `guardsman-legs` 0.3000, `borderwatch-sigil` 0.2000 |
|
||||
|
||||
The Road Bandit keeps its existing table unchanged — retuning it would change
|
||||
Burned Road balance, which this slice was not asked to touch (AGENTS §39).
|
||||
|
||||
All five §7 items are already seeded; this slice raises their availability
|
||||
rather than adding equipment. `borderwatch-sigil` is the Captain's focused
|
||||
desirable drop (§5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Combat engine
|
||||
|
||||
### 7.1 New content abilities
|
||||
|
||||
```ts
|
||||
export interface MonsterGuardAbility {
|
||||
roundInterval: number;
|
||||
armorBonus: number;
|
||||
durationRounds: number;
|
||||
}
|
||||
|
||||
export interface MonsterEnrageAbility {
|
||||
hpThresholdPercent: number;
|
||||
damageMultiplier: number;
|
||||
}
|
||||
```
|
||||
|
||||
Both hang off `MonsterAbilities`. The engine keeps branching on configuration,
|
||||
never on a monster key (AGENTS §9).
|
||||
|
||||
### 7.2 Guard
|
||||
|
||||
State lives on the monster's stats, beside `pendingAction`:
|
||||
|
||||
```ts
|
||||
activeGuard?: { remainingRounds: number; armorBonus: number };
|
||||
```
|
||||
|
||||
- When `guard` triggers, the monster raises its guard **instead of attacking**
|
||||
and emits `GUARD_RAISED`.
|
||||
- While active, `armorBonus` is added to the monster's armor in every
|
||||
`calculateDamage` call against it.
|
||||
- The counter decrements at the end of each round; at zero the field is cleared
|
||||
and `GUARD_ENDED` is emitted.
|
||||
- `SHIELD_BASH` clears it exactly as it clears a telegraph, emitting `INTERRUPT`
|
||||
and `GUARD_ENDED`. This is the same lesson the player already learned.
|
||||
|
||||
Priority in `resolveMonsterTurn`, top to bottom:
|
||||
|
||||
1. resolve a pending Heavy Strike
|
||||
2. raise a telegraph
|
||||
3. raise a guard
|
||||
4. normal attack (plus `bleed` if due)
|
||||
|
||||
### 7.3 Enrage
|
||||
|
||||
When the monster's HP first falls to or below `hpThresholdPercent` of its
|
||||
maximum, `enraged: true` is set on its stats and `ENRAGED` is emitted once. From
|
||||
then on `damageMultiplier` applies to every strike it makes, including a
|
||||
telegraphed one. It never expires and never re-triggers — one deterministic
|
||||
state change, no hidden roll.
|
||||
|
||||
### 7.4 Contract changes
|
||||
|
||||
- `CombatEventType` gains `GUARD_RAISED`, `GUARD_ENDED`, `ENRAGED`; a migration
|
||||
appends them to `combat_event_type_enum` following migration 1790's pattern.
|
||||
- `CombatMonsterDto` gains `guardRemainingRounds: number | null` and
|
||||
`enraged: boolean`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
Only the combat screen changes.
|
||||
|
||||
- A guard badge next to the existing telegraph indicator, showing the remaining
|
||||
rounds, and an enrage badge on the monster.
|
||||
- Combat-log lines for the three new event types.
|
||||
- `monster-artwork.ts` gains cutout, icon, runtime and sprite-scale entries for
|
||||
the four new keys.
|
||||
- The interaction panel shows the discovered-route line when
|
||||
`discoveredLocation` is set.
|
||||
|
||||
The map, travel panel and local view need no change — they already render
|
||||
whatever `connections` and `pointsOfInterest` the API returns.
|
||||
|
||||
---
|
||||
|
||||
## 9. Artwork
|
||||
|
||||
Sources committed in `2435d25`:
|
||||
|
||||
| Web key | Full art | Cutout | Icon |
|
||||
|---|---|---|---|
|
||||
| `raider-scout` | `art/enemies/raider-veteran.png` | transparent variant | cropped from cutout |
|
||||
| `raider-veteran` | `art/enemies/raider-scout.png` | transparent variant | cropped from cutout |
|
||||
| `burned-hound` | `art/enemies/burned-hound.png` | transparent variant | cropped from cutout |
|
||||
| `raider-captain` | `art/enemies/Pluendererhauptmann.png` | transparent variant | `art/enemies/PluendererhauptmannIcon.png` |
|
||||
|
||||
The first two rows are crossed on purpose (D7).
|
||||
|
||||
Backgrounds: `art/backgrounds/Wachturm.png` and `art/backgrounds/Aschengrube.png`
|
||||
copy to `apps/web/public/images/backgrounds/`, with 960px runtime JPEGs beside
|
||||
them.
|
||||
|
||||
New script `tools/derive-monster-assets.ps1`, written in the same style as
|
||||
`tools/extract-item-icons.ps1` (System.Drawing, re-runnable, overwrites only its
|
||||
own output). Per monster it produces:
|
||||
|
||||
```text
|
||||
apps/web/public/images/monsters/<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.
|
||||
154
tools/derive-monster-assets.ps1
Normal 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
|
||||
}
|
||||