This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -1,15 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module'; import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module'; import { CombatModule } from './combat/combat.module';
import { ConditionsModule } from './conditions/conditions.module';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { EquipmentModule } from './equipment/equipment.module'; import { EquipmentModule } from './equipment/equipment.module';
import { ExchangesModule } from './exchanges/exchanges.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module'; import { HuntingModule } from './hunting/hunting.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
import { LootBagsModule } from './loot-bags/loot-bags.module';
import { NpcsModule } from './npcs/npcs.module';
import { RenownModule } from './renown/renown.module'; import { RenownModule } from './renown/renown.module';
import { ReputationModule } from './reputation/reputation.module'; import { ReputationModule } from './reputation/reputation.module';
import { ShopsModule } from './shops/shops.module';
import { TravelModule } from './travel/travel.module'; import { TravelModule } from './travel/travel.module';
import { TurnInModule } from './turn-in/turn-in.module';
import { WorldModule } from './world/world.module'; import { WorldModule } from './world/world.module';
@Module({ @Module({
@@ -23,9 +27,13 @@ import { WorldModule } from './world/world.module';
CombatModule, CombatModule,
EquipmentModule, EquipmentModule,
InventoryModule, InventoryModule,
LootBagsModule,
RenownModule, RenownModule,
ReputationModule, ReputationModule,
TurnInModule, ConditionsModule,
NpcsModule,
ShopsModule,
ExchangesModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -159,7 +159,10 @@ describe('CharacterStatsService', () => {
const scope = fakeScope([]); const scope = fakeScope([]);
const anchor = new Date('2026-08-21T11:59:30.000Z'); const anchor = new Date('2026-08-21T11:59:30.000Z');
const stats = await service.calculate(character({ hpRegenSince: anchor }), scope); const stats = await service.calculate(
character({ hpRegenSince: anchor }),
scope,
);
expect(stats.hpRegenPerSecond).toBe(1); expect(stats.hpRegenPerSecond).toBe(1);
expect(stats.hpRegenSince).toEqual(anchor); expect(stats.hpRegenSince).toEqual(anchor);

View File

@@ -30,7 +30,10 @@ describe('CharacterVitalsService', () => {
const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); const { clock } = fakeClock('2026-08-21T12:00:00.000Z');
const service = new CharacterVitalsService(clock); const service = new CharacterVitalsService(clock);
const hp = service.effectiveHp(character({ currentHp: 37, hpRegenSince: null }), 100); const hp = service.effectiveHp(
character({ currentHp: 37, hpRegenSince: null }),
100,
);
expect(hp).toBe(37); expect(hp).toBe(37);
}); });

View File

@@ -34,7 +34,9 @@ export class CharactersService {
maxHp: stats.maxHp, maxHp: stats.maxHp,
attack: stats.attack, attack: stats.attack,
hpRegenPerSecond: stats.hpRegenPerSecond, hpRegenPerSecond: stats.hpRegenPerSecond,
hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null, hpRegenSince: stats.hpRegenSince
? stats.hpRegenSince.toISOString()
: null,
currentLocation: { currentLocation: {
id: character.currentLocation.id, id: character.currentLocation.id,
key: character.currentLocation.key, key: character.currentLocation.key,

View File

@@ -3,10 +3,15 @@ import {
CombatEngineService, CombatEngineService,
UnsupportedCombatActionError, UnsupportedCombatActionError,
} from './combat-engine.service'; } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types'; import {
CombatEngineCombatant,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum'; import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum'; import { Combatant } from './combatant.enum';
import { StatusEffectType } from './status-effect.enum';
import type { MonsterAbilities } from '../monsters/monster-abilities';
function baseState( function baseState(
overrides: Partial<CombatEngineState> = {}, overrides: Partial<CombatEngineState> = {},
@@ -19,15 +24,44 @@ function baseState(
maxHp: 100, maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 }, stats: { attack: 6, weaponDamage: 8, armor: 6 },
}, },
monster: { // The Ash Rat baseline: no telegraph, no status effect (spec §4).
currentHp: 45, monster: monster(),
maxHp: 45,
stats: { attack: 5, armor: 0 },
},
...overrides, ...overrides,
}; };
} }
function monster(
overrides: Partial<CombatEngineCombatant> = {},
): CombatEngineCombatant {
return {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
...overrides,
};
}
/**
* A monster whose content grants it an ability. Every special behaviour is
* configuration now (spec §4, §8), so a test that wants a telegraph or a
* bleed has to say so -- there is no rule every enemy shares any more.
*/
function withAbilities(
abilities: MonsterAbilities,
overrides: Partial<CombatEngineCombatant> = {},
): CombatEngineCombatant {
const base = monster(overrides);
return { ...base, stats: { ...base.stats, abilities } };
}
const ROAD_BANDIT_ABILITIES: MonsterAbilities = {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
};
const ROAD_HOUND_ABILITIES: MonsterAbilities = {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
};
describe('CombatEngineService', () => { describe('CombatEngineService', () => {
let engine: CombatEngineService; let engine: CombatEngineService;
@@ -175,11 +209,9 @@ describe('CombatEngineService', () => {
it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => { it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => {
const state = baseState({ const state = baseState({
monster: { monster: withAbilities(ROAD_BANDIT_ABILITIES, {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}, }),
}); });
const result = engine.resolveAction(state, { const result = engine.resolveAction(state, {
@@ -277,7 +309,10 @@ describe('CombatEngineService', () => {
}); });
it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => { it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => {
const round3State = baseState({ round: 3 }); const round3State = baseState({
round: 3,
monster: withAbilities(ROAD_BANDIT_ABILITIES),
});
const telegraphResult = engine.resolveAction(round3State, { const telegraphResult = engine.resolveAction(round3State, {
action: CombatAction.ATTACK, action: CombatAction.ATTACK,
@@ -311,11 +346,10 @@ describe('CombatEngineService', () => {
it('does not resolve a pending Heavy Attack when the monster is killed this round', () => { it('does not resolve a pending Heavy Attack when the monster is killed this round', () => {
const state = baseState({ const state = baseState({
monster: { monster: withAbilities(ROAD_BANDIT_ABILITIES, {
currentHp: 10, currentHp: 10,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}, }),
}); });
const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
@@ -353,11 +387,9 @@ describe('CombatEngineService', () => {
it('DEFEND mitigates a resolving Heavy Attack by half', () => { it('DEFEND mitigates a resolving Heavy Attack by half', () => {
const state = baseState({ const state = baseState({
monster: { monster: withAbilities(ROAD_BANDIT_ABILITIES, {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}, }),
}); });
const result = engine.resolveAction(state, { action: CombatAction.DEFEND }); const result = engine.resolveAction(state, { action: CombatAction.DEFEND });
@@ -379,4 +411,318 @@ describe('CombatEngineService', () => {
expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 4); expect(result.state.player.currentHp).toBe(100 - 4);
}); });
describe('content-driven monster abilities (spec §4, §8)', () => {
it('never telegraphs for a monster whose content grants it no telegraph', () => {
// The Ash Rat is "pure baseline combat": round 3 must be an ordinary
// exchange for it, even though it is the Road Bandit's wind-up round.
const result = engine.resolveAction(baseState({ round: 3 }), {
action: CombatAction.ATTACK,
});
expect(
result.events.some((event) => event.type === CombatEventType.TELEGRAPH),
).toBe(false);
expect(result.state.monster.stats.pendingAction).toBeUndefined();
expect(result.state.player.currentHp).toBe(100 - 5);
});
it('telegraphs on the cadence its own content sets, not a shared one', () => {
// The Charred Raider winds up every second round rather than every
// third, which is the whole of its "noticeably stronger" mechanic.
const raider = withAbilities({
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
});
const round2 = engine.resolveAction(
baseState({ round: 2, monster: raider }),
{ action: CombatAction.ATTACK },
);
const round3 = engine.resolveAction(
baseState({ round: 3, monster: raider }),
{ action: CombatAction.ATTACK },
);
expect(round2.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK');
expect(round3.state.monster.stats.pendingAction).toBeUndefined();
});
it('uses the damage multiplier from the monster content when the Heavy Attack lands', () => {
const state = baseState({
monster: withAbilities(
{ telegraph: { roundInterval: 3, damageMultiplier: 3 } },
{ stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' } },
),
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
// raw = 5; mitigated = 4.545...; * 3 = 13.6 -> rounds to 14
expect(result.events[1]).toMatchObject({
type: CombatEventType.DAMAGE,
amount: 14,
});
});
});
describe('Bleeding (spec §4)', () => {
it('applies Bleeding on top of a normal bite, then ticks it the same round', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.events.slice(1)).toEqual([
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_APPLIED,
amount: 2,
statusEffect: StatusEffectType.BLEED,
},
{
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_DAMAGE,
amount: 5,
statusEffect: StatusEffectType.BLEED,
},
]);
// The bite (5) and the first bleed tick (5) both land this round.
expect(result.state.player.currentHp).toBe(100 - 10);
expect(result.state.player.stats.statusEffects).toEqual([
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
]);
});
it('keeps ticking on later rounds and expires once its duration runs out', () => {
const state = baseState({
round: 4,
monster: withAbilities(ROAD_HOUND_ABILITIES),
player: {
currentHp: 90,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.events.map((event) => event.type)).toEqual([
CombatEventType.DAMAGE,
CombatEventType.DAMAGE,
CombatEventType.STATUS_DAMAGE,
CombatEventType.STATUS_EXPIRED,
]);
// Monster bite (5) plus the last bleed tick (5).
expect(result.state.player.currentHp).toBe(90 - 10);
expect(result.state.player.stats.statusEffects).toEqual([]);
});
it('ignores armor, because a bleed is an open wound and not a blow', () => {
const armoured = baseState({
round: 4,
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 999,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(armoured, {
action: CombatAction.ATTACK,
});
expect(
result.events.find(
(event) => event.type === CombatEventType.STATUS_DAMAGE,
),
).toMatchObject({ amount: 5 });
});
it('still ticks in a round where SHIELD_BASH interrupted the monster', () => {
// Bleeding sits on the player: interrupting its source stops the next
// blow, not the wound already open.
const state = baseState({
monster: withAbilities(ROAD_BANDIT_ABILITIES, {
stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' },
}),
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.SHIELD_BASH,
});
expect(result.events.map((event) => event.type)).toEqual([
CombatEventType.DAMAGE,
CombatEventType.INTERRUPT,
CombatEventType.STATUS_DAMAGE,
]);
expect(result.state.player.currentHp).toBe(95);
});
it('refreshes an existing Bleeding rather than stacking a second one', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
player: {
currentHp: 100,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 1,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.player.stats.statusEffects).toHaveLength(1);
// Re-applied to 2 rounds, then aged by this round's own tick.
expect(result.state.player.stats.statusEffects?.[0].remainingRounds).toBe(
1,
);
});
it('ends the combat as LOST when a bleed tick empties the player', () => {
const state = baseState({
round: 4,
player: {
currentHp: 4,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 999,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.DEFEND,
});
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events.at(-1)).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
});
it('does not tick a bleed once the killing blow has already won the fight', () => {
const state = baseState({
monster: monster({ currentHp: 10 }),
player: {
currentHp: 3,
maxHp: 100,
stats: {
attack: 6,
weaponDamage: 8,
armor: 6,
statusEffects: [
{
type: StatusEffectType.BLEED,
remainingRounds: 2,
damagePerRound: 5,
},
],
},
},
});
const result = engine.resolveAction(state, {
action: CombatAction.ATTACK,
});
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.player.currentHp).toBe(3);
});
it('does not mutate the state it was given', () => {
const state = baseState({
round: 3,
monster: withAbilities(ROAD_HOUND_ABILITIES),
});
const snapshot = structuredClone(state);
engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(state).toEqual(snapshot);
});
});
}); });

View File

@@ -3,6 +3,7 @@ import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage'; import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum'; import { Combatant } from './combatant.enum';
import { import {
ActiveStatusEffect,
CombatActionInput, CombatActionInput,
CombatEngineCombatant, CombatEngineCombatant,
CombatEngineEvent, CombatEngineEvent,
@@ -11,6 +12,11 @@ import {
} from './combat-engine.types'; } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum'; import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { StatusEffectType } from './status-effect.enum';
import type {
MonsterBleedAbility,
MonsterTelegraphAbility,
} from '../monsters/monster-abilities';
export class UnsupportedCombatActionError extends Error { export class UnsupportedCombatActionError extends Error {
constructor(action: string) { constructor(action: string) {
@@ -18,11 +24,10 @@ export class UnsupportedCombatActionError extends Error {
} }
} }
// The monster telegraphs a Heavy Attack instead of striking every third // Player-side action modifiers. These are character abilities rather than
// round it acts, then resolves it the round after unless SHIELD_BASH // monster content, so they stay constants here (Playable Slice 0.6 spec
// interrupts it. A fixed cadence (not RNG) keeps combat deterministic // §10/§11). What the *monster* does now comes from its own definition
// (Playable Slice 0.6 spec §10/§11). // (Playable Slice 0.7 V2 spec §4, §8) instead of a rule every enemy shares.
const TELEGRAPH_ROUND_INTERVAL = 3;
const HEAVY_ATTACK_MULTIPLIER = 1.6; const HEAVY_ATTACK_MULTIPLIER = 1.6;
const SHIELD_BASH_MULTIPLIER = 0.7; const SHIELD_BASH_MULTIPLIER = 0.7;
const DEFEND_MITIGATION_MULTIPLIER = 0.5; const DEFEND_MITIGATION_MULTIPLIER = 0.5;
@@ -141,6 +146,14 @@ export class CombatEngineService {
return this.finishRound(state, player, monster, events, false); return this.finishRound(state, player, monster, events, false);
} }
/**
* Closes the round: monster reply, then ongoing effects, then outcome.
*
* Status effects tick after the monster has acted and regardless of whether
* it acted at all -- Bleeding sits on the player and does not care that
* SHIELD_BASH interrupted the enemy that caused it. An effect applied this
* round therefore also deals its first tick this round.
*/
private finishRound( private finishRound(
state: CombatEngineState, state: CombatEngineState,
player: CombatEngineCombatant, player: CombatEngineCombatant,
@@ -172,6 +185,11 @@ export class CombatEngineService {
if (!interrupted) { if (!interrupted) {
this.resolveMonsterTurn(state.round, monster, player, defended, events); this.resolveMonsterTurn(state.round, monster, player, defended, events);
}
if (player.currentHp > 0) {
this.tickStatusEffects(player, events);
}
if (player.currentHp <= 0) { if (player.currentHp <= 0) {
events.push({ events.push({
@@ -184,7 +202,6 @@ export class CombatEngineService {
events, events,
}; };
} }
}
return { return {
state: { state: {
@@ -206,25 +223,21 @@ export class CombatEngineService {
events: CombatEngineEvent[], events: CombatEngineEvent[],
): void { ): void {
const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1; const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1;
const abilities = monster.stats.abilities ?? {};
if (monster.stats.pendingAction === 'HEAVY_ATTACK') { if (monster.stats.pendingAction === 'HEAVY_ATTACK') {
monster.stats.pendingAction = undefined; monster.stats.pendingAction = undefined;
const damage = calculateDamage( this.strikePlayer(
monster.stats, monster,
player.stats.armor, player,
HEAVY_ATTACK_MULTIPLIER * defendMultiplier, (abilities.telegraph?.damageMultiplier ?? HEAVY_ATTACK_MULTIPLIER) *
defendMultiplier,
events,
); );
player.currentHp = Math.max(0, player.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: damage,
});
return; return;
} }
if (round % TELEGRAPH_ROUND_INTERVAL === 0) { if (this.shouldTrigger(abilities.telegraph, round)) {
monster.stats.pendingAction = 'HEAVY_ATTACK'; monster.stats.pendingAction = 'HEAVY_ATTACK';
events.push({ events.push({
source: Combatant.MONSTER, source: Combatant.MONSTER,
@@ -234,10 +247,26 @@ export class CombatEngineService {
return; return;
} }
this.strikePlayer(monster, player, defendMultiplier, events);
// The bite lands first and then tears: Bleeding is applied on top of a
// normal attack, not instead of one (spec §4).
const bleed = abilities.bleed;
if (bleed && this.shouldTrigger(bleed, round)) {
this.applyBleed(player, bleed, events);
}
}
private strikePlayer(
monster: CombatEngineCombatant,
player: CombatEngineCombatant,
multiplier: number,
events: CombatEngineEvent[],
): void {
const damage = calculateDamage( const damage = calculateDamage(
monster.stats, monster.stats,
player.stats.armor, player.stats.armor,
defendMultiplier, multiplier,
); );
player.currentHp = Math.max(0, player.currentHp - damage); player.currentHp = Math.max(0, player.currentHp - damage);
events.push({ events.push({
@@ -248,9 +277,106 @@ export class CombatEngineService {
}); });
} }
/**
* 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,
round: number,
): boolean {
return (
ability !== undefined &&
ability.roundInterval > 0 &&
round % ability.roundInterval === 0
);
}
/** Re-applying refreshes the duration rather than stacking a second wound. */
private applyBleed(
player: CombatEngineCombatant,
bleed: MonsterBleedAbility,
events: CombatEngineEvent[],
): void {
const effects = player.stats.statusEffects ?? [];
const existing = effects.find(
(effect) => effect.type === StatusEffectType.BLEED,
);
if (existing) {
existing.remainingRounds = bleed.durationRounds;
existing.damagePerRound = bleed.damagePerRound;
} else {
effects.push({
type: StatusEffectType.BLEED,
remainingRounds: bleed.durationRounds,
damagePerRound: bleed.damagePerRound,
});
}
player.stats.statusEffects = effects;
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_APPLIED,
amount: bleed.durationRounds,
statusEffect: StatusEffectType.BLEED,
});
}
/**
* 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.
*/
private tickStatusEffects(
combatant: CombatEngineCombatant,
events: CombatEngineEvent[],
): void {
const effects = combatant.stats.statusEffects ?? [];
if (effects.length === 0) {
return;
}
const surviving: ActiveStatusEffect[] = [];
for (const effect of effects) {
const damage = Math.max(0, effect.damagePerRound);
combatant.currentHp = Math.max(0, combatant.currentHp - damage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_DAMAGE,
amount: damage,
statusEffect: effect.type,
});
const remainingRounds = effect.remainingRounds - 1;
if (remainingRounds > 0) {
surviving.push({ ...effect, remainingRounds });
} else {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.STATUS_EXPIRED,
statusEffect: effect.type,
});
}
}
combatant.stats.statusEffects = surviving;
}
private cloneCombatant( private cloneCombatant(
combatant: CombatEngineCombatant, combatant: CombatEngineCombatant,
): CombatEngineCombatant { ): CombatEngineCombatant {
return { ...combatant, stats: { ...combatant.stats } }; return {
...combatant,
stats: {
...combatant.stats,
statusEffects: combatant.stats.statusEffects?.map((effect) => ({
...effect,
})),
},
};
} }
} }

View File

@@ -2,12 +2,28 @@ import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum'; import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum'; import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { StatusEffectType } from './status-effect.enum';
import type { MonsterAbilities } from '../monsters/monster-abilities';
// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves // Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves
// immediately and is never held as a pending intent (Playable Slice 0.6 // immediately and is never held as a pending intent (Playable Slice 0.6
// spec §5). Add members here as future slices add more prepared actions. // spec §5). Add members here as future slices add more prepared actions.
export type CombatIntent = 'HEAVY_ATTACK'; export type CombatIntent = 'HEAVY_ATTACK';
/**
* An ongoing effect carried between rounds (Playable Slice 0.7 V2 spec §4).
*
* Duration and damage are copied from the applying monster's content at the
* moment it lands, so the effect keeps ticking on its own terms even if the
* definition is retuned mid-fight. Server-authoritative: the client only
* ever renders what this says.
*/
export interface ActiveStatusEffect {
type: StatusEffectType;
remainingRounds: number;
damagePerRound: number;
}
export interface CombatEngineCombatantStats { export interface CombatEngineCombatantStats {
attack: number; attack: number;
weaponDamage?: number; weaponDamage?: number;
@@ -18,6 +34,12 @@ export interface CombatEngineCombatantStats {
// Monster-only: set when it telegraphs, cleared when the action resolves // Monster-only: set when it telegraphs, cleared when the action resolves
// or is interrupted. Optional because the player's stats never carry it. // or is interrupted. Optional because the player's stats never carry it.
pendingAction?: CombatIntent; pendingAction?: CombatIntent;
// 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[];
} }
export interface CombatEngineCombatant { export interface CombatEngineCombatant {
@@ -42,6 +64,7 @@ export interface CombatEngineEvent {
target: Combatant; target: Combatant;
type: CombatEventType; type: CombatEventType;
amount?: number; amount?: number;
statusEffect?: StatusEffectType;
} }
export interface CombatEngineResult { export interface CombatEngineResult {

View File

@@ -281,9 +281,7 @@ function fakeTravelService(): TravelService {
function fakeRewardService(): CombatRewardService { function fakeRewardService(): CombatRewardService {
return { return {
grantVictoryRewards: jest grantVictoryRewards: jest.fn().mockResolvedValue({ silver: 0, items: [] }),
.fn()
.mockResolvedValue({ silver: 0, items: [] }),
loadRewards: jest.fn().mockResolvedValue(null), loadRewards: jest.fn().mockResolvedValue(null),
} as unknown as CombatRewardService; } as unknown as CombatRewardService;
} }

View File

@@ -4,6 +4,9 @@ export enum CombatEventType {
DEFEND = 'DEFEND', DEFEND = 'DEFEND',
TELEGRAPH = 'TELEGRAPH', TELEGRAPH = 'TELEGRAPH',
INTERRUPT = 'INTERRUPT', INTERRUPT = 'INTERRUPT',
STATUS_APPLIED = 'STATUS_APPLIED',
STATUS_DAMAGE = 'STATUS_DAMAGE',
STATUS_EXPIRED = 'STATUS_EXPIRED',
COMBAT_WON = 'COMBAT_WON', COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST', COMBAT_LOST = 'COMBAT_LOST',
} }

View File

@@ -198,8 +198,10 @@ function monster(
maxHp: 45, maxHp: 45,
attack: 5, attack: 5,
armor: 0, armor: 0,
silverMin: 4, flavorText: null,
silverMax: 7, // The Ash Rat has no special mechanic (spec §4). A test that wants a
// telegraph or a bleed has to seed the monster content that grants it.
abilities: {},
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
@@ -331,6 +333,7 @@ describe('CombatService', () => {
currentHp: 100, currentHp: 100,
potionsRemaining: 2, potionsRemaining: 2,
potionsMax: 2, potionsMax: 2,
statusEffects: [],
}); });
expect(combat.monster).toEqual({ expect(combat.monster).toEqual({
key: 'ash-rat', key: 'ash-rat',
@@ -366,7 +369,10 @@ describe('CombatService', () => {
it('pauses regeneration on the character once a combat starts', async () => { it('pauses regeneration on the character once a combat starts', async () => {
const state = createState({ const state = createState({
characters: [ characters: [
character({ currentHp: 63, hpRegenSince: new Date('2026-08-18T08:00:00.000Z') }), character({
currentHp: 63,
hpRegenSince: new Date('2026-08-18T08:00:00.000Z'),
}),
], ],
}); });
const { dataSource, service } = createService({ state }); const { dataSource, service } = createService({ state });
@@ -601,7 +607,9 @@ describe('CombatService', () => {
}); });
it('restarts regeneration from 0 HP once the fight is lost', async () => { it('restarts regeneration from 0 HP once the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); const state = createState({
characters: [character({ baseHp: 1, currentHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -663,7 +671,9 @@ describe('CombatService', () => {
}); });
it('frees the encounter for another attempt when the fight is lost', async () => { it('frees the encounter for another attempt when the fight is lost', async () => {
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); const state = createState({
characters: [character({ baseHp: 1, currentHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -684,7 +694,9 @@ describe('CombatService', () => {
}); });
it('lets a lost encounter be fought again once the character has recovered HP', async () => { it('lets a lost encounter be fought again once the character has recovered HP', async () => {
const state = createState({ characters: [character({ baseHp: 1, currentHp: 1 })] }); const state = createState({
characters: [character({ baseHp: 1, currentHp: 1 })],
});
const { dataSource, service, combatId } = await startedCombat(state); const { dataSource, service, combatId } = await startedCombat(state);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
@@ -777,7 +789,19 @@ describe('CombatService', () => {
}); });
it('persists the telegraphed Heavy Attack across a reload', async () => { it('persists the telegraphed Heavy Attack across a reload', async () => {
const { service, combatId } = await startedCombat(); // The telegraph is the Road Bandit's content, not a rule every monster
// shares (spec §4), so the fight has to be against one that has it.
const { service, combatId } = await startedCombat(
createState({
monsters: [
monster({
abilities: {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
},
}),
],
}),
);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const telegraphed = await service.performAction( const telegraphed = await service.performAction(

View File

@@ -14,7 +14,11 @@ import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum'; import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum'; import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service'; import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState, CombatIntent } from './combat-engine.types'; import {
ActiveStatusEffect,
CombatEngineState,
CombatIntent,
} from './combat-engine.types';
import { import {
characterNotFound, characterNotFound,
characterTooWounded, characterTooWounded,
@@ -30,18 +34,30 @@ import {
} from './combat.errors'; } from './combat.errors';
import { CombatStatus } from './combat-status.enum'; import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity'; import { CombatEvent } from './entities/combat-event.entity';
import { Combat, CombatPlayerState } from './entities/combat.entity'; import {
Combat,
CombatMonsterState,
CombatPlayerState,
} from './entities/combat.entity';
import { StatusEffectType } from './status-effect.enum';
// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the // Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the
// persistent consumable inventory. // persistent consumable inventory.
const STARTING_POTION_COUNT = 2; const STARTING_POTION_COUNT = 2;
export interface CombatStatusEffectDto {
type: StatusEffectType;
remainingRounds: number;
damagePerRound: number;
}
export interface CombatPlayerDto { export interface CombatPlayerDto {
name: string; name: string;
maxHp: number; maxHp: number;
currentHp: number; currentHp: number;
potionsRemaining: number; potionsRemaining: number;
potionsMax: number; potionsMax: number;
statusEffects: CombatStatusEffectDto[];
} }
export interface CombatMonsterDto { export interface CombatMonsterDto {
@@ -61,6 +77,7 @@ export interface CombatEventDto {
source: string; source: string;
target: string; target: string;
amount?: number; amount?: number;
statusEffect?: StatusEffectType;
} }
export interface CombatDto { export interface CombatDto {
@@ -163,7 +180,11 @@ export class CombatService {
armor: playerStats.armor, armor: playerStats.armor,
potionsRemaining: STARTING_POTION_COUNT, potionsRemaining: STARTING_POTION_COUNT,
}, },
monsterState: { attack: monster.attack, armor: monster.armor }, monsterState: {
attack: monster.attack,
armor: monster.armor,
abilities: monster.abilities ?? {},
},
completedAt: null, completedAt: null,
}); });
await combats.save(combat); await combats.save(combat);
@@ -223,11 +244,10 @@ export class CombatService {
const combatEvents = manager.getRepository(CombatEvent); const combatEvents = manager.getRepository(CombatEvent);
// Lock the character before the combat row here, matching the order // Lock the character before the combat row here, matching the order
// startCombat already uses (character, then combat). grantVictoryRewards // startCombat already uses (character, then combat). Keeping both code
// locks the character again later in this same transaction, which is a // paths on the same order avoids a lock-order inversion that could
// no-op re-lock — but locking it first here keeps both code paths // deadlock two concurrent requests against the same character. Do not
// consistent and avoids a lock-order inversion that could deadlock two // reorder this.
// concurrent requests against the same character. Do not reorder this.
const character = await this.lockCharacter(characters, characterId); const character = await this.lockCharacter(characters, characterId);
const combat = await combats.findOne({ const combat = await combats.findOne({
@@ -256,7 +276,7 @@ export class CombatService {
combat.playerCurrentHp = result.state.player.currentHp; combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp;
combat.playerState = result.state.player.stats as CombatPlayerState; combat.playerState = result.state.player.stats as CombatPlayerState;
combat.monsterState = result.state.monster.stats; combat.monsterState = result.state.monster.stats as CombatMonsterState;
if (combat.status !== CombatStatus.ACTIVE) { if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date(); combat.completedAt = new Date();
this.characterVitals.resume(character, combat.playerCurrentHp); this.characterVitals.resume(character, combat.playerCurrentHp);
@@ -284,6 +304,7 @@ export class CombatService {
source: event.source, source: event.source,
target: event.target, target: event.target,
amount: event.amount ?? null, amount: event.amount ?? null,
statusEffect: event.statusEffect ?? null,
}); });
await combatEvents.save(entity); await combatEvents.save(entity);
} }
@@ -308,7 +329,13 @@ export class CombatService {
this.loadEvents(combat.id, combatEvents), this.loadEvents(combat.id, combatEvents),
]); ]);
return this.toCombatDto(combat, reloadedCharacter.name, monster, events, rewards); return this.toCombatDto(
combat,
reloadedCharacter.name,
monster,
events,
rewards,
);
}); });
} }
@@ -420,6 +447,9 @@ export class CombatService {
currentHp: combat.playerCurrentHp, currentHp: combat.playerCurrentHp,
potionsRemaining: combat.playerState.potionsRemaining, potionsRemaining: combat.playerState.potionsRemaining,
potionsMax: STARTING_POTION_COUNT, potionsMax: STARTING_POTION_COUNT,
statusEffects: this.toStatusEffectDtos(
combat.playerState.statusEffects,
),
}, },
monster: { monster: {
key: monster.key, key: monster.key,
@@ -437,8 +467,19 @@ export class CombatService {
source: event.source, source: event.source,
target: event.target, target: event.target,
amount: event.amount ?? undefined, amount: event.amount ?? undefined,
statusEffect: event.statusEffect ?? undefined,
})), })),
rewards, rewards,
}; };
} }
private toStatusEffectDtos(
effects: ActiveStatusEffect[] | undefined,
): CombatStatusEffectDto[] {
return (effects ?? []).map((effect) => ({
type: effect.type,
remainingRounds: effect.remainingRounds,
damagePerRound: effect.damagePerRound,
}));
}
} }

View File

@@ -9,6 +9,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Combatant } from '../combatant.enum'; import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum'; import { CombatEventType } from '../combat-event-type.enum';
import { StatusEffectType } from '../status-effect.enum';
import { Combat } from './combat.entity'; import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' }) @Entity({ name: 'combat_events' })
@@ -55,6 +56,17 @@ export class CombatEvent {
@Column({ name: 'amount', type: 'integer', nullable: true }) @Column({ name: 'amount', type: 'integer', nullable: true })
amount!: number | null; amount!: number | null;
// Which ongoing effect a STATUS_* event is about. Null for every other
// event type (spec §4).
@Column({
name: 'status_effect',
type: 'enum',
enum: StatusEffectType,
enumName: 'status_effect_type_enum',
nullable: true,
})
statusEffect!: StatusEffectType | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@@ -11,13 +11,21 @@ import {
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatIntent } from '../combat-engine.types'; import { ActiveStatusEffect, CombatIntent } from '../combat-engine.types';
import { CombatStatus } from '../combat-status.enum'; import { CombatStatus } from '../combat-status.enum';
import type { MonsterAbilities } from '../../monsters/monster-abilities';
export interface CombatCombatantState { export interface CombatCombatantState {
attack: number; attack: number;
armor: number; armor: number;
pendingAction?: CombatIntent; pendingAction?: CombatIntent;
statusEffects?: ActiveStatusEffect[];
}
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;
} }
export interface CombatPlayerState extends CombatCombatantState { export interface CombatPlayerState extends CombatCombatantState {
@@ -69,7 +77,7 @@ export class Combat {
playerState!: CombatPlayerState; playerState!: CombatPlayerState;
@Column({ name: 'monster_state', type: 'jsonb' }) @Column({ name: 'monster_state', type: 'jsonb' })
monsterState!: CombatCombatantState; monsterState!: CombatMonsterState;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@@ -0,0 +1,9 @@
/**
* Ongoing effects a combatant can carry between rounds (spec §4).
*
* Only the Feral Road Hound's Bleeding exists in this slice; the enum is the
* extension point later status effects hang off.
*/
export enum StatusEffectType {
BLEED = 'BLEED',
}

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
/**
* The shared gate engine (NPC spec §21).
*
* `forFeature` is required even though the service resolves its repositories
* off the DataSource: the runtime config uses `autoLoadEntities`, which only
* registers entities a module actually declares.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
ItemDefinition,
CharacterNpcState,
CharacterReputation,
ReputationFaction,
]),
],
providers: [GameConditionService],
exports: [GameConditionService],
})
export class ConditionsModule {}

View File

@@ -0,0 +1,283 @@
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { GameConditionService } from './game-condition.service';
import { ComparisonOperator, GameConditionType } from './game-condition.types';
const CHARACTER_ID = 'character-1';
const NPC_ID = 'npc-1';
const FACTION_ID = 'faction-1';
interface Fixture {
renown?: number;
reputation?: number;
factionEnabled?: boolean;
flags?: Record<string, boolean | string | number> | null;
itemQuantity?: number;
}
function createService(fixture: Fixture = {}): GameConditionService {
const dataSource = {
getRepository: (entity: unknown) => {
if (entity === Character) {
return {
findOneBy: () =>
Promise.resolve({
id: CHARACTER_ID,
renown: fixture.renown ?? 1,
}),
};
}
if (entity === ReputationFaction) {
return {
findOneBy: (criteria: { key: string; enabled: boolean }) =>
Promise.resolve(
criteria.key === 'border-guard' &&
(fixture.factionEnabled ?? true) === criteria.enabled
? { id: FACTION_ID, key: 'border-guard' }
: null,
),
};
}
if (entity === CharacterReputation) {
return {
findOneBy: () =>
Promise.resolve(
fixture.reputation === undefined
? null
: { reputation: fixture.reputation },
),
};
}
if (entity === CharacterNpcState) {
return {
findOneBy: () =>
Promise.resolve(
fixture.flags === undefined || fixture.flags === null
? null
: { flags: fixture.flags },
),
};
}
if (entity === ItemDefinition) {
return {
findOneBy: (criteria: { key: string }) =>
Promise.resolve(
criteria.key === 'ash-pelt' ? { id: 'item-1' } : null,
),
};
}
if (entity === CharacterItem) {
return {
findOneBy: () =>
Promise.resolve(
fixture.itemQuantity === undefined
? null
: { quantity: fixture.itemQuantity },
),
};
}
throw new Error('Unexpected repository');
},
} as unknown as DataSource;
return new GameConditionService(dataSource);
}
describe('GameConditionService', () => {
it('treats an empty condition list as met, so ungated content stays open', async () => {
await expect(
createService().evaluate({ characterId: CHARACTER_ID }, []),
).resolves.toBe(true);
await expect(
createService().evaluate({ characterId: CHARACTER_ID }, null),
).resolves.toBe(true);
});
it('compares regional reputation against the required value (spec §20)', async () => {
const service = createService({ reputation: 30 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
]),
).resolves.toBe(true);
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 31,
},
]),
).resolves.toBe(false);
});
it('reads a faction the character never met as 0, not as a pass', async () => {
// No CharacterReputation row exists. The gate must still evaluate, and it
// must evaluate against zero.
const service = createService({ reputation: undefined });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 1,
},
]),
).resolves.toBe(false);
});
it('compares World Renown against the character rank', async () => {
const service = createService({ renown: 3 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
]),
).resolves.toBe(true);
});
it('reads a per-NPC flag, and only when an NPC is in context (spec §7)', async () => {
const service = createService({ flags: { met: true } });
await expect(
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
]),
).resolves.toBe(true);
// Without an NPC there is no flag store to read, so the gate stays shut
// rather than guessing.
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: true },
]),
).resolves.toBe(false);
});
it('treats an unset flag as false, which is what makes a greeting node work', async () => {
// No state row at all: the character has never spoken to this NPC.
const service = createService({ flags: null });
await expect(
service.evaluate({ characterId: CHARACTER_ID, npcId: NPC_ID }, [
{ type: GameConditionType.FLAG_SET, key: 'met', value: false },
]),
).resolves.toBe(true);
});
it('defaults HAS_ITEM to "at least one"', async () => {
await expect(
createService({ itemQuantity: 1 }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
),
).resolves.toBe(true);
await expect(
createService({ itemQuantity: undefined }).evaluate(
{ characterId: CHARACTER_ID },
[{ type: GameConditionType.HAS_ITEM, key: 'ash-pelt' }],
),
).resolves.toBe(false);
});
it('requires every condition to hold', async () => {
const service = createService({ renown: 1, reputation: 100 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 10,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 5,
},
]),
).resolves.toBe(false);
});
it('fails closed on a condition type nothing backs yet', async () => {
// Quests arrive in Slice 0.9. Until then a quest gate must lock content,
// never wave it through -- a gate that defaults open is not a gate.
const service = createService();
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{ type: GameConditionType.QUEST_COMPLETED, key: 'anything' },
]),
).resolves.toBe(false);
});
it('fails closed when a condition names no target', async () => {
const service = createService({ reputation: 999 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
operator: ComparisonOperator.GTE,
value: 1,
},
]),
).resolves.toBe(false);
});
it('fails closed when the required value is not a number', async () => {
const service = createService({ reputation: 50 });
await expect(
service.evaluate({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 'lots',
},
]),
).resolves.toBe(false);
});
it('reports each condition separately for a UI that explains a lock', async () => {
const service = createService({ renown: 1, reputation: 100 });
const outcomes = await service.describe({ characterId: CHARACTER_ID }, [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 10,
},
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 5,
},
]);
expect(outcomes.map((outcome) => outcome.met)).toEqual([true, false]);
});
});

View File

@@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { CharacterNpcState } from '../npcs/entities/character-npc-state.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import {
ComparisonOperator,
compare,
GameCondition,
GameConditionType,
SUPPORTED_CONDITION_TYPES,
} from './game-condition.types';
/**
* What a condition is being evaluated *about*.
*
* `npcId` scopes FLAG_SET, because a dialogue flag is per-NPC player state
* (spec §7) rather than a global switch.
*/
export interface ConditionContext {
characterId: string;
npcId?: string;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
export interface ConditionOutcome {
condition: GameCondition;
met: boolean;
}
/**
* Evaluates content-defined conditions server-side (NPC spec §21).
*
* Reused by dialogue, shop offers and exchange rules so unlock logic exists
* once. The client never evaluates a condition (spec §37.9); it is only ever
* told the outcome.
*
* Everything fails closed. An unknown type, a missing key, a condition whose
* backing system does not exist yet -- all read as "not met". For a gate, the
* safe direction of a bug is locked, never opened.
*/
@Injectable()
export class GameConditionService {
constructor(private readonly dataSource: DataSource) {}
/** True only when every condition holds. An empty list is always true. */
async evaluate(
context: ConditionContext,
conditions: GameCondition[] | null | undefined,
manager?: EntityManager,
): Promise<boolean> {
if (!conditions || conditions.length === 0) {
return true;
}
const scope: RepositoryScope = manager ?? this.dataSource;
for (const condition of conditions) {
if (!(await this.evaluateOne(context, condition, scope))) {
return false;
}
}
return true;
}
/**
* Reports each condition individually, so a caller can say *why* something
* is locked instead of only hiding it. Slice 0.8.5 builds its locked-offer
* presentation on this.
*/
async describe(
context: ConditionContext,
conditions: GameCondition[] | null | undefined,
manager?: EntityManager,
): Promise<ConditionOutcome[]> {
if (!conditions || conditions.length === 0) {
return [];
}
const scope: RepositoryScope = manager ?? this.dataSource;
const outcomes: ConditionOutcome[] = [];
for (const condition of conditions) {
outcomes.push({
condition,
met: await this.evaluateOne(context, condition, scope),
});
}
return outcomes;
}
private async evaluateOne(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!SUPPORTED_CONDITION_TYPES.has(condition.type)) {
return false;
}
switch (condition.type) {
case GameConditionType.REGION_REPUTATION:
return this.evaluateRegionReputation(context, condition, scope);
case GameConditionType.WORLD_RENOWN:
return this.evaluateWorldRenown(context, condition, scope);
case GameConditionType.FLAG_SET:
return this.evaluateFlag(context, condition, scope);
case GameConditionType.HAS_ITEM:
return this.evaluateHasItem(context, condition, scope);
default:
return false;
}
}
private async evaluateRegionReputation(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!condition.key) {
return false;
}
const faction = await scope
.getRepository(ReputationFaction)
.findOneBy({ key: condition.key, enabled: true });
if (!faction) {
return false;
}
const row = await scope.getRepository(CharacterReputation).findOneBy({
characterId: context.characterId,
factionId: faction.id,
});
// A faction the character never interacted with reads as 0, not as absent
// -- the same rule ReputationService.getCharacterReputation applies.
return this.compareNumeric(row?.reputation ?? 0, condition);
}
private async evaluateWorldRenown(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
const character = await scope
.getRepository(Character)
.findOneBy({ id: context.characterId });
if (!character) {
return false;
}
return this.compareNumeric(character.renown, condition);
}
private async evaluateFlag(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
// Flags are per-NPC player state (spec §7). Without an NPC in context
// there is nothing to read, so the gate stays shut.
if (!condition.key || !context.npcId) {
return false;
}
const state = await scope.getRepository(CharacterNpcState).findOneBy({
characterId: context.characterId,
npcId: context.npcId,
});
const expected = condition.value ?? true;
return (state?.flags?.[condition.key] ?? false) === expected;
}
private async evaluateHasItem(
context: ConditionContext,
condition: GameCondition,
scope: RepositoryScope,
): Promise<boolean> {
if (!condition.key) {
return false;
}
const definition = await scope
.getRepository(ItemDefinition)
.findOneBy({ key: condition.key });
if (!definition) {
return false;
}
const owned = await scope.getRepository(CharacterItem).findOneBy({
characterId: context.characterId,
itemDefinitionId: definition.id,
});
// "Has item" without an explicit comparison means "at least one".
return this.compareNumeric(owned?.quantity ?? 0, {
...condition,
operator: condition.operator ?? ComparisonOperator.GTE,
value: condition.value ?? 1,
});
}
private compareNumeric(actual: number, condition: GameCondition): boolean {
const expected = Number(condition.value);
if (!Number.isFinite(expected)) {
return false;
}
return compare(
actual,
condition.operator ?? ComparisonOperator.GTE,
expected,
);
}
}

View File

@@ -0,0 +1,76 @@
/**
* The shared condition vocabulary (NPC spec §19, §20).
*
* One engine gates dialogue, shop offers and exchanges alike, so a new gate is
* a row of content rather than another bespoke `if` in a service (spec §21,
* "Keine separaten Condition-Systeme für jedes Feature bauen").
*/
export enum GameConditionType {
QUEST_ACTIVE = 'QUEST_ACTIVE',
QUEST_COMPLETED = 'QUEST_COMPLETED',
HAS_ITEM = 'HAS_ITEM',
REGION_REPUTATION = 'REGION_REPUTATION',
WORLD_RENOWN = 'WORLD_RENOWN',
FLAG_SET = 'FLAG_SET',
BOSS_DEFEATED = 'BOSS_DEFEATED',
LOCATION_DISCOVERED = 'LOCATION_DISCOVERED',
}
export enum ComparisonOperator {
EQ = 'EQ',
NEQ = 'NEQ',
GT = 'GT',
GTE = 'GTE',
LT = 'LT',
LTE = 'LTE',
}
/**
* Stored generically so content can express a new gate without a schema
* change (spec §20). `key` names the target (a faction key, a flag name),
* `operator`/`value` the comparison.
*/
export interface GameCondition {
type: GameConditionType;
key?: string;
operator?: ComparisonOperator;
value?: string | number | boolean;
}
/**
* Condition types this build can actually answer.
*
* The remaining types are part of the V1 vocabulary (spec §19) but have no
* backing system yet: quests arrive in Slice 0.9, bosses in 0.11, and location
* discovery is not tracked per character at all. They are listed in the enum
* so content and migrations do not need rewriting later, and rejected at
* evaluation time so an unbacked gate can never silently read as "passed".
*/
export const SUPPORTED_CONDITION_TYPES: ReadonlySet<GameConditionType> =
new Set([
GameConditionType.REGION_REPUTATION,
GameConditionType.WORLD_RENOWN,
GameConditionType.FLAG_SET,
GameConditionType.HAS_ITEM,
]);
export function compare(
left: number,
operator: ComparisonOperator,
right: number,
): boolean {
switch (operator) {
case ComparisonOperator.EQ:
return left === right;
case ComparisonOperator.NEQ:
return left !== right;
case ComparisonOperator.GT:
return left > right;
case ComparisonOperator.GTE:
return left >= right;
case ComparisonOperator.LT:
return left < right;
case ComparisonOperator.LTE:
return left <= right;
}
}

View File

@@ -20,6 +20,8 @@ export class AddHpRegeneration1792000000000 implements MigrationInterface {
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); await queryRunner.query(
'ALTER TABLE "characters" DROP COLUMN "hp_regen_since"',
);
} }
} }

View File

@@ -0,0 +1,100 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CompleteBurnedRoad1793000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Monster content: mechanics and atmosphere as data (spec §4, §8) ---
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
);
await queryRunner.query(
`ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb NOT NULL DEFAULT '{}'::jsonb`,
);
// --- No direct currency from a kill (spec §7) ---
// Slice 0.6.5 kept `silver_min`/`silver_max` as a deliberate carve-out for
// a possible lore-valid direct drop. Playable Slice 0.7 V2 closes that:
// normal kills grant no Silver at all, and Silver reaches the player
// through merchant/turn-in exchange instead. Nothing reads these columns
// any more, so they go rather than sit as a path that could quietly start
// paying out again. Every seeded monster already had them at 0, so no
// player balance moves with this.
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
);
// --- Status effects (spec §4: Bleeding) ---
await queryRunner.query(
`CREATE TYPE "status_effect_type_enum" AS ENUM ('BLEED')`,
);
await queryRunner.query(
'ALTER TABLE "combat_events" ADD COLUMN "status_effect" "status_effect_type_enum"',
);
// Postgres allows ADD VALUE inside a transaction as long as the new value
// is not also used in it; this migration only declares them. Same
// technique as 1790000000000-ExtendCombatEventTypes.ts.
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_APPLIED'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_DAMAGE'`,
);
await queryRunner.query(
`ALTER TYPE "combat_event_type_enum" ADD VALUE 'STATUS_EXPIRED'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Postgres has no "DROP VALUE"; rebuild the type from scratch instead.
// This fails if any row already uses one of the new values -- expected
// for a dev rollback, the same tradeoff the earlier enum migration makes.
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', '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"',
);
await queryRunner.query(
'ALTER TABLE "combat_events" DROP COLUMN "status_effect"',
);
await queryRunner.query('DROP TYPE "status_effect_type_enum"');
// The dropped currency columns come back at 0 -- their pre-image was 0 for
// every seeded monster, and no reward row recorded anything else.
await queryRunner.query(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "combat_rewards" ALTER COLUMN "silver_granted" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max" integer NOT NULL DEFAULT 0',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_min" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "silver_max" DROP DEFAULT',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateLootBags1794000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Categories as content (spec §3, §5) ---
await queryRunner.query(
`CREATE TYPE "loot_category_enum" AS ENUM ('HIDE', 'RAIDER_TROPHY')`,
);
await queryRunner.query(
`CREATE TYPE "monster_category_enum" AS ENUM ('BEAST', 'HUMANOID')`,
);
// Nullable: only trade goods belong to a carrying bucket. Equipment and
// consumables stay outside the system entirely (spec §8).
await queryRunner.query(
'ALTER TABLE "item_definitions" ADD COLUMN "loot_category" "loot_category_enum"',
);
// Every monster has a category, so this backfills before going NOT NULL.
// BEAST is the safe default for the pre-existing rows: the seed
// immediately reclassifies the two humanoids by their stable keys, and a
// wrong classification changes no behaviour in this slice — nothing
// branches on monster category yet.
await queryRunner.query(
'ALTER TABLE "monster_definitions" ADD COLUMN "monster_category" "monster_category_enum"',
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'BEAST' WHERE "monster_category" IS NULL`,
);
await queryRunner.query(
`UPDATE "monster_definitions" SET "monster_category" = 'HUMANOID' WHERE "key" IN ('road-bandit', 'charred-looter')`,
);
await queryRunner.query(
'ALTER TABLE "monster_definitions" ALTER COLUMN "monster_category" SET NOT NULL',
);
// --- Bags (spec §6) ---
await queryRunner.query(`CREATE TABLE "loot_bag_definitions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"loot_category" "loot_category_enum" NOT NULL,
"capacity" integer NOT NULL,
"icon_path" character varying(255) NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_loot_bag_definitions" PRIMARY KEY ("id"),
CONSTRAINT "CHK_loot_bag_definitions_capacity" CHECK ("capacity" >= 1)
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_loot_bag_definitions_key" ON "loot_bag_definitions" ("key")',
);
await queryRunner.query(`CREATE TABLE "character_loot_bags" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"loot_bag_definition_id" uuid NOT NULL,
"active" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_loot_bags" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_loot_bags_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_character_loot_bags_definition" FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
// A character holds any given bag at most once. "One *active* bag per
// category" is a service-level rule instead: the category lives on the
// definition, and copying it here to get a partial unique index would
// duplicate content into player state (AGENTS §7).
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition" ON "character_loot_bags" ("character_id", "loot_bag_definition_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_character_loot_bags_character" ON "character_loot_bags" ("character_id")',
);
// --- Left-behind loot is part of the record (spec §9) ---
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
);
// A drop that was refused outright creates no stack to point at.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
);
// Slice 0.4 required quantity >= 1, because back then a reward row could
// only mean "you got this". A fully refused drop is granted 0, so the
// floor moves to 0 -- but the row must still record *something*, hence
// the replacement constraint: no row may be all zeroes.
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 0 AND "quantity_left_behind" >= 0 AND "quantity" + "quantity_left_behind" >= 1)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rows recording a fully-rejected drop have no character item and cannot
// satisfy the restored NOT NULL, so they go with the feature that created
// them. Nothing else can produce a null there.
await queryRunner.query(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP CONSTRAINT "CHK_combat_reward_items_quantity"',
);
await queryRunner.query(
`ALTER TABLE "combat_reward_items" ADD CONSTRAINT "CHK_combat_reward_items_quantity" CHECK ("quantity" >= 1)`,
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" ALTER COLUMN "character_item_id" SET NOT NULL',
);
await queryRunner.query(
'ALTER TABLE "combat_reward_items" DROP COLUMN "quantity_left_behind"',
);
await queryRunner.query('DROP INDEX "IDX_character_loot_bags_character"');
await queryRunner.query(
'DROP INDEX "IDX_character_loot_bags_character_definition"',
);
await queryRunner.query('DROP TABLE "character_loot_bags"');
await queryRunner.query('DROP INDEX "IDX_loot_bag_definitions_key"');
await queryRunner.query('DROP TABLE "loot_bag_definitions"');
await queryRunner.query(
'ALTER TABLE "monster_definitions" DROP COLUMN "monster_category"',
);
await queryRunner.query(
'ALTER TABLE "item_definitions" DROP COLUMN "loot_category"',
);
await queryRunner.query('DROP TYPE "monster_category_enum"');
await queryRunner.query('DROP TYPE "loot_category_enum"');
}
}

View File

@@ -0,0 +1,266 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The NPC system and the merchant trade-in loop
* (NPC Specification V1 §29; Playable Slice 0.8).
*
* Also retires `turn_in_definitions` from Slice 0.6.5. Everything it could
* sell is now sold through the merchant instead -- at a better price, and with
* reputation and renown attached -- so the two are not left running as
* parallel payout paths for the same pelt (slice 0.8 §6).
*/
export class CreateNpcSystem1795000000000 implements MigrationInterface {
name = 'CreateNpcSystem1795000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "npc_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"name" character varying(150) NOT NULL,
"title" character varying(150),
"description" text,
"location_id" uuid NOT NULL,
"faction_key" character varying(100),
"portrait_path" character varying(255) NOT NULL,
"artwork_path" character varying(255),
"capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_npc_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_definitions_location" FOREIGN KEY ("location_id")
REFERENCES "location_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_definitions_location" ON "npc_definitions" ("location_id")`,
);
await queryRunner.query(`
CREATE TABLE "dialogue_nodes" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"npc_id" uuid NOT NULL,
"key" character varying(100) NOT NULL,
"text" text NOT NULL,
"priority" integer NOT NULL,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"actions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"responses" jsonb NOT NULL DEFAULT '[]'::jsonb,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_dialogue_nodes" PRIMARY KEY ("id"),
CONSTRAINT "FK_dialogue_nodes_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_dialogue_nodes_npc_key" ON "dialogue_nodes" ("npc_id", "key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_dialogue_nodes_npc_priority" ON "dialogue_nodes" ("npc_id", "priority")`,
);
await queryRunner.query(`
CREATE TABLE "character_npc_states" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"character_id" uuid NOT NULL,
"npc_id" uuid NOT NULL,
"first_met_at" TIMESTAMP WITH TIME ZONE,
"last_interaction_at" TIMESTAMP WITH TIME ZONE,
"flags" jsonb NOT NULL DEFAULT '{}'::jsonb,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_character_npc_states" PRIMARY KEY ("id"),
CONSTRAINT "FK_character_npc_states_character" FOREIGN KEY ("character_id")
REFERENCES "characters"("id") ON DELETE CASCADE,
CONSTRAINT "FK_character_npc_states_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_character_npc_states_character_npc" ON "character_npc_states" ("character_id", "npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_shops" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_npc_shops" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_shops_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_shops_npc" ON "npc_shops" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "shop_offers" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"shop_id" uuid NOT NULL,
"item_definition_id" uuid NOT NULL,
"currency_type" character varying(50) NOT NULL,
"price" integer NOT NULL,
"quantity" integer NOT NULL DEFAULT 1,
"repeatable" boolean NOT NULL DEFAULT true,
"sort_order" integer NOT NULL DEFAULT 0,
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_shop_offers" PRIMARY KEY ("id"),
CONSTRAINT "CHK_shop_offers_price" CHECK ("price" >= 0),
CONSTRAINT "CHK_shop_offers_quantity" CHECK ("quantity" >= 1),
CONSTRAINT "FK_shop_offers_shop" FOREIGN KEY ("shop_id")
REFERENCES "npc_shops"("id") ON DELETE CASCADE,
CONSTRAINT "FK_shop_offers_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")`,
);
await queryRunner.query(`
CREATE TABLE "npc_exchange_profiles" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"npc_id" uuid NOT NULL,
"name" character varying(150) NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_npc_exchange_profiles" PRIMARY KEY ("id"),
CONSTRAINT "FK_npc_exchange_profiles_npc" FOREIGN KEY ("npc_id")
REFERENCES "npc_definitions"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_npc_exchange_profiles_npc" ON "npc_exchange_profiles" ("npc_id")`,
);
await queryRunner.query(`
CREATE TABLE "exchange_rules" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"profile_id" uuid NOT NULL,
"input_item_id" uuid NOT NULL,
"input_quantity" integer NOT NULL DEFAULT 1,
"faction_id" uuid NOT NULL,
"silver_reward" integer NOT NULL DEFAULT 0,
"region_reputation_reward" integer NOT NULL DEFAULT 0,
"renown_milestone_key" character varying(100),
"conditions" jsonb NOT NULL DEFAULT '[]'::jsonb,
"sort_order" integer NOT NULL DEFAULT 0,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_exchange_rules" PRIMARY KEY ("id"),
CONSTRAINT "CHK_exchange_rules_input_quantity" CHECK ("input_quantity" >= 1),
CONSTRAINT "CHK_exchange_rules_rewards" CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0),
CONSTRAINT "FK_exchange_rules_profile" FOREIGN KEY ("profile_id")
REFERENCES "npc_exchange_profiles"("id") ON DELETE CASCADE,
CONSTRAINT "FK_exchange_rules_item" FOREIGN KEY ("input_item_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_exchange_rules_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")`,
);
// Retires Slice 0.6.5's `turn_in_definitions`.
//
// Nothing is carried across: exchange rules are seeded content, and the
// seed re-establishes an equivalent (better paying, reputation- and
// renown-aware) rule for every item that used to be turned in. Migrations
// run before seeds, so there is no exchange profile to attach carried rows
// to at this point anyway.
//
// Dropped rather than left standing, because a dormant second payout path
// for the same pelts is exactly the competing progression model slice 0.8
// §6 rules out -- and a table nothing reads is one someone re-wires later.
await queryRunner.query(`DROP TABLE "turn_in_definitions"`);
// Trading the last of a stack deletes the `character_items` row, and
// Slice 0.4 pinned reward bookkeeping to it with ON DELETE RESTRICT --
// correct when nothing could ever consume a stack, and a hard 500 the
// moment something could. This slice is that something.
//
// SET NULL keeps the reward history intact (it still records what
// dropped) while letting the stack itself go. The column has been
// nullable since 0.7.5, which already used null to mean "no live stack".
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE SET NULL ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Rebuilt exactly as migration 1791 left it, so a rollback lands on the
// schema that migration expects to own.
await queryRunner.query(`
CREATE TABLE "turn_in_definitions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" character varying(100) NOT NULL,
"item_definition_id" uuid NOT NULL,
"faction_id" uuid NOT NULL,
"silver_reward_per_item" integer NOT NULL,
"reputation_reward_per_item" integer NOT NULL,
"repeatable" boolean NOT NULL DEFAULT true,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_turn_in_definitions" PRIMARY KEY ("id"),
CONSTRAINT "FK_turn_in_definitions_item" FOREIGN KEY ("item_definition_id")
REFERENCES "item_definitions"("id") ON DELETE RESTRICT,
CONSTRAINT "FK_turn_in_definitions_faction" FOREIGN KEY ("faction_id")
REFERENCES "reputation_factions"("id") ON DELETE RESTRICT
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_turn_in_definitions_key" ON "turn_in_definitions" ("key")`,
);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
DROP CONSTRAINT "FK_combat_reward_items_character_item"
`);
await queryRunner.query(`
ALTER TABLE "combat_reward_items"
ADD CONSTRAINT "FK_combat_reward_items_character_item"
FOREIGN KEY ("character_item_id") REFERENCES "character_items"("id")
ON DELETE RESTRICT ON UPDATE NO ACTION
`);
await queryRunner.query(`DROP TABLE "exchange_rules"`);
await queryRunner.query(`DROP TABLE "npc_exchange_profiles"`);
await queryRunner.query(`DROP TABLE "shop_offers"`);
await queryRunner.query(`DROP TABLE "npc_shops"`);
await queryRunner.query(`DROP TABLE "character_npc_states"`);
await queryRunner.query(`DROP TABLE "dialogue_nodes"`);
await queryRunner.query(`DROP TABLE "npc_definitions"`);
}
}

View File

@@ -7,7 +7,8 @@ describe('characters.hp_regen_since schema', () => {
const metadata = getMetadataArgsStorage(); const metadata = getMetadataArgsStorage();
const column = metadata.columns.find( const column = metadata.columns.find(
(candidate) => (candidate) =>
candidate.target === Character && candidate.propertyName === 'hpRegenSince', candidate.target === Character &&
candidate.propertyName === 'hpRegenSince',
); );
expect(column).toBeDefined(); expect(column).toBeDefined();

View File

@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CompleteBurnedRoad1793000000000 } from './1793000000000-CompleteBurnedRoad';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { CombatEventType } from '../../combat/combat-event-type.enum';
import { CombatReward } from '../../rewards/entities/combat-reward.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { StatusEffectType } from '../../combat/status-effect.enum';
describe('CompleteBurnedRoad1793000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CompleteBurnedRoad1793000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CompleteBurnedRoad1793000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('adds the monster content columns the slice drives its mechanics from', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "flavor_text" text',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "abilities" jsonb',
),
]),
);
});
it('defaults abilities to an empty object so existing monsters stay plain attackers', async () => {
const up = await runUp();
const abilities = up.find((sql) => sql.includes('"abilities"'));
expect(abilities).toContain("DEFAULT '{}'::jsonb");
expect(abilities).toContain('NOT NULL');
});
it('drops every direct currency reward path from a kill', async () => {
const up = await runUp();
// Spec §7: a normal kill grants no Silver. Leaving the columns in place
// would leave a path that could quietly start paying out again.
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "combat_rewards" DROP COLUMN "silver_granted"',
),
]),
);
});
it('creates the status effect type and hangs it off combat_events', async () => {
const up = await runUp();
const createIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "status_effect_type_enum"'),
);
const columnIndex = up.findIndex((sql) =>
sql.includes('ADD COLUMN "status_effect"'),
);
expect(createIndex).toBeGreaterThanOrEqual(0);
expect(columnIndex).toBeGreaterThanOrEqual(0);
// The column cannot reference a type that does not exist yet.
expect(createIndex).toBeLessThan(columnIndex);
});
it('extends the combat event enum with the status effect events', async () => {
const up = await runUp();
for (const value of ['STATUS_APPLIED', 'STATUS_DAMAGE', 'STATUS_EXPIRED']) {
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
`ALTER TYPE "combat_event_type_enum" ADD VALUE '${value}'`,
),
]),
);
}
});
it('rebuilds the combat event enum without the new values on the way down', async () => {
const down = await runDown();
const rebuilt = down.find((sql) =>
sql.includes('CREATE TYPE "combat_event_type_enum"'),
);
expect(rebuilt).toBeDefined();
expect(rebuilt).not.toContain('STATUS_APPLIED');
// Everything the previous migrations added must survive the rollback.
for (const value of [
'DAMAGE',
'HEAL',
'DEFEND',
'TELEGRAPH',
'INTERRUPT',
'COMBAT_WON',
'COMBAT_LOST',
]) {
expect(rebuilt).toContain(value);
}
});
it('restores every dropped column on the way down', async () => {
const down = await runDown();
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining(
'ALTER TABLE "combat_rewards" ADD COLUMN "silver_granted"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_min"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" ADD COLUMN "silver_max"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "abilities"',
),
expect.stringContaining(
'ALTER TABLE "monster_definitions" DROP COLUMN "flavor_text"',
),
]),
);
expect(down).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TYPE "status_effect_type_enum"'),
]),
);
});
});
describe('slice 0.7 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores monster abilities as jsonb', () => {
expect(column(MonsterDefinition, 'abilities')?.options.type).toBe('jsonb');
});
it('allows a monster without a flavor line', () => {
const flavorText = column(MonsterDefinition, 'flavorText');
expect(flavorText?.options.type).toBe('text');
expect(flavorText?.options.nullable).toBe(true);
});
it('no longer models a currency range on a monster', () => {
expect(column(MonsterDefinition, 'silverMin')).toBeUndefined();
expect(column(MonsterDefinition, 'silverMax')).toBeUndefined();
});
it('no longer models granted Silver on a combat reward', () => {
expect(column(CombatReward, 'silverGranted')).toBeUndefined();
});
it('tags a combat event with the status effect it concerns', () => {
const statusEffect = column(CombatEvent, 'statusEffect');
expect(statusEffect?.options.enum).toBe(StatusEffectType);
expect(statusEffect?.options.nullable).toBe(true);
});
it('includes the status effect event types', () => {
expect(Object.values(CombatEventType)).toEqual(
expect.arrayContaining([
'STATUS_APPLIED',
'STATUS_DAMAGE',
'STATUS_EXPIRED',
]),
);
});
});

View File

@@ -79,8 +79,11 @@ describe('loot and rewards schema', () => {
propertyName: 'combatReward', propertyName: 'combatReward',
target: CombatRewardItem, target: CombatRewardItem,
}), }),
// SET NULL since Slice 0.8: trading the last of a stack deletes the
// character_items row, and RESTRICT made that fail. The reward record
// survives with a null pointer rather than pinning the stack forever.
expect.objectContaining({ expect.objectContaining({
onDelete: 'RESTRICT', onDelete: 'SET NULL',
propertyName: 'characterItem', propertyName: 'characterItem',
target: CombatRewardItem, target: CombatRewardItem,
}), }),

View File

@@ -0,0 +1,223 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateLootBags1794000000000 } from './1794000000000-CreateLootBags';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { CombatRewardItem } from '../../rewards/entities/combat-reward-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { LootCategory } from '../../items/loot-category.enum';
import { MonsterCategory } from '../../monsters/monster-category.enum';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
describe('CreateLootBags1794000000000', () => {
async function runUp() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateLootBags1794000000000().up(queryRunner);
return query.mock.calls.map(([sql]) => sql as string);
}
async function runDown() {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
const migration = new CreateLootBags1794000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
it('creates both category enums before the columns that use them', async () => {
const up = await runUp();
const lootTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "loot_category_enum"'),
);
const lootColumnIndex = up.findIndex((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
const monsterTypeIndex = up.findIndex((sql) =>
sql.includes('CREATE TYPE "monster_category_enum"'),
);
const monsterColumnIndex = up.findIndex((sql) =>
sql.includes('"monster_definitions" ADD COLUMN "monster_category"'),
);
expect(lootTypeIndex).toBeGreaterThanOrEqual(0);
expect(monsterTypeIndex).toBeGreaterThanOrEqual(0);
expect(lootTypeIndex).toBeLessThan(lootColumnIndex);
expect(monsterTypeIndex).toBeLessThan(monsterColumnIndex);
});
it('leaves loot_category nullable, because only trade goods have one', async () => {
const up = await runUp();
const column = up.find((sql) =>
sql.includes('"item_definitions" ADD COLUMN "loot_category"'),
);
expect(column).not.toContain('NOT NULL');
});
it('backfills monster_category BEFORE making it NOT NULL', async () => {
const up = await runUp();
const backfillIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'BEAST'`),
);
const humanoidIndex = up.findIndex((sql) =>
sql.includes(`SET "monster_category" = 'HUMANOID'`),
);
const notNullIndex = up.findIndex((sql) =>
sql.includes('"monster_category" SET NOT NULL'),
);
expect(backfillIndex).toBeGreaterThanOrEqual(0);
// Reversing these would fail the migration on any existing row.
expect(backfillIndex).toBeLessThan(notNullIndex);
expect(humanoidIndex).toBeLessThan(notNullIndex);
});
it('creates the bag tables with their ownership keys', async () => {
const up = await runUp();
const joined = up.join('\n');
expect(joined).toContain('CREATE TABLE "loot_bag_definitions"');
expect(joined).toContain('CREATE TABLE "character_loot_bags"');
// A character cannot hold the same bag twice.
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_character_loot_bags_character_definition"',
);
// Deleting a character takes their bags; a bag definition in use cannot
// be deleted out from under them.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
expect(joined).toContain(
'FOREIGN KEY ("loot_bag_definition_id") REFERENCES "loot_bag_definitions"("id") ON DELETE RESTRICT',
);
});
it('refuses a bag that carries nothing', async () => {
const up = await runUp();
expect(up.join('\n')).toContain('CHECK ("capacity" >= 1)');
});
it('lets a reward item record a drop that was refused outright', async () => {
const up = await runUp();
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining(
'"combat_reward_items" ADD COLUMN "quantity_left_behind" integer NOT NULL DEFAULT 0',
),
expect.stringContaining(
'"combat_reward_items" ALTER COLUMN "character_item_id" DROP NOT NULL',
),
]),
);
});
it('lowers the quantity floor to 0 but still forbids an empty row', async () => {
const up = await runUp();
const constraint = up
.filter((sql) => sql.includes('CHK_combat_reward_items_quantity'))
.join(' ');
// Slice 0.4's `quantity >= 1` would reject a fully refused drop outright,
// which is how this surfaced: the victory 500'd instead of recording what
// was left behind.
expect(constraint).toContain('DROP CONSTRAINT');
expect(constraint).toContain('"quantity" >= 0');
// A row still has to mean something: granted 0 and refused 0 is nonsense.
expect(constraint).toContain('"quantity" + "quantity_left_behind" >= 1');
});
it('restores the original quantity floor on the way down', async () => {
const down = await runDown();
const restored = down.find((sql) =>
sql.includes('ADD CONSTRAINT "CHK_combat_reward_items_quantity"'),
);
expect(restored).toContain('"quantity" >= 1');
expect(restored).not.toContain('quantity_left_behind');
});
it('clears the rows that cannot satisfy the restored NOT NULL on the way down', async () => {
const down = await runDown();
const deleteIndex = down.findIndex((sql) =>
sql.includes(
'DELETE FROM "combat_reward_items" WHERE "character_item_id" IS NULL',
),
);
const notNullIndex = down.findIndex((sql) =>
sql.includes('"character_item_id" SET NOT NULL'),
);
expect(deleteIndex).toBeGreaterThanOrEqual(0);
// Without the delete first, the rollback would simply fail.
expect(deleteIndex).toBeLessThan(notNullIndex);
});
it('drops the enum types only after the columns using them are gone', async () => {
const down = await runDown();
const dropColumnIndex = down.findIndex((sql) =>
sql.includes('"item_definitions" DROP COLUMN "loot_category"'),
);
const dropTypeIndex = down.findIndex((sql) =>
sql.includes('DROP TYPE "loot_category_enum"'),
);
const dropBagTableIndex = down.findIndex((sql) =>
sql.includes('DROP TABLE "loot_bag_definitions"'),
);
expect(dropColumnIndex).toBeLessThan(dropTypeIndex);
// loot_bag_definitions.loot_category uses the same type.
expect(dropBagTableIndex).toBeLessThan(dropTypeIndex);
});
});
describe('slice 0.7.5 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('lets an item definition sit outside every loot category', () => {
const lootCategory = column(ItemDefinition, 'lootCategory');
expect(lootCategory?.options.enum).toBe(LootCategory);
expect(lootCategory?.options.nullable).toBe(true);
});
it('requires a category on every monster definition', () => {
const monsterCategory = column(MonsterDefinition, 'monsterCategory');
expect(monsterCategory?.options.enum).toBe(MonsterCategory);
expect(monsterCategory?.options.nullable).toBeUndefined();
});
it('models a bag definition as content with a category and a capacity', () => {
expect(column(LootBagDefinition, 'lootCategory')?.options.enum).toBe(
LootCategory,
);
expect(column(LootBagDefinition, 'capacity')?.options.type).toBe('integer');
});
it('models an owned bag as active-or-not player state', () => {
expect(column(CharacterLootBag, 'active')?.options.type).toBe('boolean');
expect(column(CharacterLootBag, 'characterId')?.options.type).toBe('uuid');
});
it('lets a reward item point at no character item', () => {
expect(column(CombatRewardItem, 'characterItemId')?.options.nullable).toBe(
true,
);
expect(column(CombatRewardItem, 'quantityLeftBehind')?.options.type).toBe(
'integer',
);
});
});

View File

@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { getMetadataArgsStorage, QueryRunner } from 'typeorm';
import { CreateNpcSystem1795000000000 } from './1795000000000-CreateNpcSystem';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { CharacterNpcState } from '../../npcs/entities/character-npc-state.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
async function runUp(): Promise<string[]> {
const query = jest.fn().mockResolvedValue(undefined);
const queryRunner = { query } as unknown as QueryRunner;
await new CreateNpcSystem1795000000000().up(queryRunner);
return 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 CreateNpcSystem1795000000000();
await migration.up(queryRunner);
const upCount = query.mock.calls.length;
await migration.down(queryRunner);
return query.mock.calls.slice(upCount).map(([sql]) => sql as string);
}
describe('CreateNpcSystem1795000000000', () => {
it('creates every NPC-system table', async () => {
const joined = (await runUp()).join('\n');
for (const table of [
'npc_definitions',
'dialogue_nodes',
'character_npc_states',
'npc_shops',
'shop_offers',
'npc_exchange_profiles',
'exchange_rules',
]) {
expect(joined).toContain(`CREATE TABLE "${table}"`);
}
});
it('creates parent tables before the children that reference them', async () => {
const up = await runUp();
const indexOf = (needle: string) =>
up.findIndex((sql) => sql.includes(needle));
const npcs = indexOf('CREATE TABLE "npc_definitions"');
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "dialogue_nodes"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_shops"'));
expect(npcs).toBeLessThan(indexOf('CREATE TABLE "npc_exchange_profiles"'));
expect(indexOf('CREATE TABLE "npc_shops"')).toBeLessThan(
indexOf('CREATE TABLE "shop_offers"'),
);
expect(indexOf('CREATE TABLE "npc_exchange_profiles"')).toBeLessThan(
indexOf('CREATE TABLE "exchange_rules"'),
);
});
it('gives every NPC-facing row a stable unique business key (spec §4)', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_definitions_key" ON "npc_definitions" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_shops_key" ON "npc_shops" ("key")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_npc_exchange_profiles_key" ON "npc_exchange_profiles" ("key")',
);
});
it('keeps one exchange rule per item, so a good has one price', async () => {
const joined = (await runUp()).join('\n');
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_exchange_rules_profile_item" ON "exchange_rules" ("profile_id", "input_item_id")',
);
expect(joined).toContain(
'CREATE UNIQUE INDEX "IDX_shop_offers_shop_item" ON "shop_offers" ("shop_id", "item_definition_id")',
);
});
it('refuses content that would pay out nonsense', async () => {
const joined = (await runUp()).join('\n');
// A rule trading zero items would loop forever against any stack.
expect(joined).toContain('CHECK ("input_quantity" >= 1)');
expect(joined).toContain(
'CHECK ("silver_reward" >= 0 AND "region_reputation_reward" >= 0)',
);
expect(joined).toContain('CHECK ("price" >= 0)');
});
it('cascades player-owned rows and protects referenced content', async () => {
// Constraints are written across two lines for readability, so compare
// against whitespace-collapsed SQL rather than the literal formatting.
const joined = (await runUp()).join('\n').replace(/\s+/g, ' ');
// Deleting a character takes their NPC state with it.
expect(joined).toContain(
'FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE',
);
// An item that is priced somewhere cannot be deleted out from under it.
expect(joined).toContain(
'FOREIGN KEY ("input_item_id") REFERENCES "item_definitions"("id") ON DELETE RESTRICT',
);
expect(joined).toContain(
'FOREIGN KEY ("faction_id") REFERENCES "reputation_factions"("id") ON DELETE RESTRICT',
);
});
it('retires the turn-in table that the exchange replaces', async () => {
const up = await runUp();
// Slice 0.6.5's turn-in and this exchange both convert a pelt into silver
// and reputation. Leaving both would be two prices for one pelt
// (slice 0.8 §6).
expect(up).toEqual(
expect.arrayContaining([
expect.stringContaining('DROP TABLE "turn_in_definitions"'),
]),
);
});
it('rebuilds the turn-in table on the way down', async () => {
const down = await runDown();
const joined = down.join('\n');
expect(joined).toContain('CREATE TABLE "turn_in_definitions"');
expect(joined).toContain('"silver_reward_per_item" integer NOT NULL');
});
it('drops children before parents on the way down', async () => {
const down = await runDown();
const indexOf = (needle: string) =>
down.findIndex((sql) => sql.includes(needle));
expect(indexOf('DROP TABLE "exchange_rules"')).toBeLessThan(
indexOf('DROP TABLE "npc_exchange_profiles"'),
);
expect(indexOf('DROP TABLE "shop_offers"')).toBeLessThan(
indexOf('DROP TABLE "npc_shops"'),
);
expect(indexOf('DROP TABLE "dialogue_nodes"')).toBeLessThan(
indexOf('DROP TABLE "npc_definitions"'),
);
});
});
describe('slice 0.8 entity schema', () => {
function column(target: unknown, propertyName: string) {
return getMetadataArgsStorage().columns.find(
(candidate) =>
candidate.target === target && candidate.propertyName === propertyName,
);
}
it('stores NPC capabilities as data rather than as subclasses (spec §2)', () => {
expect(column(NpcDefinition, 'capabilities')?.options.type).toBe('jsonb');
});
it('lets a dialogue node carry its own conditions and priority (spec §11)', () => {
expect(column(DialogueNode, 'priority')?.options.type).toBe('integer');
expect(column(DialogueNode, 'conditions')?.options.type).toBe('jsonb');
});
it('keeps per-character NPC state off the shared definition (spec §7)', () => {
expect(column(CharacterNpcState, 'characterId')?.options.type).toBe('uuid');
expect(column(CharacterNpcState, 'flags')?.options.type).toBe('jsonb');
// Personal relationship is explicitly out of V1 scope (spec §35).
expect(column(CharacterNpcState, 'relationValue')).toBeUndefined();
});
it('gives every shop offer its own conditions (spec §16)', () => {
expect(column(ShopOffer, 'conditions')?.options.type).toBe('jsonb');
});
it('names a renown milestone instead of paying renown per item', () => {
// Renown is a 1-15 power rank recomputed from a curve (Slice 0.6.5 §4),
// so it is awarded by milestone, not accumulated per pelt.
const milestone = column(ExchangeRule, 'renownMilestoneKey');
expect(milestone?.options.nullable).toBe(true);
expect(column(ExchangeRule, 'silverReward')?.options.type).toBe('integer');
});
it('keeps the exchange profile separate from the shop (spec §17)', () => {
expect(column(NpcExchangeProfile, 'key')?.options.type).toBe('varchar');
expect(column(NpcShop, 'key')?.options.type).toBe('varchar');
});
});

View File

@@ -8,7 +8,6 @@ import { ReputationFaction } from '../../reputation/entities/reputation-faction.
import { CharacterReputation } from '../../reputation/entities/character-reputation.entity'; import { CharacterReputation } from '../../reputation/entities/character-reputation.entity';
import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity'; import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity'; import { CharacterRenownMilestone } from '../../renown/entities/character-renown-milestone.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity';
function columnNames(target: unknown): string[] { function columnNames(target: unknown): string[] {
return getMetadataArgsStorage() return getMetadataArgsStorage()
@@ -71,7 +70,7 @@ describe('Slice 0.6.5 entity metadata', () => {
).toBe(true); ).toBe(true);
}); });
it('TurnInDefinition has a unique key', () => { // TurnInDefinition's metadata assertion lived here until Slice 0.8 retired
expect(uniqueIndexFor(TurnInDefinition, ['key'])).toBe(true); // that entity in favour of ExchangeRule. Its replacement is covered by
}); // `npc-system.migration.spec.ts`.
}); });

View File

@@ -1,11 +1,14 @@
import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemRarity } from '../../items/item-rarity.enum'; import { ItemRarity } from '../../items/item-rarity.enum';
import { ItemType } from '../../items/item-type.enum'; import { ItemType } from '../../items/item-type.enum';
import { LootCategory } from '../../items/loot-category.enum';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS, ITEM_IDS,
ItemKey, ItemKey,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
export interface SeedItemDefinition { export interface SeedItemDefinition {
@@ -16,6 +19,7 @@ export interface SeedItemDefinition {
type: ItemType; type: ItemType;
equipmentSlot: EquipmentSlot | null; equipmentSlot: EquipmentSlot | null;
rarity: ItemRarity; rarity: ItemRarity;
lootCategory: LootCategory | null;
tier: number; tier: number;
weaponDamage: number; weaponDamage: number;
bonusHp: number; bonusHp: number;
@@ -38,6 +42,8 @@ function item(
'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor' 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor'
> >
> = {}, > = {},
// Only trade goods carry one; everything else is uncapped (0.7.5 §4, §8).
lootCategory: LootCategory | null = null,
): SeedItemDefinition { ): SeedItemDefinition {
return { return {
id: ITEM_IDS[key], id: ITEM_IDS[key],
@@ -47,6 +53,7 @@ function item(
type, type,
equipmentSlot, equipmentSlot,
rarity, rarity,
lootCategory,
tier: 1, tier: 1,
weaponDamage: stats.weaponDamage ?? 0, weaponDamage: stats.weaponDamage ?? 0,
bonusHp: stats.bonusHp ?? 0, bonusHp: stats.bonusHp ?? 0,
@@ -161,23 +168,49 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
null, null,
ItemRarity.COMMON, ItemRarity.COMMON,
), ),
// A Trade Good (spec §17): turned in to the Border Watch for Silver and // Trade Goods (spec §17): turned in to the Border Watch for Silver and
// reputation rather than crafted with. // reputation rather than crafted with. Playable Slice 0.7 V2 §5 makes one
// of these the guaranteed drop of every Burned Road enemy, which is why
// there are now four of them -- one per monster.
item( item(
'ash-pelt', 'ash-pelt',
'Ash Pelt', 'Ashen Pelt',
'Singed pelt, tough as leather and grey with drifting ash.', 'Singed pelt, tough as leather and grey with drifting ash.',
ItemType.TRADE_GOOD, ItemType.TRADE_GOOD,
null, null,
ItemRarity.COMMON, ItemRarity.COMMON,
{},
LootCategory.HIDE,
),
item(
'tough-hide',
'Tough Hide',
'Road-hound hide, scarred over so often it barely takes a blade.',
ItemType.TRADE_GOOD,
null,
ItemRarity.COMMON,
{},
LootCategory.HIDE,
), ),
item( item(
'bandit-insignia', 'bandit-insignia',
'Bandit Insignia', 'Raider Insignia',
'A roughly stamped token marking a road bandit as one of their band.', 'A roughly stamped token marking a road bandit as one of their band.',
ItemType.TROPHY, ItemType.TROPHY,
null, null,
ItemRarity.COMMON, ItemRarity.COMMON,
{},
LootCategory.RAIDER_TROPHY,
),
item(
'charred-raider-insignia',
'Charred Raider Insignia',
'The same stamped token, warped by heat until the band mark is barely legible.',
ItemType.TROPHY,
null,
ItemRarity.RARE,
{},
LootCategory.RAIDER_TROPHY,
), ),
]; ];
@@ -188,6 +221,16 @@ export const LOOT_TABLES = [
key: 'road-bandit-loot', key: 'road-bandit-loot',
name: 'Road Bandit Loot', name: 'Road Bandit Loot',
}, },
{
id: WILD_ROAD_DOG_LOOT_TABLE_ID,
key: 'wild-road-dog-loot',
name: 'Feral Road Hound Loot',
},
{
id: CHARRED_LOOTER_LOOT_TABLE_ID,
key: 'charred-looter-loot',
name: 'Charred Raider Loot',
},
]; ];
export interface SeedLootTableEntry { export interface SeedLootTableEntry {
@@ -218,17 +261,33 @@ function entry(
} }
/** /**
* Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §2728. * Equipment drop chances come from
* Every entry is an independent roll (spec §17), rolled in `position` order. * docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §2728; the trade goods
* are guaranteed by Playable Slice 0.7 V2 §5. Every entry is an independent
* roll (spec §17), rolled in `position` order, so the guaranteed trade good
* and the equipment chance never influence each other.
* *
* DEFERRED: the Straßenräuber table also lists 10 % Kleiner Heiltrank. It is * `position` values of pre-existing entries are left where they were: the
* omitted here because Slice 0.4 implements no consumables (spec §16). * seed upserts on (lootTableId, itemDefinitionId) while `position` carries a
* unique index, so renumbering an existing table in one statement would
* collide with itself.
*
* DEFERRED: the Feral Road Hound is also meant to drop a Small Healing Potion
* "if potions are already lootable" (spec §6). They are not — combat potions
* are still a fixed per-fight count, not inventory-backed — so the entry waits
* rather than seeding an item that does nothing.
*/ */
export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [
entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '0.6000'), entry(ASH_RAT_LOOT_TABLE_ID, 'ash-pelt', 1, '1.0000'),
entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'), entry(ASH_RAT_LOOT_TABLE_ID, 'worn-short-sword', 2, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-blade', 1, '0.1800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-hood', 2, '0.1200'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'raider-gloves', 3, '0.0800'),
entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '0.4000'), entry(ROAD_BANDIT_LOOT_TABLE_ID, 'bandit-insignia', 4, '1.0000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'tough-hide', 1, '1.0000'),
entry(WILD_ROAD_DOG_LOOT_TABLE_ID, 'ash-boots', 2, '0.0800'),
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'charred-raider-insignia', 1, '1.0000'),
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'),
]; ];

View File

@@ -13,9 +13,17 @@ export const ITEM_IDS = {
'small-healing-potion': '50000000-0000-4000-8000-00000000000b', 'small-healing-potion': '50000000-0000-4000-8000-00000000000b',
'ash-pelt': '50000000-0000-4000-8000-00000000000c', 'ash-pelt': '50000000-0000-4000-8000-00000000000c',
'bandit-insignia': '50000000-0000-4000-8000-00000000000d', 'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
'tough-hide': '50000000-0000-4000-8000-00000000000e',
'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f',
} as const; } as const;
export type ItemKey = keyof typeof ITEM_IDS; export type ItemKey = keyof typeof ITEM_IDS;
// One table per monster (Playable Slice 0.7 V2 spec §5): each Burned Road
// enemy now has its own guaranteed trade good, so they can no longer share.
export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001'; export const ASH_RAT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002'; export const ROAD_BANDIT_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000002';
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';

View File

@@ -126,14 +126,15 @@ export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
// promise a drop the roll does not guarantee (spec §8, "Mögliche // promise a drop the roll does not guarantee (spec §8, "Mögliche
// Belohnungen"). // Belohnungen").
// //
// Silver and experience were both dropped from this preview by slice 0.6.5. // Silver and experience were both dropped from this preview by slice 0.6.5,
// Experience no longer exists as a concept at all (spec §1), and every // and Playable Slice 0.7 V2 §7 makes that permanent: a normal kill grants
// monster seeded on this road now rolls silverMin/silverMax = 0 (design R7), // no XP, Silver, regional reputation or World Renown, and the monster
// so a kill here yields neither. Silver reaches the player through turn-ins // definitions no longer carry a currency range at all. Silver reaches the
// instead. Leaving either entry in place would break this list's own rule. // player through turn-ins instead. Leaving either entry in place would
// break this list's own rule.
localRewardPreview: [ localRewardPreview: [
{ key: 'equipment', label: 'Equipment', iconKey: 'equipment' }, { key: 'equipment', label: 'Equipment', iconKey: 'equipment' },
{ key: 'material', label: 'Material', iconKey: 'material' }, { key: 'material', label: 'Trade Goods', iconKey: 'material' },
], ],
}; };
@@ -171,6 +172,20 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
resultText: resultText:
'"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."', '"Beyond the gate, Graufurt\'s protection ends. Whoever heads south does so at their own risk — and rarely comes back the way they left."',
}, },
// Borin stands at the gate rather than deeper in a town that does not
// exist yet as a location. Carries `npcKey` instead of result text, so
// this hotspot opens his screen (Playable Slice 0.8 §2).
{
key: 'borin',
title: 'Borin, Quartermaster',
actionLabel: 'Trade',
type: 'NPC',
iconKey: 'speak',
xPercent: 30,
yPercent: 66,
enabled: true,
npcKey: 'borin-quartermaster',
},
{ {
key: 'south-road', key: 'south-road',
title: 'Road South', title: 'Road South',
@@ -192,6 +207,16 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
enabled: true, enabled: true,
poiKey: 'gate-watch', poiKey: 'gate-watch',
}, },
{
key: 'trade-with-borin',
label: 'Trade with Borin',
description: 'Sell goods, buy supplies',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'borin',
npcKey: 'borin-quartermaster',
},
{ {
key: 'read-notice', key: 'read-notice',
label: 'Read the notice', label: 'Read the notice',

View File

@@ -0,0 +1,38 @@
import { LootCategory } from '../../items/loot-category.enum';
export const BASIC_HIDE_BAG_ID = 'a0000000-0000-4000-8000-000000000001';
export const BASIC_TROPHY_POUCH_ID = 'a0000000-0000-4000-8000-000000000002';
export interface SeedLootBagDefinition {
id: string;
key: string;
name: string;
lootCategory: LootCategory;
capacity: number;
iconPath: string;
}
/**
* The starter bag per category (Playable Slice 0.7.5 §7).
*
* Capacity 5 against a bagless default of 1: enough that a hunt is worth
* making, small enough that the trip still ends.
*/
export const LOOT_BAG_DEFINITIONS: SeedLootBagDefinition[] = [
{
id: BASIC_HIDE_BAG_ID,
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: LootCategory.HIDE,
capacity: 5,
iconPath: '/images/items/basic-hide-bag.png',
},
{
id: BASIC_TROPHY_POUCH_ID,
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: LootCategory.RAIDER_TROPHY,
capacity: 5,
iconPath: '/images/items/basic-trophy-pouch.png',
},
];

View File

@@ -0,0 +1,335 @@
import {
ComparisonOperator,
GameCondition,
GameConditionType,
} from '../../conditions/game-condition.types';
import { NpcCapability } from '../../npcs/npc.types';
import type {
DialogueAction,
DialogueResponseContent,
} from '../../npcs/npc.types';
import { ITEM_IDS } from './item.constants';
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export const BORIN_NPC_ID = 'b0000000-0000-4000-8000-000000000001';
export const BORIN_SHOP_ID = 'b1000000-0000-4000-8000-000000000001';
export const BORIN_EXCHANGE_PROFILE_ID = 'b2000000-0000-4000-8000-000000000001';
export const BORIN_KEY = 'borin-quartermaster';
export const BORIN_SHOP_KEY = 'borin-supplies';
export const BORIN_EXCHANGE_KEY = 'borin-trade-in';
/**
* The Renown 2 milestone (Playable Slice 0.6.5 §6).
*
* 0.6.5 names "first meaningful trophy returned" as what should move a
* character from Renown 1 to 2, and this is the first slice where returning a
* trophy is possible at all. Non-repeatable: it fires on the first trade and
* never again, which is how routine trading keeps paying silver and reputation
* without inflating a power rank (0.6.5 §2.2).
*/
export const FIRST_TRADE_MILESTONE_KEY = 'first-goods-returned';
export const FIRST_TRADE_MILESTONE_ID = 'b3000000-0000-4000-8000-000000000001';
export interface SeedRenownMilestone {
id: string;
key: string;
name: string;
description: string;
renownReward: number;
repeatable: boolean;
enabled: boolean;
}
export const RENOWN_MILESTONES: SeedRenownMilestone[] = [
{
id: FIRST_TRADE_MILESTONE_ID,
key: FIRST_TRADE_MILESTONE_KEY,
name: 'Something Worth Bringing Back',
description:
'You returned from the Ashen Fields with goods worth trading, and the Border Watch noticed.',
renownReward: 1,
repeatable: false,
enabled: true,
},
];
export interface SeedNpcDefinition {
id: string;
key: string;
name: string;
title: string | null;
description: string | null;
locationKey: string;
factionKey: string | null;
portraitPath: string;
artworkPath: string | null;
capabilities: NpcCapability[];
enabled: boolean;
}
/**
* Borin, the Graufurt quartermaster (Playable Slice 0.8 §2).
*
* Placed at `south-gate` because that is Graufurt in the current content --
* the only safe, non-hunting location, named "Graufurt South Gate". No new
* town location is invented for him; when Graufurt proper is built out he
* moves by changing this one key.
*
* His capabilities list MERCHANT and RESOURCE_EXCHANGE together, which is the
* whole point of the composition model: one person, two functions, no
* `MerchantNpc` subclass (NPC spec §2, §26).
*/
export const NPC_DEFINITIONS: SeedNpcDefinition[] = [
{
id: BORIN_NPC_ID,
key: BORIN_KEY,
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description:
'A broad, grey-bearded man who has outlasted three captains and every fashion of optimism. He weighs what you bring him without ceremony, and pays what it is worth.',
locationKey: 'south-gate',
factionKey: 'border-guard',
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: [
NpcCapability.DIALOGUE,
NpcCapability.MERCHANT,
NpcCapability.RESOURCE_EXCHANGE,
],
enabled: true,
},
];
export interface SeedDialogueNode {
npcId: string;
key: string;
text: string;
priority: number;
conditions: GameCondition[];
actions: DialogueAction[];
responses: DialogueResponseContent[];
enabled: boolean;
}
/**
* Borin's lines, chosen by priority (NPC spec §11).
*
* Three nodes, deliberately overlapping: the greeting outranks the standard
* line but only survives the first visit, and the reputation line outranks
* both once the Border Watch actually knows the player. Nothing in
* `NpcService` knows any of this -- it takes the highest node whose conditions
* hold.
*/
export const DIALOGUE_NODES: SeedDialogueNode[] = [
{
npcId: BORIN_NPC_ID,
key: 'borin-first-meeting',
text: 'You have the look of someone who has been south of the gate. Most who go out there come back with nothing but ash in their boots. If you did better than that, I will weigh it and pay you fairly. Not generously. Fairly.',
priority: 800,
// Fires only before the visit is recorded -- `NpcService` sets `met` after
// dialogue is resolved, so this node is unreachable from the second visit.
conditions: [
{
type: GameConditionType.FLAG_SET,
key: 'met',
value: false,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: BORIN_NPC_ID,
key: 'borin-trusted',
text: 'Back again, and still walking. The Watch has started using your name without spitting afterward — that is as close to praise as we get here. Show me what you brought.',
priority: 500,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
actions: [],
responses: [],
enabled: true,
},
{
npcId: BORIN_NPC_ID,
key: 'borin-default',
text: 'Pelts, hides, raider trinkets — I take all of it, and the Watch asks no questions about where it came from. Silver for goods, and a word in the right ear if the goods are good.',
priority: 100,
conditions: [],
actions: [],
responses: [],
enabled: true,
},
];
export interface SeedNpcShop {
id: string;
key: string;
npcId: string;
name: string;
enabled: boolean;
}
export const NPC_SHOPS: SeedNpcShop[] = [
{
id: BORIN_SHOP_ID,
key: BORIN_SHOP_KEY,
npcId: BORIN_NPC_ID,
name: "Quartermaster's Supplies",
enabled: true,
},
];
export interface SeedShopOffer {
shopId: string;
itemDefinitionId: string;
currencyType: string;
price: number;
quantity: number;
repeatable: boolean;
sortOrder: number;
conditions: GameCondition[];
enabled: boolean;
}
/**
* Deliberately thin (slice §12: "Avoid adding many new items just to populate
* the shop"). The point of 0.8 is trade-in; the shop exists so Silver has
* somewhere to go the moment it is earned.
*
* No offer carries conditions yet -- reputation-gated offers are Slice 0.8.5
* (slice §3). Loot bags are not sold here either: 0.8.5 §4 plans the Basic
* Hide Bag as its locked-offer example, and a bag is a `LootBagDefinition`
* rather than an `ItemDefinition`, so selling one needs an offer shape this
* slice has no reason to build.
*/
export const SHOP_OFFERS: SeedShopOffer[] = [
{
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['small-healing-potion'],
currencyType: 'SILVER',
price: 12,
quantity: 1,
repeatable: true,
sortOrder: 1,
conditions: [],
enabled: true,
},
{
shopId: BORIN_SHOP_ID,
itemDefinitionId: ITEM_IDS['worn-short-sword'],
currencyType: 'SILVER',
price: 30,
quantity: 1,
repeatable: true,
sortOrder: 2,
conditions: [],
enabled: true,
},
];
export interface SeedNpcExchangeProfile {
id: string;
key: string;
npcId: string;
name: string;
enabled: boolean;
}
export const NPC_EXCHANGE_PROFILES: SeedNpcExchangeProfile[] = [
{
id: BORIN_EXCHANGE_PROFILE_ID,
key: BORIN_EXCHANGE_KEY,
npcId: BORIN_NPC_ID,
name: 'Border Watch Trade-In',
enabled: true,
},
];
export interface SeedExchangeRule {
profileId: string;
inputItemId: string;
inputQuantity: number;
factionId: string;
silverReward: number;
regionReputationReward: number;
renownMilestoneKey: string | null;
conditions: GameCondition[];
sortOrder: number;
enabled: boolean;
}
/**
* What Borin pays (Playable Slice 0.8 §4, §5).
*
* All four Burned Road trade goods are accepted, and the rare Charred Raider
* Insignia is worth visibly more than the common Ashen Pelt -- 30 silver
* against 5, six times the value for a drop that comes off a 2%-weight
* encounter (slice §5).
*
* Silver is up from the old turn-in values (4 and 12) because this is now the
* only way to earn it: normal kills pay nothing at all (slice 0.7 §7), so the
* exchange has to carry the whole economy on its own (slice §7).
*
* Every rule pays Border Watch reputation, the Ashen Fields faction. Only the
* pelt carries the renown milestone: it is the guaranteed Ash Rat drop, so
* the very first trade any player makes will fire it, whatever they hunted.
* Provisional balancing values (slice §4).
*/
export const EXCHANGE_RULES: SeedExchangeRule[] = [
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['ash-pelt'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 5,
regionReputationReward: 2,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 1,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['tough-hide'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 8,
regionReputationReward: 3,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 2,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['bandit-insignia'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 14,
regionReputationReward: 5,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 3,
enabled: true,
},
{
profileId: BORIN_EXCHANGE_PROFILE_ID,
inputItemId: ITEM_IDS['charred-raider-insignia'],
inputQuantity: 1,
factionId: BORDER_GUARD_FACTION_ID,
silverReward: 30,
regionReputationReward: 12,
renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY,
conditions: [],
sortOrder: 4,
enabled: true,
},
];

View File

@@ -1,38 +0,0 @@
import { ITEM_IDS } from './item.constants';
import { BORDER_GUARD_FACTION_ID } from './reputation-content';
export interface SeedTurnInDefinition {
id: string;
key: string;
itemDefinitionId: string;
factionId: string;
silverRewardPerItem: number;
reputationRewardPerItem: number;
repeatable: boolean;
enabled: boolean;
}
// Initial development values (spec §21), kept in content so they can be
// tuned without touching TurnInService's logic (spec §42).
export const TURN_IN_DEFINITIONS: SeedTurnInDefinition[] = [
{
id: '90000000-0000-4000-8000-000000000001',
key: 'ash-pelt-border-guard',
itemDefinitionId: ITEM_IDS['ash-pelt'],
factionId: BORDER_GUARD_FACTION_ID,
silverRewardPerItem: 4,
reputationRewardPerItem: 1,
repeatable: true,
enabled: true,
},
{
id: '90000000-0000-4000-8000-000000000002',
key: 'bandit-insignia-border-guard',
itemDefinitionId: ITEM_IDS['bandit-insignia'],
factionId: BORDER_GUARD_FACTION_ID,
silverRewardPerItem: 12,
reputationRewardPerItem: 4,
repeatable: true,
enabled: true,
},
];

View File

@@ -5,17 +5,27 @@ import { CharacterItem } from '../../items/entities/character-item.entity';
import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTable } from '../../loot/entities/loot-table.entity';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity'; import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity'; import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS, ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { seedVisibleVerticalSlice } from './vertical-slice.seed'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
@@ -88,7 +98,15 @@ function createDataSource(
characterItemRepository: InMemoryRepository = new InMemoryRepository(), characterItemRepository: InMemoryRepository = new InMemoryRepository(),
characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(), characterEquipmentRepository: InMemoryRepository = new InMemoryRepository(),
reputationFactionRepository: InMemoryRepository = new InMemoryRepository(), reputationFactionRepository: InMemoryRepository = new InMemoryRepository(),
turnInDefinitionRepository: InMemoryRepository = new InMemoryRepository(), renownMilestoneRepository: InMemoryRepository = new InMemoryRepository(),
lootBagDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
characterLootBagRepository: InMemoryRepository = new InMemoryRepository(),
npcDefinitionRepository: InMemoryRepository = new InMemoryRepository(),
dialogueNodeRepository: InMemoryRepository = new InMemoryRepository(),
npcShopRepository: InMemoryRepository = new InMemoryRepository(),
shopOfferRepository: InMemoryRepository = new InMemoryRepository(),
npcExchangeProfileRepository: InMemoryRepository = new InMemoryRepository(),
exchangeRuleRepository: InMemoryRepository = new InMemoryRepository(),
): DataSource { ): DataSource {
return { return {
getRepository: jest.fn((entity: unknown) => { getRepository: jest.fn((entity: unknown) => {
@@ -103,7 +121,16 @@ function createDataSource(
if (entity === CharacterItem) return characterItemRepository; if (entity === CharacterItem) return characterItemRepository;
if (entity === CharacterEquipment) return characterEquipmentRepository; if (entity === CharacterEquipment) return characterEquipmentRepository;
if (entity === ReputationFaction) return reputationFactionRepository; if (entity === ReputationFaction) return reputationFactionRepository;
if (entity === TurnInDefinition) return turnInDefinitionRepository; if (entity === RenownMilestoneDefinition)
return renownMilestoneRepository;
if (entity === NpcDefinition) return npcDefinitionRepository;
if (entity === DialogueNode) return dialogueNodeRepository;
if (entity === NpcShop) return npcShopRepository;
if (entity === ShopOffer) return shopOfferRepository;
if (entity === NpcExchangeProfile) return npcExchangeProfileRepository;
if (entity === ExchangeRule) return exchangeRuleRepository;
if (entity === LootBagDefinition) return lootBagDefinitionRepository;
if (entity === CharacterLootBag) return characterLootBagRepository;
throw new Error('Unexpected repository'); throw new Error('Unexpected repository');
}), }),
@@ -182,67 +209,90 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({ expect.objectContaining({
key: 'ash-rat', key: 'ash-rat',
name: 'Ash Rat', name: 'Ash Rat',
monsterCategory: 'BEAST',
level: 1, level: 1,
maxHp: 45, maxHp: 45,
attack: 5, attack: 5,
armor: 0, armor: 0,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
// Pure baseline combat: no telegraph, no status effect (spec §4).
abilities: {},
}), }),
expect.objectContaining({ expect.objectContaining({
key: 'road-bandit', key: 'road-bandit',
name: 'Road Bandit', name: 'Road Bandit',
monsterCategory: 'HUMANOID',
level: 2, level: 2,
maxHp: 75, maxHp: 75,
attack: 9, attack: 9,
armor: 5, armor: 5,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
abilities: { telegraph: { roundInterval: 3, damageMultiplier: 1.6 } },
}), }),
expect.objectContaining({ expect.objectContaining({
key: 'wild-road-dog', key: 'wild-road-dog',
name: 'Feral Road Hound', name: 'Feral Road Hound',
monsterCategory: 'BEAST',
level: 1, level: 1,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/wild-road-dog.png', artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png', iconPath: '/images/combat/icons/wild-road-dog-128.png',
abilities: {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
},
}), }),
expect.objectContaining({ expect.objectContaining({
key: 'charred-looter', key: 'charred-looter',
name: 'Charred Raider', name: 'Charred Raider',
monsterCategory: 'HUMANOID',
level: 2, level: 2,
silverMin: 0,
silverMax: 0,
artworkPath: '/images/monsters/charred-looter.png', artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png', iconPath: '/images/combat/icons/charred-looter-128.png',
// The rare encounter reuses the known telegraph on a tighter
// cadence rather than introducing a new subsystem (spec §4).
abilities: { telegraph: { roundInterval: 2, damageMultiplier: 1.6 } },
}), }),
]), ]),
); );
// Every monster carries an atmosphere line for its encounter card
// (spec §9).
for (const row of monsterRepository.rows) {
expect(typeof row.flavorText).toBe('string');
}
// No monster carries a currency range any more: a normal kill grants no
// Silver (spec §7).
for (const row of monsterRepository.rows) {
expect(row).not.toHaveProperty('silverMin');
expect(row).not.toHaveProperty('silverMax');
}
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith( expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID, monsterId: ASH_RAT_MONSTER_ID,
weight: 40, weight: 50,
encounterType: 'NORMAL',
}), }),
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: WILD_ROAD_DOG_MONSTER_ID, monsterId: WILD_ROAD_DOG_MONSTER_ID,
weight: 30, weight: 30,
encounterType: 'NORMAL',
}), }),
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID, monsterId: ROAD_BANDIT_MONSTER_ID,
weight: 20, weight: 18,
encounterType: 'NORMAL',
}), }),
// The rare find, at 2 out of 100 (spec §3).
expect.objectContaining({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: CHARRED_LOOTER_MONSTER_ID, monsterId: CHARRED_LOOTER_MONSTER_ID,
weight: 10, weight: 2,
encounterType: 'RARE',
}), }),
]), ]),
['locationId', 'monsterId'], ['locationId', 'monsterId'],
@@ -328,7 +378,8 @@ describe('seedVisibleVerticalSlice', () => {
localArtworkPath: '/images/backgrounds/Suedtor.png', localArtworkPath: '/images/backgrounds/Suedtor.png',
}), }),
); );
expect(southGate.localPointsOfInterest).toHaveLength(3); // Four since Slice 0.8 gave Borin a hotspot at the gate.
expect(southGate.localPointsOfInterest).toHaveLength(4);
// A transition location offers no hunt, so no HUNT hotspot may appear. // A transition location offers no hunt, so no HUNT hotspot may appear.
expect( expect(
(southGate.localPointsOfInterest as { type: string }[]).some( (southGate.localPointsOfInterest as { type: string }[]).some(
@@ -415,7 +466,7 @@ describe('seedVisibleVerticalSlice', () => {
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
expect(itemRepository.rows).toHaveLength(13); expect(itemRepository.rows).toHaveLength(15);
expect(itemRepository.rows).toEqual( expect(itemRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -431,35 +482,89 @@ describe('seedVisibleVerticalSlice', () => {
}), }),
expect.objectContaining({ expect.objectContaining({
key: 'ash-pelt', key: 'ash-pelt',
name: 'Ash Pelt', name: 'Ashen Pelt',
type: 'TRADE_GOOD', type: 'TRADE_GOOD',
equipmentSlot: null, equipmentSlot: null,
lootCategory: 'HIDE',
}),
expect.objectContaining({
key: 'tough-hide',
name: 'Tough Hide',
type: 'TRADE_GOOD',
equipmentSlot: null,
lootCategory: 'HIDE',
}), }),
expect.objectContaining({ expect.objectContaining({
key: 'bandit-insignia', key: 'bandit-insignia',
name: 'Bandit Insignia', name: 'Raider Insignia',
type: 'TROPHY', type: 'TROPHY',
equipmentSlot: null, equipmentSlot: null,
lootCategory: 'RAIDER_TROPHY',
}),
expect.objectContaining({
key: 'charred-raider-insignia',
name: 'Charred Raider Insignia',
type: 'TROPHY',
equipmentSlot: null,
lootCategory: 'RAIDER_TROPHY',
}), }),
]), ]),
); );
expect(lootTableRepository.rows).toHaveLength(2); // Equipment and consumables stay outside the carrying system entirely
expect(lootEntryRepository.rows).toHaveLength(6); // (0.7.5 §8), so they must not pick up a category by accident.
for (const row of itemRepository.rows) {
if (row.type === 'EQUIPMENT' || row.type === 'CONSUMABLE') {
expect(row.lootCategory).toBeNull();
}
}
// One table per monster now that each has its own guaranteed trade good.
expect(lootTableRepository.rows).toHaveLength(4);
expect(lootEntryRepository.rows).toHaveLength(12);
// Every Burned Road enemy guarantees exactly one trade good (spec §5).
const guaranteedTradeGoods: Array<[string, string]> = [
[ASH_RAT_LOOT_TABLE_ID, ITEM_IDS['ash-pelt']],
[WILD_ROAD_DOG_LOOT_TABLE_ID, ITEM_IDS['tough-hide']],
[ROAD_BANDIT_LOOT_TABLE_ID, ITEM_IDS['bandit-insignia']],
[CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS['charred-raider-insignia']],
];
for (const [lootTableId, itemDefinitionId] of guaranteedTradeGoods) {
expect(lootEntryRepository.rows).toEqual( expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
lootTableId: ASH_RAT_LOOT_TABLE_ID, lootTableId,
itemDefinitionId: ITEM_IDS['ash-pelt'], itemDefinitionId,
position: 1, dropChance: '1.0000',
dropChance: '0.6000', minQuantity: 1,
maxQuantity: 1,
enabled: true,
}), }),
]),
);
}
// Equipment stays a roll, so it can never be confused with the
// guaranteed good (spec §6).
expect(lootEntryRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['bandit-blade'], itemDefinitionId: ITEM_IDS['bandit-blade'],
position: 1, position: 1,
dropChance: '0.1800', dropChance: '0.1800',
}), }),
expect.objectContaining({
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['ash-boots'],
dropChance: '0.0800',
}),
expect.objectContaining({
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
itemDefinitionId: ITEM_IDS['reinforced-leather-jacket'],
dropChance: '0.1000',
}),
]), ]),
); );
@@ -476,14 +581,100 @@ describe('seedVisibleVerticalSlice', () => {
key: 'ash-rat', key: 'ash-rat',
lootTableId: ASH_RAT_LOOT_TABLE_ID, lootTableId: ASH_RAT_LOOT_TABLE_ID,
}), }),
expect.objectContaining({
key: 'wild-road-dog',
lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
}),
expect.objectContaining({ expect.objectContaining({
key: 'road-bandit', key: 'road-bandit',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}), }),
expect.objectContaining({
key: 'charred-looter',
lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
}),
]), ]),
); );
}); });
it('seeds both starter loot bags idempotently', async () => {
const lootBagDefinitionRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
lootBagDefinitionRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(lootBagDefinitionRepository.rows).toHaveLength(2);
expect(lootBagDefinitionRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: 'HIDE',
capacity: 5,
}),
expect.objectContaining({
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: 'RAIDER_TROPHY',
capacity: 5,
}),
]),
);
});
it('gives the demo character one active bag per category, idempotently', async () => {
const characterLootBagRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
characterLootBagRepository,
);
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
// Deliberate: 0.7.5 §7 leaves acquisition to the merchant slice, but with
// no merchant yet a bagless character could never empty a full bag. See
// the ASSUMPTION note in the seed.
expect(characterLootBagRepository.rows).toHaveLength(2);
expect(characterLootBagRepository.rows.map((row) => row.active)).toEqual([
true,
true,
]);
expect(
characterLootBagRepository.rows.map((row) => row.lootBagDefinitionId),
).toEqual([
'a0000000-0000-4000-8000-000000000001',
'a0000000-0000-4000-8000-000000000002',
]);
});
it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => { it('seeds the starting sword as a real, equipped CharacterItem idempotently', async () => {
const locationRepository = new InMemoryRepository(); const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository();
@@ -631,8 +822,10 @@ describe('seedVisibleVerticalSlice', () => {
expect(faction).toMatchObject({ name: 'Border Watch', enabled: true }); expect(faction).toMatchObject({ name: 'Border Watch', enabled: true });
}); });
it('seeds both turn-in definitions', async () => { it('seeds Borin with his shop and every trade-in rule', async () => {
const turnInDefinitionRepository = new InMemoryRepository(); const npcDefinitionRepository = new InMemoryRepository();
const npcShopRepository = new InMemoryRepository();
const exchangeRuleRepository = new InMemoryRepository();
const dataSource = createDataSource( const dataSource = createDataSource(
new InMemoryRepository(), new InMemoryRepository(),
new InMemoryRepository(), new InMemoryRepository(),
@@ -645,13 +838,70 @@ describe('seedVisibleVerticalSlice', () => {
new InMemoryRepository(), new InMemoryRepository(),
new InMemoryRepository(), new InMemoryRepository(),
new InMemoryRepository(), new InMemoryRepository(),
turnInDefinitionRepository, new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
npcDefinitionRepository,
new InMemoryRepository(),
npcShopRepository,
new InMemoryRepository(),
new InMemoryRepository(),
exchangeRuleRepository,
);
// Twice: seeds must be idempotent (NPC spec §32).
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(npcDefinitionRepository.rows).toHaveLength(1);
expect(npcDefinitionRepository.rows[0]).toMatchObject({
key: 'borin-quartermaster',
enabled: true,
});
// Placed in Graufurt via the location key, not a hardcoded id.
expect(npcDefinitionRepository.rows[0].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);
const silverByItem = new Map(
exchangeRuleRepository.rows.map((row: Row) => [
row.inputItemId,
row.silverReward,
]),
);
expect(
silverByItem.get(ITEM_IDS['charred-raider-insignia']),
).toBeGreaterThan(silverByItem.get(ITEM_IDS['ash-pelt']));
});
it('seeds the renown milestone the first trade-in completes', async () => {
const renownMilestoneRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
renownMilestoneRepository,
); );
await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource);
expect( // Non-repeatable, so routine trading cannot pump a power rank
turnInDefinitionRepository.rows.map((row: Row) => row.key).sort(), // (Slice 0.6.5 §2.2).
).toEqual(['ash-pelt-border-guard', 'bandit-insignia-border-guard']); expect(renownMilestoneRepository.rows).toHaveLength(1);
expect(renownMilestoneRepository.rows[0]).toMatchObject({
key: 'first-goods-returned',
repeatable: false,
enabled: true,
});
}); });
}); });

View File

@@ -11,13 +11,22 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum';
import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.entity';
import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTable } from '../../loot/entities/loot-table.entity';
import { CharacterLootBag } from '../../loot-bags/entities/character-loot-bag.entity';
import { LootBagDefinition } from '../../loot-bags/entities/loot-bag-definition.entity';
import { EncounterType } from '../../monsters/entities/encounter-type.enum'; import { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { MonsterCategory } from '../../monsters/monster-category.enum';
import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity'; import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { TurnInDefinition } from '../../turn-in/entities/turn-in-definition.entity'; import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.entity';
import { ExchangeRule } from '../../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../../exchanges/entities/npc-exchange-profile.entity';
import { DialogueNode } from '../../npcs/entities/dialogue-node.entity';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { import {
ITEM_DEFINITIONS, ITEM_DEFINITIONS,
LOOT_TABLES, LOOT_TABLES,
@@ -25,15 +34,30 @@ import {
} from './item-content'; } from './item-content';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID,
ITEM_IDS, ITEM_IDS,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { import {
BURNED_ROAD_LOCAL_CONTENT, BURNED_ROAD_LOCAL_CONTENT,
SOUTH_GATE_LOCAL_CONTENT, SOUTH_GATE_LOCAL_CONTENT,
} from './local-location.content'; } from './local-location.content';
import {
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
LOOT_BAG_DEFINITIONS,
} from './loot-bag-content';
import { REPUTATION_FACTIONS } from './reputation-content'; import { REPUTATION_FACTIONS } from './reputation-content';
import { TURN_IN_DEFINITIONS } from './turn-in-content'; import {
DIALOGUE_NODES,
EXCHANGE_RULES,
NPC_DEFINITIONS,
NPC_EXCHANGE_PROFILES,
NPC_SHOPS,
RENOWN_MILESTONES,
SHOP_OFFERS,
} from './npc-content';
import { import {
ASH_RAT_MONSTER_ID, ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
@@ -54,9 +78,20 @@ export async function seedVisibleVerticalSlice(
const itemRepository = dataSource.getRepository(ItemDefinition); const itemRepository = dataSource.getRepository(ItemDefinition);
const lootTableRepository = dataSource.getRepository(LootTable); const lootTableRepository = dataSource.getRepository(LootTable);
const lootEntryRepository = dataSource.getRepository(LootTableEntry); const lootEntryRepository = dataSource.getRepository(LootTableEntry);
const lootBagDefinitionRepository =
dataSource.getRepository(LootBagDefinition);
const reputationFactionRepository = const reputationFactionRepository =
dataSource.getRepository(ReputationFaction); dataSource.getRepository(ReputationFaction);
const turnInDefinitionRepository = dataSource.getRepository(TurnInDefinition); const renownMilestoneRepository = dataSource.getRepository(
RenownMilestoneDefinition,
);
const npcDefinitionRepository = dataSource.getRepository(NpcDefinition);
const dialogueNodeRepository = dataSource.getRepository(DialogueNode);
const npcShopRepository = dataSource.getRepository(NpcShop);
const shopOfferRepository = dataSource.getRepository(ShopOffer);
const npcExchangeProfileRepository =
dataSource.getRepository(NpcExchangeProfile);
const exchangeRuleRepository = dataSource.getRepository(ExchangeRule);
const locations = [ const locations = [
{ {
@@ -136,20 +171,27 @@ export async function seedVisibleVerticalSlice(
'lootTableId', 'lootTableId',
'itemDefinitionId', 'itemDefinitionId',
]); ]);
await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['key']);
await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']); await reputationFactionRepository.upsert(REPUTATION_FACTIONS, ['key']);
await turnInDefinitionRepository.upsert(TURN_IN_DEFINITIONS, ['key']); await renownMilestoneRepository.upsert(RENOWN_MILESTONES, ['key']);
// Burned Road roster (Playable Slice 0.7 V2 §2, §4). Each enemy has to be
// mechanically and economically distinct, so the differences live in
// `abilities` (what it does in a fight) and `lootTableId` (what it is worth)
// rather than in engine or UI branches.
const monsters = [ const monsters = [
{ {
id: ASH_RAT_MONSTER_ID, id: ASH_RAT_MONSTER_ID,
key: 'ash-rat', key: 'ash-rat',
name: 'Ash Rat', name: 'Ash Rat',
monsterCategory: MonsterCategory.BEAST,
level: 1, level: 1,
maxHp: 45, maxHp: 45,
attack: 5, attack: 5,
armor: 0, armor: 0,
silverMin: 0, flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
silverMax: 0, // Pure baseline combat: no telegraph, no status effect, short fight.
abilities: {},
artworkPath: '/images/monsters/ash-rat.png', artworkPath: '/images/monsters/ash-rat.png',
iconPath: '/images/combat/icons/ash-rat-128.png', iconPath: '/images/combat/icons/ash-rat-128.png',
lootTableId: ASH_RAT_LOOT_TABLE_ID, lootTableId: ASH_RAT_LOOT_TABLE_ID,
@@ -158,28 +200,39 @@ export async function seedVisibleVerticalSlice(
id: WILD_ROAD_DOG_MONSTER_ID, id: WILD_ROAD_DOG_MONSTER_ID,
key: 'wild-road-dog', key: 'wild-road-dog',
name: 'Feral Road Hound', name: 'Feral Road Hound',
monsterCategory: MonsterCategory.BEAST,
level: 1, level: 1,
maxHp: 55, maxHp: 55,
attack: 7, attack: 7,
armor: 0, armor: 0,
silverMin: 0, flavorText:
silverMax: 0, 'It stopped waiting for scraps a long time ago. Its bites do not close.',
// Introduces Bleeding: every third round its bite also opens a wound
// that ticks for two rounds. Windows of clean rounds in between keep
// the effect readable instead of permanent.
abilities: {
bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 },
},
artworkPath: '/images/monsters/wild-road-dog.png', artworkPath: '/images/monsters/wild-road-dog.png',
iconPath: '/images/combat/icons/wild-road-dog-128.png', iconPath: '/images/combat/icons/wild-road-dog-128.png',
// Shares the beast table: both are scorched road animals that leave a lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID,
// pelt behind. A table of its own waits for content that differs.
lootTableId: ASH_RAT_LOOT_TABLE_ID,
}, },
{ {
id: ROAD_BANDIT_MONSTER_ID, id: ROAD_BANDIT_MONSTER_ID,
key: 'road-bandit', key: 'road-bandit',
name: 'Road Bandit', name: 'Road Bandit',
monsterCategory: MonsterCategory.HUMANOID,
level: 2, level: 2,
maxHp: 75, maxHp: 75,
attack: 9, attack: 9,
armor: 5, armor: 5,
silverMin: 0, flavorText:
silverMax: 0, 'This one has done it before, and winds up the swing where you can see it.',
// Reinforces telegraphing: winds up a Heavy Strike every third round,
// which Shield Bash can interrupt and Defend can blunt.
abilities: {
telegraph: { roundInterval: 3, damageMultiplier: 1.6 },
},
artworkPath: '/images/monsters/road-bandit.png', artworkPath: '/images/monsters/road-bandit.png',
iconPath: '/images/combat/icons/road-bandit-128.png', iconPath: '/images/combat/icons/road-bandit-128.png',
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
@@ -188,16 +241,22 @@ export async function seedVisibleVerticalSlice(
id: CHARRED_LOOTER_MONSTER_ID, id: CHARRED_LOOTER_MONSTER_ID,
key: 'charred-looter', key: 'charred-looter',
name: 'Charred Raider', name: 'Charred Raider',
monsterCategory: MonsterCategory.HUMANOID,
level: 2, level: 2,
maxHp: 85, maxHp: 85,
attack: 11, attack: 11,
armor: 6, armor: 6,
silverMin: 0, flavorText:
silverMax: 0, 'Burned armor, a burned face, and no interest at all in your reasons.',
// The rare encounter: no new subsystem, just the known telegraph on a
// tighter cadence on top of higher stats. It is winding up almost
// constantly, so the player has to interrupt or brace.
abilities: {
telegraph: { roundInterval: 2, damageMultiplier: 1.6 },
},
artworkPath: '/images/monsters/charred-looter.png', artworkPath: '/images/monsters/charred-looter.png',
iconPath: '/images/combat/icons/charred-looter-128.png', iconPath: '/images/combat/icons/charred-looter-128.png',
// Shares the raider table: same gear, taken from the same caravans. lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID,
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
}, },
]; ];
@@ -217,22 +276,30 @@ export async function seedVisibleVerticalSlice(
monsterIds.set(key, existingMonster?.id ?? id); monsterIds.set(key, existingMonster?.id ?? id);
} }
// Weights read as "how often you meet this on the road". They also drive the // Weights read as "how often you meet this on the road" (spec §3). They also
// location's danger rating, which is computed from the weighted average of // drive the location's danger rating, which is computed from the weighted
// the pool rather than from its single worst entry. // average of the pool rather than from its single worst entry.
const encounterWeights: Readonly<Record<string, number>> = { //
'ash-rat': 40, // The Charred Raider is deliberately the odd one out at weight 2: it is a
'wild-road-dog': 30, // rare find, and `EncounterType.RARE` is what the hunt card reads to mark it
'road-bandit': 20, // as such.
'charred-looter': 10, const encounterPool: ReadonlyArray<{
}; key: string;
weight: number;
encounterType: EncounterType;
}> = [
{ key: 'ash-rat', weight: 50, encounterType: EncounterType.NORMAL },
{ key: 'wild-road-dog', weight: 30, encounterType: EncounterType.NORMAL },
{ key: 'road-bandit', weight: 18, encounterType: EncounterType.NORMAL },
{ key: 'charred-looter', weight: 2, encounterType: EncounterType.RARE },
];
await locationMonsterRepository.upsert( await locationMonsterRepository.upsert(
Object.entries(encounterWeights).map(([key, weight]) => ({ encounterPool.map(({ key, weight, encounterType }) => ({
locationId: burnedRoadId, locationId: burnedRoadId,
monsterId: monsterIds.get(key) as string, monsterId: monsterIds.get(key) as string,
weight, weight,
encounterType: EncounterType.NORMAL, encounterType,
enabled: true, enabled: true,
})), })),
['locationId', 'monsterId'], ['locationId', 'monsterId'],
@@ -294,4 +361,62 @@ export async function seedVisibleVerticalSlice(
characterItemId: startingSwordItemId, characterItemId: startingSwordItemId,
}); });
} }
// ASSUMPTION (Playable Slice 0.7.5 §7 leaves acquisition to the merchant
// and quest slices, so nothing in 0.7.5 hands out a bag).
//
// The demo character starts with both starter bags anyway. Without them the
// bagless default of 1 applies, and since the merchant that empties a bag
// only arrives in 0.8, the second kill of a hunt would leave its trade good
// behind forever -- the hunting loop would be unplayable between these two
// slices. Capacity 5 lets the fill-up actually be experienced.
//
// Smallest reversible choice: two seed rows, no acquisition mechanism.
// Delete them once the merchant sells bags.
const characterLootBagRepository = dataSource.getRepository(CharacterLootBag);
for (const lootBagDefinitionId of [
BASIC_HIDE_BAG_ID,
BASIC_TROPHY_POUCH_ID,
]) {
const existingBag = await characterLootBagRepository.findOneBy({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId,
});
if (!existingBag) {
await characterLootBagRepository.insert({
characterId: DEMO_CHARACTER_ID,
lootBagDefinitionId,
active: true,
});
}
}
// NPC content (NPC Specification V1 §32; Playable Slice 0.8).
//
// Seeded after the character so a fresh database has somewhere to stand and
// someone to trade with in one pass. Every link uses a stable business key
// rather than a generated id (spec §4), and every write is an upsert, so
// re-running the seed re-tunes prices without duplicating Borin.
for (const npc of NPC_DEFINITIONS) {
const locationId = locationIds.get(npc.locationKey);
if (!locationId) {
throw new Error(
`NPC ${npc.key} references unknown location ${npc.locationKey}.`,
);
}
const { locationKey, ...definition } = npc;
await npcDefinitionRepository.upsert({ ...definition, locationId }, [
'key',
]);
}
await dialogueNodeRepository.upsert(DIALOGUE_NODES, ['npcId', 'key']);
await npcShopRepository.upsert(NPC_SHOPS, ['key']);
await shopOfferRepository.upsert(SHOP_OFFERS, ['shopId', 'itemDefinitionId']);
await npcExchangeProfileRepository.upsert(NPC_EXCHANGE_PROFILES, ['key']);
await exchangeRuleRepository.upsert(EXCHANGE_RULES, [
'profileId',
'inputItemId',
]);
} }

View File

@@ -110,7 +110,10 @@ export class EquipmentService {
throw itemNotEquippable(); throw itemNotEquippable();
} }
const statsBeforeChange = await this.characterStats.calculate(character, manager); const statsBeforeChange = await this.characterStats.calculate(
character,
manager,
);
this.characterVitals.settle(character, statsBeforeChange.maxHp); this.characterVitals.settle(character, statsBeforeChange.maxHp);
await characters.save(character); await characters.save(character);

View File

@@ -0,0 +1,35 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsString,
Min,
ValidateNested,
} from 'class-validator';
export class ExchangeItemDto {
@IsString()
itemKey!: string;
@IsInt()
@Min(1)
quantity!: number;
}
export class ExchangeRequestDto {
/**
* Item keys and quantities only.
*
* Prices and rewards are never accepted from the client -- the server reads
* them from the exchange rules (slice §8, NPC spec §37.9). The upper bound
* keeps one request from turning into an unbounded row-by-row transaction.
*/
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => ExchangeItemDto)
items!: ExchangeItemDto[];
}

View File

@@ -0,0 +1,120 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type { GameCondition } from '../../conditions/game-condition.types';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
import { NpcExchangeProfile } from './npc-exchange-profile.entity';
/**
* What one accepted material is worth (NPC spec §17, Playable Slice 0.8 §4).
*
* This is the hinge of the 0.8 loop: the kill produces the object, and this
* row is where the object becomes economy and progression (slice §4). Values
* are balancing content, tuned here rather than in `ExchangeService`.
*
* Supersedes `TurnInDefinition` from Slice 0.6.5, which mapped an item to
* silver and reputation with no NPC, no renown and no conditions. Both alive
* at once would have been two competing ways to sell the same pelt at
* different prices -- exactly the second progression model slice 0.8 §6
* forbids.
*/
@Entity({ name: 'exchange_rules' })
@Index('IDX_exchange_rules_profile_item', ['profileId', 'inputItemId'], {
unique: true,
})
export class ExchangeRule {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'profile_id', type: 'uuid' })
profileId!: string;
@Column({ name: 'input_item_id', type: 'uuid' })
inputItemId!: string;
/**
* How many the rule trades in one step. A rule with `inputQuantity` 5 can
* only be redeemed in multiples of five, which is what makes spec §17's
* "5 x Aschenfell -> 18 silver" expressible as a batch rather than as five
* separate single-pelt payouts.
*/
@Column({ name: 'input_quantity', type: 'integer', default: 1 })
inputQuantity!: number;
/** Which faction's regional reputation this rule pays into (slice §6). */
@Column({ name: 'faction_id', type: 'uuid' })
factionId!: string;
@Column({ name: 'silver_reward', type: 'integer', default: 0 })
silverReward!: number;
@Column({ name: 'region_reputation_reward', type: 'integer', default: 0 })
regionReputationReward!: number;
/**
* World Renown, granted as a milestone rather than as points per pelt.
*
* Slice 0.8 §4 sketches `worldRenownPerUnit or batch rule`, and §9's example
* response shows a flat `worldRenown: 2`. A per-unit grant is not compatible
* with the Renown system that already exists: Slice 0.6.5 made Renown a
* 1-15 *rank* that reassigns baseHp/baseAttack from a fixed power curve on
* every change, and warns that "repeatedly killing weak monsters must not be
* an efficient way to increase Renown" (0.6.5 §2.2, §4, §5). Paying renown
* by the pelt would run a player from rank 1 to the rank-15 stat ceiling in
* a handful of trips and flatten the whole progression curve.
*
* So this names a `RenownMilestoneDefinition` key instead, which is the
* "batch rule" half of slice §4 and matches 0.6.5 §6 exactly -- it names
* "first meaningful trophy returned" as the Renown 2 milestone. Non-
* repeatable milestones fire once and are then silently skipped, so routine
* trading keeps paying silver and reputation without touching renown.
*
* This is also literally the field 0.6.5 §19 parked for later: it lists
* `firstTurnInMilestoneKey` as an optional addition with "do not add these
* unless actually needed". Slice 0.8 is when it is needed.
*
* Null means this rule grants no renown at all, which is the normal case.
*/
@Column({
name: 'renown_milestone_key',
type: 'varchar',
length: 100,
nullable: true,
})
renownMilestoneKey!: string | null;
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
conditions!: GameCondition[];
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcExchangeProfile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile!: NpcExchangeProfile;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'input_item_id' })
inputItem!: ItemDefinition;
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'faction_id' })
faction!: ReputationFaction;
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
/**
* The set of materials one NPC accepts (NPC spec §17).
*
* Kept apart from `NpcShop` on purpose: handing over five pelts for silver and
* reputation is not a purchase, and modelling it as a negative-price shop
* offer would smear two different systems together (spec §17, §37.7).
*/
@Entity({ name: 'npc_exchange_profiles' })
@Index('IDX_npc_exchange_profiles_key', ['key'], { unique: true })
@Index('IDX_npc_exchange_profiles_npc', ['npcId'])
export class NpcExchangeProfile {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
}

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { ExchangeRequestDto } from './dto/exchange.dto';
import {
ExchangeResultDto,
ExchangeService,
ExchangeViewDto,
} from './exchange.service';
/**
* Trade-in endpoints, addressed by merchant key (Playable Slice 0.8 §9).
*
* The character is taken from the session-stand-in, never from the request,
* so a caller cannot trade out of somebody else's inventory.
*/
@Controller('merchants/:merchantKey')
export class ExchangeController {
constructor(private readonly exchangeService: ExchangeService) {}
@Get('trade-in')
getTradeIn(
@Param('merchantKey') merchantKey: string,
): Promise<ExchangeViewDto> {
return this.exchangeService.getMerchantExchangeView(
DEMO_CHARACTER_ID,
merchantKey,
);
}
@Post('trade-in')
tradeIn(
@Param('merchantKey') merchantKey: string,
@Body() request: ExchangeRequestDto,
): Promise<ExchangeResultDto> {
return this.exchangeService.exchangeWithMerchant(
DEMO_CHARACTER_ID,
merchantKey,
request.items,
);
}
}

View File

@@ -0,0 +1,71 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type ExchangeErrorCode =
| 'EXCHANGE_NOT_FOUND'
| 'EXCHANGE_DISABLED'
| 'EXCHANGE_ITEM_NOT_ACCEPTED'
| 'EXCHANGE_INVALID_QUANTITY'
| 'EXCHANGE_INSUFFICIENT_QUANTITY'
| 'EXCHANGE_EMPTY_REQUEST';
export class ExchangeDomainError extends HttpException {
constructor(
public readonly code: ExchangeErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function exchangeNotFound(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This merchant does not trade in goods.',
);
}
export function exchangeDisabled(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_DISABLED',
HttpStatus.CONFLICT,
'This merchant is not trading right now.',
);
}
export function exchangeItemNotAccepted(itemKey: string): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_ITEM_NOT_ACCEPTED',
HttpStatus.CONFLICT,
`This merchant does not accept ${itemKey}.`,
);
}
export function exchangeInvalidQuantity(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive whole number.',
);
}
export function exchangeInsufficientQuantity(
itemKey: string,
): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_INSUFFICIENT_QUANTITY',
HttpStatus.CONFLICT,
`You are not carrying that many ${itemKey}.`,
);
}
export function exchangeEmptyRequest(): ExchangeDomainError {
return new ExchangeDomainError(
'EXCHANGE_EMPTY_REQUEST',
HttpStatus.BAD_REQUEST,
'Select at least one item to trade.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,577 @@
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import { LootCapacityService } from '../loot-bags/loot-capacity.service';
import { NpcService } from '../npcs/npc.service';
import { RenownService } from '../renown/renown.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import { ExchangeService } from './exchange.service';
const CHARACTER_ID = 'character-1';
const PROFILE_ID = 'profile-1';
const FACTION_ID = 'faction-1';
const PROFILE_KEY = 'borin-trade-in';
interface ItemRow {
id: string;
characterId: string;
itemDefinitionId: string;
quantity: number;
}
interface WorldFixture {
silver?: number;
renown?: number;
carried?: ItemRow[];
profileEnabled?: boolean;
milestoneAlreadyDone?: boolean;
ruleConditionsMet?: boolean;
}
class MilestoneAlreadyCompleted extends Error {
readonly code = 'RENOWN_MILESTONE_ALREADY_COMPLETED';
}
function rule(overrides: Partial<ExchangeRule> = {}): ExchangeRule {
return {
id: 'rule-pelt',
profileId: PROFILE_ID,
inputItemId: 'item-pelt',
inputQuantity: 1,
factionId: FACTION_ID,
silverReward: 5,
regionReputationReward: 2,
renownMilestoneKey: 'first-goods-returned',
conditions: [],
sortOrder: 1,
enabled: true,
inputItem: { id: 'item-pelt', key: 'ash-pelt', name: 'Ashen Pelt' },
...overrides,
} as unknown as ExchangeRule;
}
function createWorld(fixture: WorldFixture = {}) {
const character = {
id: CHARACTER_ID,
silver: fixture.silver ?? 0,
renown: fixture.renown ?? 1,
} as Character;
const items: ItemRow[] = (fixture.carried ?? []).map((row) => ({ ...row }));
const removed: ItemRow[] = [];
const rules = [
rule(),
rule({
id: 'rule-insignia',
inputItemId: 'item-insignia',
silverReward: 30,
regionReputationReward: 12,
inputItem: {
id: 'item-insignia',
key: 'charred-raider-insignia',
name: 'Charred Raider Insignia',
},
} as Partial<ExchangeRule>),
rule({
id: 'rule-batch',
inputItemId: 'item-batch',
inputQuantity: 5,
silverReward: 40,
regionReputationReward: 10,
renownMilestoneKey: null,
inputItem: { id: 'item-batch', key: 'tough-hide', name: 'Tough Hide' },
} as Partial<ExchangeRule>),
];
const repositories = (entity: unknown) => {
if (entity === Character) {
return {
findOne: () => Promise.resolve(character),
findOneBy: () => Promise.resolve(character),
findOneByOrFail: () => Promise.resolve(character),
save: (row: Character) => Promise.resolve(row),
};
}
if (entity === NpcExchangeProfile) {
return {
findOne: () =>
Promise.resolve({
id: PROFILE_ID,
key: PROFILE_KEY,
npcId: 'npc-1',
name: 'Border Watch Trade-In',
enabled: fixture.profileEnabled ?? true,
npc: { key: 'borin-quartermaster' },
}),
findOneBy: () =>
Promise.resolve({
id: PROFILE_ID,
key: PROFILE_KEY,
npcId: 'npc-1',
name: 'Border Watch Trade-In',
enabled: fixture.profileEnabled ?? true,
}),
};
}
if (entity === ExchangeRule) {
return {
find: () =>
Promise.resolve(
rules.map((entry) => ({
...entry,
faction: { key: 'border-guard', name: 'Border Watch' },
})),
),
};
}
if (entity === CharacterItem) {
return {
find: () => Promise.resolve(items),
findOne: (options: { where: { itemDefinitionId: string } }) =>
Promise.resolve(
items.find(
(row) => row.itemDefinitionId === options.where.itemDefinitionId,
) ?? null,
),
remove: async (row: ItemRow) => {
const index = items.findIndex((candidate) => candidate.id === row.id);
if (index >= 0) {
removed.push(items.splice(index, 1)[0]);
}
},
save: (row: ItemRow) => Promise.resolve(row),
};
}
if (entity === ReputationFaction) {
return {
findOneBy: () =>
Promise.resolve({ id: FACTION_ID, key: 'border-guard' }),
};
}
throw new Error('Unexpected repository');
};
const manager = { getRepository: repositories } as unknown as EntityManager;
const dataSource = {
getRepository: repositories,
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
run(manager),
} as unknown as DataSource;
const reputationGrants: Array<{ factionKey: string; amount: number }> = [];
const reputation = {
grantReputation: jest.fn(
async (_characterId: string, factionKey: string, amount: number) => {
reputationGrants.push({ factionKey, amount });
return {
factionKey,
previousReputation: 0,
newReputation: amount,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
};
},
),
} as unknown as ReputationService;
const milestoneCalls: string[] = [];
const renown = {
completeMilestone: jest.fn(async (_characterId: string, key: string) => {
milestoneCalls.push(key);
if (fixture.milestoneAlreadyDone) {
throw new MilestoneAlreadyCompleted();
}
character.renown += 1;
return {
milestoneKey: key,
previousRenown: character.renown - 1,
newRenown: character.renown,
renownGranted: true,
};
}),
} as unknown as RenownService;
const lootCapacity = {
getCapacities: jest.fn(() =>
Promise.resolve([
{ category: 'HIDE', current: 0, capacity: 5, bag: null },
]),
),
} as unknown as LootCapacityService;
const conditions = {
evaluate: jest.fn(() => Promise.resolve(fixture.ruleConditionsMet ?? true)),
} as unknown as GameConditionService;
const npcs = {
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
} as unknown as NpcService;
const service = new ExchangeService(
dataSource,
reputation,
renown,
lootCapacity,
conditions,
npcs,
);
return {
service,
character,
items,
removed,
reputationGrants,
milestoneCalls,
reputation,
};
}
describe('ExchangeService', () => {
it('pays Silver and regional reputation from content, not from the request', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 5,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 5 },
]);
// 5 pelts at 5 silver and 2 reputation each.
expect(result.rewards.silver).toBe(25);
expect(result.rewards.regionalReputation).toBe(10);
expect(world.character.silver).toBe(25);
expect(world.reputationGrants).toEqual([
{ factionKey: 'border-guard', amount: 10 },
]);
});
it('removes exactly the traded quantity and leaves the rest', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 5,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 2 },
]);
expect(world.items[0].quantity).toBe(3);
});
it('deletes the stack when the last one is traded, freeing bag capacity', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 3,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
]);
// Capacity is derived from owned items, so removing the stack is what
// frees the bag (slice §11).
expect(world.items).toHaveLength(0);
expect(world.removed).toHaveLength(1);
});
it('rejects a quantity the character does not carry', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 2,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
expect(world.character.silver).toBe(0);
expect(world.items[0].quantity).toBe(2);
});
it('rejects an item this merchant does not accept', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-sword',
quantity: 1,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'worn-short-sword', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
});
it('cannot be tricked into overdrawing a stack by repeating one item', async () => {
// Each line would individually pass against an untouched stack of three.
// Folding the request together first is what stops six pelts leaving a
// three-pelt stack.
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 3,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 3 },
{ itemKey: 'ash-pelt', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INSUFFICIENT_QUANTITY' });
expect(world.character.silver).toBe(0);
expect(world.items[0].quantity).toBe(3);
});
it('trades several different goods in one handover', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 2,
},
{
id: 'ci-2',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-insignia',
quantity: 1,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 2 },
{ itemKey: 'charred-raider-insignia', quantity: 1 },
]);
expect(result.rewards.silver).toBe(2 * 5 + 30);
expect(result.consumed).toHaveLength(2);
});
it('only trades a batch rule in whole steps', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-batch',
quantity: 7,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'tough-hide', quantity: 3 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'tough-hide', quantity: 5 },
]);
expect(result.rewards.silver).toBe(40);
});
it('grants the renown milestone on the first trade', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]);
expect(result.rewards.worldRenown).toBe(1);
expect(result.renownMilestonesCompleted).toEqual(['first-goods-returned']);
expect(result.balances.worldRenown).toBe(2);
});
it('keeps paying Silver once the renown milestone is spent', async () => {
// The second trade onward: the milestone is done, which is normal rather
// than an error. Silver and reputation must still land.
const world = createWorld({
milestoneAlreadyDone: true,
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 4,
},
],
});
const result = await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 4 },
]);
expect(result.rewards.worldRenown).toBe(0);
expect(result.renownMilestonesCompleted).toEqual([]);
expect(result.rewards.silver).toBe(20);
expect(result.balances.worldRenown).toBe(1);
});
it('claims one milestone once even when several goods name it', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
{
id: 'ci-2',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-insignia',
quantity: 1,
},
],
});
await world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
{ itemKey: 'charred-raider-insignia', quantity: 1 },
]);
expect(world.milestoneCalls).toEqual(['first-goods-returned']);
});
it('refuses an empty request rather than committing a no-op trade', async () => {
const world = createWorld();
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, []),
).rejects.toMatchObject({ code: 'EXCHANGE_EMPTY_REQUEST' });
});
it('refuses a zero or fractional quantity', async () => {
const world = createWorld();
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 0 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1.5 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_INVALID_QUANTITY' });
});
it('refuses to trade into a disabled profile', async () => {
const world = createWorld({ profileEnabled: false });
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_DISABLED' });
});
it('refuses a rule whose conditions are not met, server-side', async () => {
const world = createWorld({
ruleConditionsMet: false,
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 1,
},
],
});
await expect(
world.service.exchange(CHARACTER_ID, PROFILE_KEY, [
{ itemKey: 'ash-pelt', quantity: 1 },
]),
).rejects.toMatchObject({ code: 'EXCHANGE_ITEM_NOT_ACCEPTED' });
});
it('shows what is carried and what it is worth', async () => {
const world = createWorld({
carried: [
{
id: 'ci-1',
characterId: CHARACTER_ID,
itemDefinitionId: 'item-pelt',
quantity: 4,
},
],
});
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
const pelt = view.offers.find((offer) => offer.itemKey === 'ash-pelt');
expect(pelt).toMatchObject({ quantityCarried: 4, silverPerStep: 5 });
// Goods the character is not carrying still appear, so the player can see
// what this merchant would take.
expect(
view.offers.find((offer) => offer.itemKey === 'tough-hide'),
).toMatchObject({ quantityCarried: 0 });
});
it('hides a locked rule from the view entirely', async () => {
const world = createWorld({ ruleConditionsMet: false });
const view = await world.service.getExchangeView(CHARACTER_ID, PROFILE_KEY);
expect(view.offers).toHaveLength(0);
});
});

View File

@@ -0,0 +1,506 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager, In } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import {
LootCapacityDto,
LootCapacityService,
} from '../loot-bags/loot-capacity.service';
import { NpcService } from '../npcs/npc.service';
import { RenownService } from '../renown/renown.service';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import {
characterNotFound,
exchangeDisabled,
exchangeEmptyRequest,
exchangeInsufficientQuantity,
exchangeInvalidQuantity,
exchangeItemNotAccepted,
exchangeNotFound,
} from './exchange.errors';
export interface ExchangeOfferDto {
itemKey: string;
itemName: string;
iconPath: string;
/** How many the character is carrying right now. */
quantityCarried: number;
/** Smallest tradeable step. Quantities must be a multiple of this. */
inputQuantity: number;
silverPerStep: number;
reputationPerStep: number;
factionKey: string;
factionName: string;
/** The renown milestone this rule can still award, if any. */
renownMilestoneKey: string | null;
}
export interface ExchangeViewDto {
profileKey: string;
profileName: string;
npcKey: string;
offers: ExchangeOfferDto[];
capacities: LootCapacityDto[];
}
export interface ExchangeRequestItem {
itemKey: string;
quantity: number;
}
export interface ExchangeConsumedDto {
itemKey: string;
itemName: string;
quantity: number;
}
export interface ExchangeResultDto {
profileKey: string;
consumed: ExchangeConsumedDto[];
rewards: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
balances: {
silver: number;
regionalReputation: number;
worldRenown: number;
};
/** Ranks crossed by this trade, so the UI can call them out. */
reputationRankChanged: boolean;
newReputationRank: string | null;
/** Milestone keys this trade completed. Empty on every later trade. */
renownMilestonesCompleted: string[];
capacities: LootCapacityDto[];
}
/**
* Turns carried materials into Silver, regional reputation and World Renown
* (NPC spec §17, §30; Playable Slice 0.8 §4, §8).
*
* This is the only place a normal hunt becomes progression: kills grant no
* money or reputation at all (slice 0.7 §7), so everything the player earns
* passes through here.
*
* Every reward is computed from persisted content. The request carries item
* keys and quantities and nothing else -- never a price, never a reward
* (spec §37.9).
*/
@Injectable()
export class ExchangeService {
constructor(
private readonly dataSource: DataSource,
private readonly reputation: ReputationService,
private readonly renown: RenownService,
private readonly lootCapacity: LootCapacityService,
private readonly conditions: GameConditionService,
private readonly npcs: NpcService,
) {}
/**
* The trade-in view for a merchant, addressed by NPC key (slice §9).
*
* Reachability is checked first: the character must be standing where the
* merchant is, so a client cannot sell into Graufurt from the Burned Road.
*/
async getMerchantExchangeView(
characterId: string,
merchantKey: string,
): Promise<ExchangeViewDto> {
const profileKey = await this.resolveMerchantProfileKey(
characterId,
merchantKey,
);
return this.getExchangeView(characterId, profileKey);
}
/** Trades goods in with a merchant addressed by NPC key (slice §9). */
async exchangeWithMerchant(
characterId: string,
merchantKey: string,
requested: ExchangeRequestItem[],
): Promise<ExchangeResultDto> {
const profileKey = await this.resolveMerchantProfileKey(
characterId,
merchantKey,
);
return this.exchange(characterId, profileKey, requested);
}
private async resolveMerchantProfileKey(
characterId: string,
merchantKey: string,
): Promise<string> {
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
const profile = await this.dataSource
.getRepository(NpcExchangeProfile)
.findOneBy({ npcId: npc.id, enabled: true });
if (!profile) {
throw exchangeNotFound();
}
return profile.key;
}
/** What this merchant will take, and what the character is carrying. */
async getExchangeView(
characterId: string,
profileKey: string,
): Promise<ExchangeViewDto> {
const profile = await this.dataSource
.getRepository(NpcExchangeProfile)
.findOne({ where: { key: profileKey }, relations: { npc: true } });
if (!profile) {
throw exchangeNotFound();
}
if (!profile.enabled) {
throw exchangeDisabled();
}
const rules = await this.dataSource.getRepository(ExchangeRule).find({
where: { profileId: profile.id, enabled: true },
relations: { inputItem: true, faction: true },
order: { sortOrder: 'ASC' },
});
const carried = await this.loadCarriedQuantities(
characterId,
rules.map((rule) => rule.inputItemId),
this.dataSource,
);
const offers: ExchangeOfferDto[] = [];
for (const rule of rules) {
const unlocked = await this.conditions.evaluate(
{ characterId, npcId: profile.npcId },
rule.conditions,
);
if (!unlocked) {
continue;
}
offers.push({
itemKey: rule.inputItem.key,
itemName: rule.inputItem.name,
iconPath: rule.inputItem.iconPath,
quantityCarried: carried.get(rule.inputItemId) ?? 0,
inputQuantity: rule.inputQuantity,
silverPerStep: rule.silverReward,
reputationPerStep: rule.regionReputationReward,
factionKey: rule.faction.key,
factionName: rule.faction.name,
renownMilestoneKey: rule.renownMilestoneKey,
});
}
return {
profileKey: profile.key,
profileName: profile.name,
npcKey: profile.npc.key,
offers,
capacities: await this.lootCapacity.getCapacities(characterId),
};
}
/**
* Hands goods over and pays out, all or nothing (slice §8).
*
* The whole thing runs in one transaction: goods are removed, Silver
* credited, reputation granted and any renown milestone completed together,
* so a failure anywhere leaves the character exactly as they were and the
* same pelt can never be sold twice.
*/
async exchange(
characterId: string,
profileKey: string,
requested: ExchangeRequestItem[],
): Promise<ExchangeResultDto> {
const merged = this.mergeRequest(requested);
return this.dataSource.transaction(async (manager) => {
const character = await manager.getRepository(Character).findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const profile = await manager
.getRepository(NpcExchangeProfile)
.findOneBy({ key: profileKey });
if (!profile) {
throw exchangeNotFound();
}
if (!profile.enabled) {
throw exchangeDisabled();
}
const rules = await manager.getRepository(ExchangeRule).find({
where: { profileId: profile.id, enabled: true },
relations: { inputItem: true },
});
const rulesByItemKey = new Map(
rules.map((rule) => [rule.inputItem.key, rule]),
);
let silverGranted = 0;
const reputationByFaction = new Map<string, number>();
const milestoneKeys: string[] = [];
const consumed: ExchangeConsumedDto[] = [];
for (const [itemKey, quantity] of merged) {
const rule = rulesByItemKey.get(itemKey);
if (!rule) {
throw exchangeItemNotAccepted(itemKey);
}
const unlocked = await this.conditions.evaluate(
{ characterId, npcId: profile.npcId },
rule.conditions,
manager,
);
if (!unlocked) {
throw exchangeItemNotAccepted(itemKey);
}
// A batch rule only trades in whole steps: five pelts for a fixed
// payout cannot be redeemed three at a time.
if (quantity % rule.inputQuantity !== 0) {
throw exchangeInvalidQuantity();
}
await this.consumeItems(manager, characterId, rule, quantity, itemKey);
const steps = quantity / rule.inputQuantity;
silverGranted += rule.silverReward * steps;
reputationByFaction.set(
rule.factionId,
(reputationByFaction.get(rule.factionId) ?? 0) +
rule.regionReputationReward * steps,
);
if (rule.renownMilestoneKey) {
milestoneKeys.push(rule.renownMilestoneKey);
}
consumed.push({
itemKey,
itemName: rule.inputItem.name,
quantity,
});
}
character.silver += silverGranted;
await manager.getRepository(Character).save(character);
const reputationResult = await this.grantReputation(
manager,
characterId,
reputationByFaction,
);
const renownResult = await this.grantRenown(
manager,
characterId,
milestoneKeys,
);
// Re-read: RenownService rewrites base stats when a milestone lands, so
// the row loaded at the top of the transaction is already stale.
const settled = await manager
.getRepository(Character)
.findOneByOrFail({ id: characterId });
return {
profileKey,
consumed,
rewards: {
silver: silverGranted,
regionalReputation: reputationResult.granted,
worldRenown: renownResult.granted,
},
balances: {
silver: settled.silver,
regionalReputation: reputationResult.balance,
worldRenown: settled.renown,
},
reputationRankChanged: reputationResult.rankChanged,
newReputationRank: reputationResult.newRank,
renownMilestonesCompleted: renownResult.completed,
capacities: await this.lootCapacity.getCapacities(characterId, manager),
};
});
}
/**
* Folds a request down to one entry per item.
*
* Without this, a client could send the same key twice and have each line
* pass the ownership check against the same untouched stack -- trading three
* pelts twice while carrying three.
*/
private mergeRequest(requested: ExchangeRequestItem[]): Map<string, number> {
if (!requested || requested.length === 0) {
throw exchangeEmptyRequest();
}
const merged = new Map<string, number>();
for (const entry of requested) {
if (!Number.isInteger(entry.quantity) || entry.quantity <= 0) {
throw exchangeInvalidQuantity();
}
merged.set(
entry.itemKey,
(merged.get(entry.itemKey) ?? 0) + entry.quantity,
);
}
return merged;
}
/** Removes exactly `quantity`, deleting the stack when it empties. */
private async consumeItems(
manager: EntityManager,
characterId: string,
rule: ExchangeRule,
quantity: number,
itemKey: string,
): Promise<void> {
const characterItems = manager.getRepository(CharacterItem);
const owned = await characterItems.findOne({
where: { characterId, itemDefinitionId: rule.inputItemId },
lock: { mode: 'pessimistic_write' },
});
if (!owned || owned.quantity < quantity) {
throw exchangeInsufficientQuantity(itemKey);
}
if (owned.quantity === quantity) {
await characterItems.remove(owned);
return;
}
owned.quantity -= quantity;
await characterItems.save(owned);
}
private async grantReputation(
manager: EntityManager,
characterId: string,
amountByFaction: Map<string, number>,
): Promise<{
granted: number;
balance: number;
rankChanged: boolean;
newRank: string | null;
}> {
let granted = 0;
let balance = 0;
let rankChanged = false;
let newRank: string | null = null;
for (const [factionId, amount] of amountByFaction) {
if (amount <= 0) {
continue;
}
const faction = await manager
.getRepository(ReputationFaction)
.findOneBy({ id: factionId });
if (!faction) {
throw exchangeNotFound();
}
const result = await this.reputation.grantReputation(
characterId,
faction.key,
amount,
manager,
);
granted += amount;
// Slice 0.8 reports a single regional reputation figure. Every seeded
// rule pays the same region, so this is that region's balance; if a
// profile ever spans factions, the last one wins and the response shape
// needs to grow into a list.
balance = result.newReputation;
rankChanged = rankChanged || result.rankChanged;
newRank = result.rankChanged ? result.newRank : newRank;
}
return { granted, balance, rankChanged, newRank };
}
/**
* Completes any renown milestones this trade earned.
*
* A milestone that is already done is not an error -- it is the normal case
* from the second trade onward -- so `RenownService`'s "already completed"
* rejection is swallowed rather than allowed to fail the trade.
*/
private async grantRenown(
manager: EntityManager,
characterId: string,
milestoneKeys: string[],
): Promise<{ granted: number; completed: string[] }> {
let granted = 0;
const completed: string[] = [];
for (const key of new Set(milestoneKeys)) {
try {
const result = await this.renown.completeMilestone(
characterId,
key,
manager,
);
if (result.renownGranted) {
granted += result.newRenown - result.previousRenown;
completed.push(key);
}
} catch (error) {
if (!this.isAlreadyCompleted(error)) {
throw error;
}
}
}
return { granted, completed };
}
private isAlreadyCompleted(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'RENOWN_MILESTONE_ALREADY_COMPLETED'
);
}
private async loadCarriedQuantities(
characterId: string,
itemDefinitionIds: string[],
scope: Pick<DataSource, 'getRepository'>,
): Promise<Map<string, number>> {
if (itemDefinitionIds.length === 0) {
return new Map();
}
const owned = await scope.getRepository(CharacterItem).find({
where: { characterId, itemDefinitionId: In(itemDefinitionIds) },
});
const totals = new Map<string, number>();
for (const item of owned) {
totals.set(
item.itemDefinitionId,
(totals.get(item.itemDefinitionId) ?? 0) + item.quantity,
);
}
return totals;
}
}

View File

@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module';
import { CharacterItem } from '../items/entities/character-item.entity';
import { LootBagsModule } from '../loot-bags/loot-bags.module';
import { NpcsModule } from '../npcs/npcs.module';
import { RenownModule } from '../renown/renown.module';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationModule } from '../reputation/reputation.module';
import { ExchangeRule } from './entities/exchange-rule.entity';
import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity';
import { ExchangeController } from './exchange.controller';
import { ExchangeService } from './exchange.service';
/**
* Materials in, progression out (NPC spec §17; Playable Slice 0.8).
*
* Pulls in reputation and renown rather than reimplementing either, so the
* trade-in feeds the progression systems that already exist instead of
* inventing a competing one (slice §6).
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
ExchangeRule,
NpcExchangeProfile,
ReputationFaction,
]),
ConditionsModule,
LootBagsModule,
NpcsModule,
RenownModule,
ReputationModule,
],
controllers: [ExchangeController],
providers: [ExchangeService],
exports: [ExchangeService],
})
export class ExchangesModule {}

View File

@@ -20,8 +20,12 @@ const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002'; const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Road Bandit
const MONSTER_C_ID = '30000000-0000-4000-8000-000000000003'; // Feral Road Hound
const MONSTER_D_ID = '30000000-0000-4000-8000-000000000004'; // Charred Raider
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001'; const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002'; const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
const LOCATION_MONSTER_C_ID = '40000000-0000-4000-8000-000000000003';
const LOCATION_MONSTER_D_ID = '40000000-0000-4000-8000-000000000004';
interface FakeState { interface FakeState {
characters: Character[]; characters: Character[];
@@ -259,8 +263,8 @@ function monsterDefinition(
maxHp: 20, maxHp: 20,
attack: 3, attack: 3,
armor: 0, armor: 0,
silverMin: 1, flavorText: null,
silverMax: 3, abilities: {},
artworkPath: `/assets/monsters/${key}.webp`, artworkPath: `/assets/monsters/${key}.webp`,
createdAt: new Date('2026-08-18T09:00:00.000Z'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
@@ -274,18 +278,63 @@ function locationMonster(
monster: MonsterDefinition, monster: MonsterDefinition,
weight: number, weight: number,
enabled = true, enabled = true,
encounterType: EncounterType = EncounterType.NORMAL,
): LocationMonster { ): LocationMonster {
return { return {
id, id,
locationId, locationId,
monsterId: monster.id, monsterId: monster.id,
weight, weight,
encounterType: EncounterType.NORMAL, encounterType,
enabled, enabled,
monster, monster,
} as LocationMonster; } as LocationMonster;
} }
/**
* The real Burned Road pool (spec §3): four distinct monsters, the Charred
* Raider rare at weight 2 out of 100.
*/
function burnedRoadPool(): LocationMonster[] {
return [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat'),
50,
),
locationMonster(
LOCATION_MONSTER_C_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_C_ID, 'wild-road-dog', 'Feral Road Hound'),
30,
),
locationMonster(
LOCATION_MONSTER_B_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_B_ID, 'road-bandit', 'Road Bandit'),
18,
),
locationMonster(
LOCATION_MONSTER_D_ID,
HUNTING_LOCATION_ID,
monsterDefinition(MONSTER_D_ID, 'charred-looter', 'Charred Raider'),
2,
true,
EncounterType.RARE,
),
];
}
/** Puts the character on the hunting ground with the given encounter pool. */
function stateAtHuntingGround(pool: LocationMonster[]): FakeState {
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = pool;
return state;
}
function createState(): FakeState { function createState(): FakeState {
return { return {
characters: [character(safeLocation())], characters: [character(safeLocation())],
@@ -364,23 +413,7 @@ describe('HuntingService', () => {
}); });
it('starts a valid hunt with exactly three saved encounters', async () => { it('starts a valid hunt with exactly three saved encounters', async () => {
const monsterA = monsterDefinition( const state = stateAtHuntingGround(burnedRoadPool());
MONSTER_A_ID,
'aschenratte',
'Ash Rat',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({ const { dataSource, service } = createService({
state, state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]), randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
@@ -444,52 +477,109 @@ describe('HuntingService', () => {
}); });
it('picks monsters deterministically from canned RandomSource rolls', async () => { it('picks monsters deterministically from canned RandomSource rolls', async () => {
const monsterA = monsterDefinition( const state = stateAtHuntingGround(burnedRoadPool());
MONSTER_A_ID, // Weights 50/30/18/2. Each pick is removed from the pool before the next
'aschenratte', // roll, so the totals shrink: 0.1*100=10 -> Ash Rat (<50); the hound and
'Ash Rat', // the bandit remain in front of the rare, so 0.1*50=5 -> Feral Road Hound
); // and 0.1*20=2 -> Road Bandit.
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
const { dataSource, service } = createService({ const { dataSource, service } = createService({
state, state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]), randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
}); });
const result = await service.startHunt(CHARACTER_ID); const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((e) => e.monster.key)).toEqual([ expect(result.encounters.map((e) => e.monster.key)).toEqual([
'aschenratte', 'ash-rat',
'strassenraeuber', 'wild-road-dog',
'aschenratte', 'road-bandit',
]); ]);
const persisted = [...dataSource.state.huntEncounters].sort( const persisted = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position, (a, b) => a.position - b.position,
); );
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([ expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
MONSTER_A_ID, MONSTER_A_ID,
MONSTER_C_ID,
MONSTER_B_ID, MONSTER_B_ID,
MONSTER_A_ID,
]); ]);
}); });
it('supersedes the previous active hunt when a new hunt is started', async () => { it('rolls the rare Charred Raider from a canned roll inside its 2% band', async () => {
const monsterA = monsterDefinition( const state = stateAtHuntingGround(burnedRoadPool());
MONSTER_A_ID, // 0.99*100 = 99, past the 98 the three common entries cover, so the last
'aschenratte', // 2 points of weight -- the rare -- take the first card.
'Ash Rat', const { service } = createService({
state,
randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters[0].monster.key).toBe('charred-looter');
expect(result.encounters[0].encounterType).toBe(EncounterType.RARE);
// The common entries stay NORMAL, so the card only marks the real find.
expect(result.encounters.slice(1).map((e) => e.encounterType)).toEqual([
EncounterType.NORMAL,
EncounterType.NORMAL,
]);
});
it('never puts the same monster on two cards of one hunt', async () => {
const state = stateAtHuntingGround(burnedRoadPool());
// Every roll aims at the lowest band, which without removal would return
// the Ash Rat three times over.
const { service } = createService({
state,
randomSource: fakeRandomSource([0, 0, 0]),
});
const result = await service.startHunt(CHARACTER_ID);
const keys = result.encounters.map((e) => e.monster.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('returns fewer encounters when the pool has fewer distinct monsters', async () => {
const state = stateAtHuntingGround(burnedRoadPool().slice(0, 2));
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
// "2-3 distinct encounter records where possible" (spec §3): a
// two-monster pool yields two cards rather than repeating one.
expect(result.encounters).toHaveLength(2);
expect(dataSource.state.huntEncounters).toHaveLength(2);
});
it('passes the monster flavor text through to the encounter', async () => {
const monsterA = monsterDefinition(MONSTER_A_ID, 'ash-rat', 'Ash Rat', {
flavorText: 'Scrawny and quick, it feeds on whatever the fires left.',
});
const state = stateAtHuntingGround([
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
]);
const { service } = createService({
state,
randomSource: fakeRandomSource([0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters[0].monster.flavorText).toBe(
'Scrawny and quick, it feeds on whatever the fires left.',
); );
});
it('supersedes the previous active hunt when a new hunt is started', async () => {
const monsterA = monsterDefinition(MONSTER_A_ID, 'aschenratte', 'Ash Rat');
const state = createState(); const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID; state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation(); state.characters[0].currentLocation = huntingLocation();
@@ -521,26 +611,10 @@ describe('HuntingService', () => {
}); });
it('gives each encounter its own id matching the monster rolled for that slot', async () => { it('gives each encounter its own id matching the monster rolled for that slot', async () => {
const monsterA = monsterDefinition( const state = stateAtHuntingGround(burnedRoadPool());
MONSTER_A_ID,
'aschenratte',
'Ash Rat',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Road Bandit',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({ const { dataSource, service } = createService({
state, state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]), randomSource: fakeRandomSource([0.99, 0.1, 0.1]),
}); });
await service.startHunt(CHARACTER_ID); await service.startHunt(CHARACTER_ID);
@@ -550,9 +624,9 @@ describe('HuntingService', () => {
); );
const ids = encounters.map((e) => e.id); const ids = encounters.map((e) => e.id);
expect(new Set(ids).size).toBe(3); expect(new Set(ids).size).toBe(3);
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID); expect(encounters[0].monsterDefinitionId).toBe(MONSTER_D_ID);
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID); expect(encounters[1].monsterDefinitionId).toBe(MONSTER_A_ID);
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID); expect(encounters[2].monsterDefinitionId).toBe(MONSTER_C_ID);
}); });
it('computes a danger rating per encounter from the real monster stats', async () => { it('computes a danger rating per encounter from the real monster stats', async () => {
@@ -592,22 +666,7 @@ describe('HuntingService', () => {
}); });
it('marks every freshly rolled encounter as AVAILABLE', async () => { it('marks every freshly rolled encounter as AVAILABLE', async () => {
const monsterA = monsterDefinition( const state = stateAtHuntingGround(burnedRoadPool());
MONSTER_A_ID,
'aschenratte',
'Ash Rat',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({ const { dataSource, service } = createService({
state, state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]), randomSource: fakeRandomSource([0.1, 0.1, 0.1]),

View File

@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { EncounterType } from '../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import type { LocationSummary } from '../travel/travel.service'; import type { LocationSummary } from '../travel/travel.service';
@@ -25,6 +26,8 @@ export interface MonsterSummary {
name: string; name: string;
level: number; level: number;
artworkPath: string; artworkPath: string;
/** Short atmosphere line for the encounter card; null when unauthored. */
flavorText: string | null;
} }
export interface HuntEncounterDto { export interface HuntEncounterDto {
@@ -32,6 +35,8 @@ export interface HuntEncounterDto {
monster: MonsterSummary; monster: MonsterSummary;
dangerRating: DangerRating; dangerRating: DangerRating;
status: HuntEncounterStatus; status: HuntEncounterStatus;
/** Lets the card mark a rare find without knowing any monster keys. */
encounterType: EncounterType;
} }
export interface HuntResultDto { export interface HuntResultDto {
@@ -40,7 +45,9 @@ export interface HuntResultDto {
encounters: HuntEncounterDto[]; encounters: HuntEncounterDto[];
} }
const ENCOUNTER_COUNT = 3; // Upper bound only: a pool with fewer distinct monsters yields fewer cards
// (spec §3, "2-3 distinct encounter records where possible").
const MAX_ENCOUNTER_COUNT = 3;
@Injectable() @Injectable()
export class HuntingService { export class HuntingService {
@@ -103,20 +110,27 @@ export class HuntingService {
}); });
await txHunts.save(hunt); await txHunts.save(hunt);
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT); const picked = this.rollEncounters(pool, MAX_ENCOUNTER_COUNT);
const encounterDtos: HuntEncounterDto[] = []; const encounterDtos: HuntEncounterDto[] = [];
for (let position = 0; position < pickedMonsters.length; position += 1) { for (let position = 0; position < picked.length; position += 1) {
const monster = pickedMonsters[position]; const entry = picked[position];
const encounter = txEncounters.create({ const encounter = txEncounters.create({
huntId: hunt.id, huntId: hunt.id,
monsterDefinitionId: monster.id, monsterDefinitionId: entry.monster.id,
position, position,
status: HuntEncounterStatus.AVAILABLE, status: HuntEncounterStatus.AVAILABLE,
}); });
await txEncounters.save(encounter); await txEncounters.save(encounter);
encounterDtos.push(this.toEncounterDto(encounter, monster, character)); encounterDtos.push(
this.toEncounterDto(
encounter,
entry.monster,
character,
entry.encounterType,
),
);
} }
return { return {
@@ -161,19 +175,38 @@ export class HuntingService {
order: { position: 'ASC' }, order: { position: 'ASC' },
}); });
// The encounter type belongs to the location's pool, not to the rolled
// row, so it is re-read here rather than duplicated onto every encounter.
const encounterTypes = await this.loadEncounterTypes(hunt.locationId);
return { return {
id: hunt.id, id: hunt.id,
location: this.toLocationSummary(character.currentLocation), location: this.toLocationSummary(character.currentLocation),
encounters: encounters.map((encounter) => encounters: encounters.map((encounter) =>
this.toEncounterDto(encounter, encounter.monster, character), this.toEncounterDto(
encounter,
encounter.monster,
character,
encounterTypes.get(encounter.monsterDefinitionId) ??
EncounterType.NORMAL,
),
), ),
}; };
} }
private async loadEncounterTypes(
locationId: string,
): Promise<Map<string, EncounterType>> {
const locationMonsters = this.dataSource.getRepository(LocationMonster);
const pool = await locationMonsters.find({ where: { locationId } });
return new Map(pool.map((entry) => [entry.monsterId, entry.encounterType]));
}
private toEncounterDto( private toEncounterDto(
encounter: HuntEncounter, encounter: HuntEncounter,
monster: MonsterDefinition, monster: MonsterDefinition,
character: Character, character: Character,
encounterType: EncounterType,
): HuntEncounterDto { ): HuntEncounterDto {
return { return {
id: encounter.id, id: encounter.id,
@@ -182,41 +215,54 @@ export class HuntingService {
name: monster.name, name: monster.name,
level: monster.level, level: monster.level,
artworkPath: monster.artworkPath, artworkPath: monster.artworkPath,
flavorText: monster.flavorText ?? null,
}, },
dangerRating: calculateDangerRating( dangerRating: calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp }, { attack: character.baseAttack, armor: 0, hp: character.baseHp },
{ attack: monster.attack, armor: monster.armor, hp: monster.maxHp }, { attack: monster.attack, armor: monster.armor, hp: monster.maxHp },
), ),
status: encounter.status, status: encounter.status,
encounterType,
}; };
} }
/** /**
* Rolls `count` independent weighted picks from `pool`. Each slot walks * Draws up to `count` *distinct* monsters from `pool` (spec §3).
* the pool in the order it was supplied, accumulating weight, and picks *
* the first entry whose cumulative weight exceeds the roll * Each slot is one weighted pick over the entries still available, and the
* (roll < cumulative). Pure and deterministic given a RandomSource, so * winner is then removed so the same monster cannot fill two cards -- the
* it is trivially unit-testable with canned `next()` values. * player is supposed to be choosing between different enemies, not looking
* at the same rat three times. A pool with fewer entries than `count`
* simply yields fewer cards ("where possible").
*
* The pick walks the remaining pool in the order it was supplied,
* accumulating weight, and takes the first entry whose cumulative weight
* exceeds the roll (roll < cumulative). Pure and deterministic given a
* RandomSource, so canned `next()` values make even the rare encounter
* reproducible in tests.
*/ */
private rollEncounters( private rollEncounters(
pool: LocationMonster[], pool: LocationMonster[],
count: number, count: number,
): MonsterDefinition[] { ): LocationMonster[] {
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0); const remaining = [...pool];
const picks: MonsterDefinition[] = []; const picks: LocationMonster[] = [];
for (let i = 0; i < count; i += 1) {
while (picks.length < count && remaining.length > 0) {
const totalWeight = remaining.reduce((sum, e) => sum + e.weight, 0);
const roll = this.randomSource.next() * totalWeight; const roll = this.randomSource.next() * totalWeight;
let cumulative = 0; let cumulative = 0;
let picked: LocationMonster = pool[pool.length - 1]; let pickedIndex = remaining.length - 1;
for (const entry of pool) { for (let index = 0; index < remaining.length; index += 1) {
cumulative += entry.weight; cumulative += remaining[index].weight;
if (roll < cumulative) { if (roll < cumulative) {
picked = entry; pickedIndex = index;
break; break;
} }
} }
picks.push(picked.monster); picks.push(remaining.splice(pickedIndex, 1)[0]);
} }
return picks; return picks;
} }

View File

@@ -19,7 +19,8 @@ function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
itemDefinition: { itemDefinition: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Worn Shortsword', name: 'Worn Shortsword',
description: "A recruit's blade, sharpened more often than it's been swung.", description:
"A recruit's blade, sharpened more often than it's been swung.",
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
equipmentSlot: EquipmentSlot.WEAPON, equipmentSlot: EquipmentSlot.WEAPON,
@@ -67,7 +68,8 @@ describe('InventoryService', () => {
item: { item: {
key: 'worn-short-sword', key: 'worn-short-sword',
name: 'Worn Shortsword', name: 'Worn Shortsword',
description: "A recruit's blade, sharpened more often than it's been swung.", description:
"A recruit's blade, sharpened more often than it's been swung.",
rarity: 'COMMON', rarity: 'COMMON',
equipmentSlot: 'WEAPON', equipmentSlot: 'WEAPON',
weaponDamage: 8, weaponDamage: 8,

View File

@@ -9,6 +9,7 @@ import {
import { EquipmentSlot } from '../equipment-slot.enum'; import { EquipmentSlot } from '../equipment-slot.enum';
import { ItemRarity } from '../item-rarity.enum'; import { ItemRarity } from '../item-rarity.enum';
import { ItemType } from '../item-type.enum'; import { ItemType } from '../item-type.enum';
import { LootCategory } from '../loot-category.enum';
@Entity({ name: 'item_definitions' }) @Entity({ name: 'item_definitions' })
@Index('IDX_item_definitions_key', ['key'], { unique: true }) @Index('IDX_item_definitions_key', ['key'], { unique: true })
@@ -50,6 +51,18 @@ export class ItemDefinition {
}) })
rarity!: ItemRarity; rarity!: ItemRarity;
// Which carrying bucket this counts against (Playable Slice 0.7.5 §4).
// Null for everything that is not a trade good -- equipment and consumables
// are deliberately unaffected by bag capacity (spec §8).
@Column({
name: 'loot_category',
type: 'enum',
enum: LootCategory,
enumName: 'loot_category_enum',
nullable: true,
})
lootCategory!: LootCategory | null;
@Column({ name: 'tier', type: 'integer' }) @Column({ name: 'tier', type: 'integer' })
tier!: number; tier!: number;

View File

@@ -0,0 +1,14 @@
/**
* The carrying bucket a trade good counts against (Playable Slice 0.7.5 §2).
*
* Only the two categories the current content needs exist; the enum is the
* extension point for CHITIN, UNDEAD_RELIC and the rest as later regions add
* the goods that need them (spec §3).
*
* Deliberately separate from MonsterCategory: a BEAST can drop HIDE today and
* CHITIN somewhere else, so the two must never be assumed equal (spec §5).
*/
export enum LootCategory {
HIDE = 'HIDE',
RAIDER_TROPHY = 'RAIDER_TROPHY',
}

View File

@@ -0,0 +1,64 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LootBagDefinition } from './loot-bag-definition.entity';
/**
* A bag one character owns, and whether they are currently carrying it
* (Playable Slice 0.7.5 §6).
*
* Its own loadout, not an equipment slot: `CharacterEquipment` is about combat
* stats and armor slots, and mixing the two would let a bag compete with a
* chest piece.
*
* V1 allows one active bag per loot category (spec §6). The category lives on
* the definition rather than being copied here, so the database cannot express
* that as a partial unique index; `LootCapacityService` resolves a duplicate
* deterministically instead of trusting row order. What the unique index below
* *does* guarantee is that a character never holds the same bag twice.
*/
@Entity({ name: 'character_loot_bags' })
@Index(
'IDX_character_loot_bags_character_definition',
['characterId', 'lootBagDefinitionId'],
{ unique: true },
)
@Index('IDX_character_loot_bags_character', ['characterId'])
export class CharacterLootBag {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'loot_bag_definition_id', type: 'uuid' })
lootBagDefinitionId!: string;
// An owned but stowed bag grants nothing. Only an active bag counts toward
// capacity, which is what makes "only active bag affects capacity"
// (spec §13) a real rule rather than an accident of ownership.
@Column({ name: 'active', type: 'boolean', default: true })
active!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => LootBagDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'loot_bag_definition_id' })
lootBagDefinition!: LootBagDefinition;
}

View File

@@ -0,0 +1,51 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { LootCategory } from '../../items/loot-category.enum';
/**
* A bag that raises the carrying capacity of exactly one loot category
* (Playable Slice 0.7.5 §6, §7).
*
* Content, not player state: capacity is balancing data, tuned here rather
* than in the service that reads it. Deliberately not an `ItemDefinition` --
* a bag is never equipped into an armor slot, never rolls as loot, and has no
* combat stats (spec §6).
*/
@Entity({ name: 'loot_bag_definitions' })
@Index('IDX_loot_bag_definitions_key', ['key'], { unique: true })
export class LootBagDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({
name: 'loot_category',
type: 'enum',
enum: LootCategory,
enumName: 'loot_category_enum',
})
lootCategory!: LootCategory;
@Column({ name: 'capacity', type: 'integer' })
capacity!: number;
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
iconPath!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { LootCapacityDto, LootCapacityService } from './loot-capacity.service';
@Controller('loot-bags')
export class LootBagsController {
constructor(private readonly lootCapacity: LootCapacityService) {}
/**
* Carrying state per loot category (spec §11).
*
* Its own route rather than a field on the inventory response: the hunt
* screen needs this between fights and has no reason to pull the whole item
* list to get it (spec §12).
*/
@Get('capacities')
getCapacities(): Promise<LootCapacityDto[]> {
return this.lootCapacity.getCapacities(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterItem } from '../items/entities/character-item.entity';
import { CharacterLootBag } from './entities/character-loot-bag.entity';
import { LootBagDefinition } from './entities/loot-bag-definition.entity';
import { LootBagsController } from './loot-bags.controller';
import { LootCapacityService } from './loot-capacity.service';
/**
* Owns carrying capacity for loot categories (Playable Slice 0.7.5).
*
* `LootCapacityService` resolves its repositories off the injected DataSource
* rather than through constructor injection, because reward granting calls it
* with the transaction's own EntityManager. The `forFeature` registration is
* still required: the runtime config uses `autoLoadEntities`, which only sees
* entities a module declares, so without this the metadata for these tables
* would never be registered at all.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
CharacterItem,
CharacterLootBag,
LootBagDefinition,
]),
],
controllers: [LootBagsController],
providers: [LootCapacityService],
exports: [LootCapacityService],
})
export class LootBagsModule {}

View File

@@ -0,0 +1,333 @@
import { DataSource, EntityTarget } from 'typeorm';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity';
import { LootCategory } from '../items/loot-category.enum';
import { CharacterLootBag } from './entities/character-loot-bag.entity';
import { LootBagDefinition } from './entities/loot-bag-definition.entity';
import {
DEFAULT_LOOT_CAPACITY,
LootCapacityBudget,
LootCapacityService,
} from './loot-capacity.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const HIDE_BAG: LootBagDefinition = {
id: 'a0000000-0000-4000-8000-000000000001',
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: LootCategory.HIDE,
capacity: 5,
iconPath: '/images/items/basic-hide-bag.png',
} as LootBagDefinition;
const TROPHY_POUCH: LootBagDefinition = {
id: 'a0000000-0000-4000-8000-000000000002',
key: 'basic-trophy-pouch',
name: 'Basic Trophy Pouch',
lootCategory: LootCategory.RAIDER_TROPHY,
capacity: 5,
iconPath: '/images/items/basic-trophy-pouch.png',
} as LootBagDefinition;
interface State {
characterItems: CharacterItem[];
characterLootBags: CharacterLootBag[];
}
/**
* Only what the service actually calls: `find` with a `where` clause. The
* `relations` option is ignored, so fixtures attach the joined row the way
* TypeORM would have hydrated it.
*/
class FakeRepository<T> {
constructor(private readonly rows: T[]) {}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(
this.rows.filter((row) =>
Object.entries(options.where).every(
([key, value]) => row[key as keyof T] === value,
),
),
);
}
}
function fakeDataSource(state: State): DataSource {
return {
getRepository: <T>(target: EntityTarget<T>) => {
if (target === CharacterItem) {
return new FakeRepository(state.characterItems) as never;
}
if (target === CharacterLootBag) {
return new FakeRepository(state.characterLootBags) as never;
}
throw new Error('Unsupported repository');
},
} as unknown as DataSource;
}
function tradeGood(
itemDefinitionId: string,
lootCategory: LootCategory | null,
): ItemDefinition {
return { id: itemDefinitionId, lootCategory } as ItemDefinition;
}
function ownedItem(
characterId: string,
quantity: number,
lootCategory: LootCategory | null,
itemDefinitionId = `item-${lootCategory ?? 'none'}`,
): CharacterItem {
return {
id: `character-item-${itemDefinitionId}-${characterId}`,
characterId,
itemDefinitionId,
quantity,
itemDefinition: tradeGood(itemDefinitionId, lootCategory),
} as CharacterItem;
}
function ownedBag(
characterId: string,
definition: LootBagDefinition,
active = true,
): CharacterLootBag {
return {
id: `bag-${definition.key}-${characterId}`,
characterId,
lootBagDefinitionId: definition.id,
active,
lootBagDefinition: definition,
} as CharacterLootBag;
}
function createService(state: Partial<State> = {}) {
const full: State = {
characterItems: state.characterItems ?? [],
characterLootBags: state.characterLootBags ?? [],
};
return new LootCapacityService(fakeDataSource(full));
}
async function capacityOf(
service: LootCapacityService,
category: LootCategory,
) {
const capacities = await service.getCapacities(CHARACTER_ID);
const entry = capacities.find((row) => row.category === category);
if (!entry) {
throw new Error(`No capacity reported for ${category}`);
}
return entry;
}
describe('LootCapacityService', () => {
describe('capacity (spec §2, §8)', () => {
it('reports every known loot category, even the empty ones', async () => {
const capacities = await createService().getCapacities(CHARACTER_ID);
// The UI renders a row per category; an absent category would silently
// vanish from the carrying strip.
expect(capacities.map((entry) => entry.category)).toEqual(
Object.values(LootCategory),
);
});
it('gives a bagless character capacity 1', async () => {
const entry = await capacityOf(createService(), LootCategory.HIDE);
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
expect(entry.capacity).toBe(1);
expect(entry.bag).toBeNull();
});
it('raises HIDE to 5 with a Basic Hide Bag, and names the bag', async () => {
const service = createService({
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
});
const entry = await capacityOf(service, LootCategory.HIDE);
expect(entry.capacity).toBe(5);
expect(entry.bag).toEqual({
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
iconPath: '/images/items/basic-hide-bag.png',
});
});
it('does not let a hide bag raise the trophy category', async () => {
const service = createService({
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
});
const trophies = await capacityOf(service, LootCategory.RAIDER_TROPHY);
// A bag lifts exactly its own configured category (spec §14).
expect(trophies.capacity).toBe(DEFAULT_LOOT_CAPACITY);
expect(trophies.bag).toBeNull();
});
it('lets two bags raise their own categories side by side', async () => {
const service = createService({
characterLootBags: [
ownedBag(CHARACTER_ID, HIDE_BAG),
ownedBag(CHARACTER_ID, TROPHY_POUCH),
],
});
expect((await capacityOf(service, LootCategory.HIDE)).bag?.key).toBe(
'basic-hide-bag',
);
expect(
(await capacityOf(service, LootCategory.RAIDER_TROPHY)).bag?.key,
).toBe('basic-trophy-pouch');
});
it('ignores an owned but inactive bag', async () => {
const service = createService({
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG, false)],
});
const entry = await capacityOf(service, LootCategory.HIDE);
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
expect(entry.bag).toBeNull();
});
it('resolves two active bags of one category to the roomier one', async () => {
const smallBag: LootBagDefinition = {
...HIDE_BAG,
id: 'small',
key: 'worn-hide-sack',
capacity: 2,
};
const service = createService({
characterLootBags: [
ownedBag(CHARACTER_ID, smallBag),
ownedBag(CHARACTER_ID, HIDE_BAG),
],
});
// V1 expects one active bag per category. If state ever violates that,
// the answer must still be deterministic rather than row-order luck.
expect((await capacityOf(service, LootCategory.HIDE)).capacity).toBe(5);
});
});
describe('current fill', () => {
it('sums every owned stack in a category', async () => {
const service = createService({
characterItems: [
ownedItem(CHARACTER_ID, 2, LootCategory.HIDE, 'ash-pelt'),
ownedItem(CHARACTER_ID, 1, LootCategory.HIDE, 'tough-hide'),
],
});
expect((await capacityOf(service, LootCategory.HIDE)).current).toBe(3);
});
it('does not count equipment or consumables against any category', async () => {
const service = createService({
characterItems: [
ownedItem(CHARACTER_ID, 9, null, 'bandit-blade'),
ownedItem(CHARACTER_ID, 1, LootCategory.HIDE, 'ash-pelt'),
],
});
// Spec §8: normal items are outside the loot-bag system entirely.
expect((await capacityOf(service, LootCategory.HIDE)).current).toBe(1);
});
});
describe('security (spec §13)', () => {
it('derives capacity only from this character, never another one', async () => {
const service = createService({
characterItems: [
ownedItem(OTHER_CHARACTER_ID, 4, LootCategory.HIDE, 'ash-pelt'),
],
characterLootBags: [ownedBag(OTHER_CHARACTER_ID, HIDE_BAG)],
});
const entry = await capacityOf(service, LootCategory.HIDE);
// Someone else's bag must not raise this character's capacity, and
// someone else's pelts must not fill it.
expect(entry.capacity).toBe(DEFAULT_LOOT_CAPACITY);
expect(entry.current).toBe(0);
expect(entry.bag).toBeNull();
});
it('ignores a bag the character does not own, however the id arrives', async () => {
const service = createService({
characterLootBags: [ownedBag(OTHER_CHARACTER_ID, HIDE_BAG)],
});
// Capacity is read from persisted ownership rows only. There is no
// parameter anywhere on this service through which a client could name
// a bag, which is what makes a faked one impossible rather than merely
// rejected.
const budget = await service.createBudget(CHARACTER_ID);
expect(budget.take(LootCategory.HIDE, 5)).toBe(DEFAULT_LOOT_CAPACITY);
});
});
describe('budget', () => {
it('offers the free room, not the total capacity', async () => {
const service = createService({
characterItems: [
ownedItem(CHARACTER_ID, 4, LootCategory.HIDE, 'ash-pelt'),
],
characterLootBags: [ownedBag(CHARACTER_ID, HIDE_BAG)],
});
const budget = await service.createBudget(CHARACTER_ID);
expect(budget.take(LootCategory.HIDE, 5)).toBe(1);
});
it('reads a character carrying more than they can as simply full', async () => {
const service = createService({
// Over capacity: a bag was unequipped, or a definition was retuned
// downward. This must never become a negative that lets loot through.
characterItems: [
ownedItem(CHARACTER_ID, 9, LootCategory.HIDE, 'ash-pelt'),
],
});
const budget = await service.createBudget(CHARACTER_ID);
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
});
it('never limits an item outside every category', () => {
const budget = new LootCapacityBudget(new Map([[LootCategory.HIDE, 0]]));
expect(budget.take(null, 3)).toBe(3);
});
it('spends down across repeated takes', () => {
const budget = new LootCapacityBudget(new Map([[LootCategory.HIDE, 2]]));
expect(budget.take(LootCategory.HIDE, 1)).toBe(1);
expect(budget.take(LootCategory.HIDE, 5)).toBe(1);
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
});
it('keeps categories independent of one another', () => {
const budget = new LootCapacityBudget(
new Map([
[LootCategory.HIDE, 0],
[LootCategory.RAIDER_TROPHY, 2],
]),
);
expect(budget.take(LootCategory.HIDE, 1)).toBe(0);
expect(budget.take(LootCategory.RAIDER_TROPHY, 1)).toBe(1);
});
});
});

View File

@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CharacterItem } from '../items/entities/character-item.entity';
import { LootCategory } from '../items/loot-category.enum';
import { CharacterLootBag } from './entities/character-loot-bag.entity';
import { LootBagDefinition } from './entities/loot-bag-definition.entity';
/**
* Carrying capacity of a loot category with no bag at all (spec §2).
*
* One, not zero: the player must always be able to bring *something* home
* from a hunt, or a bagless character could never obtain the goods that buy
* the first bag.
*/
export const DEFAULT_LOOT_CAPACITY = 1;
export interface LootCapacityBagDto {
key: string;
name: string;
iconPath: string;
}
export interface LootCapacityDto {
category: LootCategory;
current: number;
capacity: number;
/** The active bag behind `capacity`, or null when it is the bagless default. */
bag: LootCapacityBagDto | null;
}
// Both DataSource and EntityManager expose this; naming it keeps the read path
// usable inside and outside a transaction without a union type.
type RepositoryScope = Pick<DataSource, 'getRepository'>;
/**
* The single authority on how much of each loot category a character can
* carry (spec §8).
*
* Everything is derived from persisted state — owned bags and owned items —
* so a client can neither submit a capacity nor claim a bag it does not have
* (spec §13). Nothing here reads a request.
*/
@Injectable()
export class LootCapacityService {
constructor(private readonly dataSource: DataSource) {}
/** Capacity and current fill for every known loot category. */
async getCapacities(
characterId: string,
scope?: RepositoryScope,
): Promise<LootCapacityDto[]> {
const db = scope ?? this.dataSource;
const [carried, bags] = await Promise.all([
this.loadCarriedTotals(characterId, db),
this.loadActiveBags(characterId, db),
]);
return Object.values(LootCategory).map((category) => {
const bag = bags.get(category);
return {
category,
current: carried.get(category) ?? 0,
capacity: bag?.capacity ?? DEFAULT_LOOT_CAPACITY,
bag: bag
? { key: bag.key, name: bag.name, iconPath: bag.iconPath }
: null,
};
});
}
/**
* How much of `category` still fits, as a live budget the caller can spend
* down across several items of one reward (spec §10).
*
* Clamped at zero: a capacity that shrank below what the character already
* carries — a bag unequipped, a definition retuned — must read as "no room",
* never as a negative that would let a grant through.
*/
async createBudget(
characterId: string,
scope?: RepositoryScope,
): Promise<LootCapacityBudget> {
const capacities = await this.getCapacities(characterId, scope);
return new LootCapacityBudget(
new Map(
capacities.map((entry) => [
entry.category,
Math.max(0, entry.capacity - entry.current),
]),
),
);
}
/**
* Sums owned quantities per category. Items outside every loot category —
* equipment, consumables — are absent from the result entirely, which is
* what makes them unaffected by bag capacity (spec §8).
*/
private async loadCarriedTotals(
characterId: string,
db: RepositoryScope,
): Promise<Map<LootCategory, number>> {
const items = await db.getRepository(CharacterItem).find({
where: { characterId },
relations: { itemDefinition: true },
});
const totals = new Map<LootCategory, number>();
for (const item of items) {
const category = item.itemDefinition.lootCategory;
if (!category) {
continue;
}
totals.set(category, (totals.get(category) ?? 0) + item.quantity);
}
return totals;
}
/**
* The active bag per category. V1 expects at most one, but if a future bug
* or a hand-edited row leaves two active in one category, the roomiest wins
* — a deterministic answer beats whichever row the query happened to return
* first, and erring toward the player is the harmless direction.
*/
private async loadActiveBags(
characterId: string,
db: RepositoryScope,
): Promise<Map<LootCategory, LootBagDefinition>> {
const owned = await db.getRepository(CharacterLootBag).find({
where: { characterId, active: true },
relations: { lootBagDefinition: true },
});
const best = new Map<LootCategory, LootBagDefinition>();
for (const { lootBagDefinition: definition } of owned) {
const current = best.get(definition.lootCategory);
if (!current || definition.capacity > current.capacity) {
best.set(definition.lootCategory, definition);
}
}
return best;
}
}
/**
* The remaining room per category for one reward grant.
*
* Handed out by `createBudget` and spent down as items are granted, so two
* hides in the same reward cannot both slip through the last free slot
* (spec §10). Deliberately a plain object with no database access: the
* arithmetic is pure and directly testable.
*/
export class LootCapacityBudget {
constructor(private readonly remaining: Map<LootCategory, number>) {}
/**
* Reserves up to `quantity` units of `category` and reports how much fit.
*
* An item with no loot category is not a trade good and is never limited —
* that is how a Bandit Hood still drops into a full hide bag (spec §9).
*/
take(category: LootCategory | null, quantity: number): number {
if (category === null) {
return quantity;
}
const free = this.remaining.get(category) ?? DEFAULT_LOOT_CAPACITY;
const granted = Math.min(free, quantity);
this.remaining.set(category, free - granted);
return granted;
}
}

View File

@@ -159,6 +159,59 @@ describe('LootService', () => {
}); });
}); });
describe('guaranteed trade good (spec §5, §6)', () => {
// A guaranteed drop is just dropChance = 1.0000, in front of the
// equipment rolls that stay a chance.
const burnedRoadEntries = [
entry({
id: 'entry-pelt',
itemDefinitionId: ASH_PELT,
position: 1,
dropChance: '1.0000',
}),
entry({
id: 'entry-sword',
itemDefinitionId: WORN_SHORT_SWORD,
position: 2,
dropChance: '0.0800',
}),
];
it('drops the guaranteed trade good on the worst possible roll', async () => {
const service = new LootService(
dataSourceWith(burnedRoadEntries),
queuedRandom(0.9999, 0.9999),
);
await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: ASH_PELT, quantity: 1 }],
});
});
it('keeps the equipment roll independent of the guaranteed good', async () => {
// The same guaranteed pelt, with the equipment roll going both ways:
// the trade good never changes, and the gear never rides along with it.
const withoutGear = new LootService(
dataSourceWith(burnedRoadEntries),
queuedRandom(0.5, 0.5),
);
const withGear = new LootService(
dataSourceWith(burnedRoadEntries),
queuedRandom(0.5, 0.01),
);
await expect(withoutGear.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [{ itemDefinitionId: ASH_PELT, quantity: 1 }],
});
await expect(withGear.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({
items: [
{ itemDefinitionId: ASH_PELT, quantity: 1 },
{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 },
],
});
});
});
it('returns nothing for a monster without a loot table', async () => { it('returns nothing for a monster without a loot table', async () => {
const service = new LootService( const service = new LootService(
dataSourceWith(ashRatEntries), dataSourceWith(ashRatEntries),

View File

@@ -9,6 +9,8 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { LootTable } from '../../loot/entities/loot-table.entity'; import { LootTable } from '../../loot/entities/loot-table.entity';
import { MonsterCategory } from '../monster-category.enum';
import type { MonsterAbilities } from '../monster-abilities';
@Entity({ name: 'monster_definitions' }) @Entity({ name: 'monster_definitions' })
@Index('IDX_monster_definitions_key', ['key'], { unique: true }) @Index('IDX_monster_definitions_key', ['key'], { unique: true })
@@ -22,6 +24,16 @@ export class MonsterDefinition {
@Column({ name: 'name', type: 'varchar', length: 150 }) @Column({ name: 'name', type: 'varchar', length: 150 })
name!: string; name!: string;
// Broad gameplay kind (Playable Slice 0.7.5 §5). Classification for later
// systems to key off; nothing branches on it yet.
@Column({
name: 'monster_category',
type: 'enum',
enum: MonsterCategory,
enumName: 'monster_category_enum',
})
monsterCategory!: MonsterCategory;
@Column({ name: 'level', type: 'integer' }) @Column({ name: 'level', type: 'integer' })
level!: number; level!: number;
@@ -34,11 +46,15 @@ export class MonsterDefinition {
@Column({ name: 'armor', type: 'integer' }) @Column({ name: 'armor', type: 'integer' })
armor!: number; armor!: number;
@Column({ name: 'silver_min', type: 'integer' }) // Short line of atmosphere shown on the hunt encounter card (spec §9).
silverMin!: number; // Nullable: a monster without one simply shows no flavor line.
@Column({ name: 'flavor_text', type: 'text', nullable: true })
flavorText!: string | null;
@Column({ name: 'silver_max', type: 'integer' }) // Combat mechanics as content, not code (spec §4, §8). `{}` is a monster
silverMax!: number; // with no special mechanic.
@Column({ name: 'abilities', type: 'jsonb', default: () => "'{}'::jsonb" })
abilities!: MonsterAbilities;
@Column({ name: 'artwork_path', type: 'varchar', length: 255 }) @Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string; artworkPath!: string;

View File

@@ -0,0 +1,34 @@
/**
* Per-monster combat mechanics, authored as content on `MonsterDefinition`
* (Playable Slice 0.7 V2 spec §4, §8).
*
* The combat engine reads this configuration instead of branching on a
* monster key, so a new enemy is a seed row rather than an engine change.
* An empty object is a monster with no special mechanic at all -- the Ash
* Rat's "pure baseline combat" (spec §4).
*/
export interface MonsterTelegraphAbility {
/**
* The monster announces a Heavy Attack on every round divisible by this
* and resolves it the round after, unless SHIELD_BASH interrupts it.
* A fixed cadence rather than RNG keeps combat deterministic (AGENTS §10).
*/
roundInterval: number;
damageMultiplier: number;
}
export interface MonsterBleedAbility {
/** Applies Bleeding alongside its normal attack on rounds divisible by this. */
roundInterval: number;
/** Damage dealt at the end of every round the effect is still active. */
damagePerRound: number;
/** How many rounds the effect ticks for, counting the round it lands. */
durationRounds: number;
}
export interface MonsterAbilities {
telegraph?: MonsterTelegraphAbility;
bleed?: MonsterBleedAbility;
}
export const NO_MONSTER_ABILITIES: MonsterAbilities = {};

View File

@@ -0,0 +1,13 @@
/**
* A monster's broad gameplay kind (Playable Slice 0.7.5 §5).
*
* Content classification only: nothing in this slice branches on it. It exists
* so later systems -- damage types, faction reputation, quest objectives
* ("slay 10 beasts") -- have a reusable handle that is not a monster key.
*
* Not the same axis as LootCategory. See that enum's note.
*/
export enum MonsterCategory {
BEAST = 'BEAST',
HUMANOID = 'HUMANOID',
}

View File

@@ -0,0 +1,65 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { NpcDefinition } from './npc-definition.entity';
/**
* What one character has done with one NPC (NPC spec §7).
*
* Kept out of `NpcDefinition` on purpose: the NPC is global content, this is
* per-character state (spec §37.2). Storing "has met" on the definition would
* make it true for everyone the moment one player walks up.
*
* Spec §35 asks for this to be *prepared* rather than fully exploited in V1,
* and that is what it is: first-met tracking drives the greeting dialogue, and
* `flags` backs FLAG_SET conditions. `relationValue` is deliberately absent --
* personal NPC relationship is explicitly out of V1 scope (spec §35), and
* reputation is a separate system that must not be conflated with it
* (spec §8, §37.3).
*/
@Entity({ name: 'character_npc_states' })
@Index('IDX_character_npc_states_character_npc', ['characterId', 'npcId'], {
unique: true,
})
export class CharacterNpcState {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'first_met_at', type: 'timestamptz', nullable: true })
firstMetAt!: Date | null;
@Column({ name: 'last_interaction_at', type: 'timestamptz', nullable: true })
lastInteractionAt!: Date | null;
/** Free-form per-NPC dialogue flags, read by FLAG_SET conditions (spec §19). */
@Column({ name: 'flags', type: 'jsonb', default: () => "'{}'::jsonb" })
flags!: Record<string, boolean | string | number>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => Character, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
}

View File

@@ -0,0 +1,67 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type { GameCondition } from '../../conditions/game-condition.types';
import type { DialogueAction, DialogueResponseContent } from '../npc.types';
import { NpcDefinition } from './npc-definition.entity';
/**
* One thing an NPC might say (NPC spec §10, §11).
*
* Several nodes can be valid at once, so each carries a priority and the
* server picks the highest valid one. That is what keeps situational dialogue
* out of the service as if/else chains (spec §11).
*
* Responses and actions are stored as JSONB rather than as their own tables:
* they are only ever read as part of their node, never queried across nodes,
* and V1 dialogue is a single authored beat rather than a conversation tree.
*/
@Entity({ name: 'dialogue_nodes' })
@Index('IDX_dialogue_nodes_npc_key', ['npcId', 'key'], { unique: true })
@Index('IDX_dialogue_nodes_npc_priority', ['npcId', 'priority'])
export class DialogueNode {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'text', type: 'text' })
text!: string;
/** Higher wins. Spec §11 sketches the scale: 1000 turn-in … 100 default. */
@Column({ name: 'priority', type: 'integer' })
priority!: number;
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
conditions!: GameCondition[];
@Column({ name: 'actions', type: 'jsonb', default: () => "'[]'::jsonb" })
actions!: DialogueAction[];
@Column({ name: 'responses', type: 'jsonb', default: () => "'[]'::jsonb" })
responses!: DialogueResponseContent[];
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
}

View File

@@ -0,0 +1,79 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { NpcCapability } from '../npc.types';
/**
* A person in the world (NPC spec §3).
*
* Global content, never player state: whether *this* character has met Borin
* lives in `CharacterNpcState` (spec §7, §37.2). There is deliberately no
* `MerchantNpc` subclass -- roles come from linked rows such as `NpcShop` and
* `NpcExchangeProfile`, so one NPC can be a merchant and a trader at once
* (spec §2, §37.1).
*/
@Entity({ name: 'npc_definitions' })
@Index('IDX_npc_definitions_key', ['key'], { unique: true })
@Index('IDX_npc_definitions_location', ['locationId'])
export class NpcDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
/** Stable business key used by seeds, content and tests (spec §4). */
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'title', type: 'varchar', length: 150, nullable: true })
title!: string | null;
@Column({ name: 'description', type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@Column({ name: 'faction_key', type: 'varchar', length: 100, nullable: true })
factionKey!: string | null;
@Column({ name: 'portrait_path', type: 'varchar', length: 255 })
portraitPath!: string;
@Column({
name: 'artwork_path',
type: 'varchar',
length: 255,
nullable: true,
})
artworkPath!: string | null;
/**
* Declared capabilities (spec §5). Descriptive only -- an NPC that claims
* MERCHANT but owns no enabled shop simply offers no shop action.
*/
@Column({ name: 'capabilities', type: 'jsonb', default: () => "'[]'::jsonb" })
capabilities!: NpcCapability[];
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
}

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Param } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { NpcService } from './npc.service';
import { NpcInteractionDto, NpcSummaryDto } from './npc.types';
/** NPC read endpoints (NPC spec §22). */
@Controller()
export class NpcController {
constructor(private readonly npcService: NpcService) {}
@Get('locations/:locationId/npcs')
getNpcsAtLocation(
@Param('locationId') locationId: string,
): Promise<NpcSummaryDto[]> {
return this.npcService.getNpcsAtLocation(locationId);
}
/**
* Everything needed to render one NPC screen: who they are, what they say
* right now, and which actions the server will honour (spec §23).
*/
@Get('npcs/:npcKey/interaction')
getInteraction(@Param('npcKey') npcKey: string): Promise<NpcInteractionDto> {
return this.npcService.getInteraction(DEMO_CHARACTER_ID, npcKey);
}
}

View File

@@ -0,0 +1,36 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type NpcErrorCode = 'NPC_NOT_FOUND' | 'NPC_UNAVAILABLE';
export class NpcDomainError extends HttpException {
constructor(
public readonly code: NpcErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function npcNotFound(): NpcDomainError {
return new NpcDomainError(
'NPC_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This person could not be found.',
);
}
/**
* Raised when the character is not standing where the NPC is.
*
* Deliberately the same message as "not found": whether somebody exists
* elsewhere in the world is not something an out-of-range client gets to
* learn by probing the endpoint.
*/
export function npcUnavailable(): NpcDomainError {
return new NpcDomainError(
'NPC_UNAVAILABLE',
HttpStatus.CONFLICT,
'This person is not here.',
);
}

View File

@@ -0,0 +1,298 @@
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity';
import { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity';
import { NpcDefinition } from './entities/npc-definition.entity';
import { NpcService } from './npc.service';
import { NpcCapability } from './npc.types';
const CHARACTER_ID = 'character-1';
const NPC_ID = 'npc-1';
const LOCATION_ID = 'location-1';
interface Fixture {
characterLocationId?: string;
npcEnabled?: boolean;
nodes?: Array<Partial<DialogueNode>>;
metCondition?: boolean;
hasShop?: boolean;
shopEnabled?: boolean;
hasExchangeProfile?: boolean;
exchangeRuleCount?: number;
existingState?: Record<string, unknown> | null;
}
function createWorld(fixture: Fixture = {}) {
const savedStates: Array<Record<string, unknown>> = [];
const npc = {
id: NPC_ID,
key: 'borin-quartermaster',
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description: 'A broad, grey-bearded man.',
locationId: LOCATION_ID,
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: [NpcCapability.DIALOGUE, NpcCapability.MERCHANT],
enabled: fixture.npcEnabled ?? true,
} as unknown as NpcDefinition;
const dataSource = {
getRepository: (entity: unknown) => {
if (entity === NpcDefinition) {
return {
find: () => Promise.resolve(npc.enabled ? [npc] : []),
findOneBy: (criteria: { key: string; enabled: boolean }) =>
Promise.resolve(
npc.enabled === criteria.enabled && npc.key === criteria.key
? npc
: null,
),
};
}
if (entity === Character) {
return {
findOneBy: () =>
Promise.resolve({
id: CHARACTER_ID,
currentLocationId: fixture.characterLocationId ?? LOCATION_ID,
}),
};
}
if (entity === DialogueNode) {
return {
find: async () => {
const nodes = (fixture.nodes ?? []) as DialogueNode[];
return [...nodes].sort(
(left, right) =>
right.priority - left.priority ||
left.key.localeCompare(right.key),
);
},
};
}
if (entity === NpcShop) {
return {
findOneBy: () =>
Promise.resolve(
(fixture.hasShop ?? true) && (fixture.shopEnabled ?? true)
? { id: 'shop-1', key: 'borin-supplies', enabled: true }
: null,
),
};
}
if (entity === NpcExchangeProfile) {
return {
findOneBy: () =>
Promise.resolve(
(fixture.hasExchangeProfile ?? true)
? { id: 'profile-1', key: 'borin-trade-in', enabled: true }
: null,
),
};
}
if (entity === ExchangeRule) {
return {
countBy: () => Promise.resolve(fixture.exchangeRuleCount ?? 4),
};
}
if (entity === CharacterNpcState) {
return {
findOneBy: () => Promise.resolve(fixture.existingState ?? null),
create: (row: Record<string, unknown>) => row,
save: async (row: Record<string, unknown>) => {
savedStates.push(row);
return row;
},
};
}
throw new Error('Unexpected repository');
},
} as unknown as DataSource;
const conditions = {
evaluate: jest.fn(async (_context, conditionList) => {
if (!conditionList || conditionList.length === 0) {
return true;
}
return fixture.metCondition ?? false;
}),
} as unknown as GameConditionService;
return { service: new NpcService(dataSource, conditions), savedStates, npc };
}
function node(overrides: Partial<DialogueNode>): Partial<DialogueNode> {
return {
id: overrides.key,
npcId: NPC_ID,
key: 'node',
text: 'text',
priority: 100,
conditions: [],
actions: [],
responses: [],
enabled: true,
...overrides,
};
}
describe('NpcService', () => {
it('lists the people at a location with their markers', async () => {
const world = createWorld();
const npcs = await world.service.getNpcsAtLocation(LOCATION_ID);
expect(npcs).toHaveLength(1);
expect(npcs[0]).toMatchObject({
key: 'borin-quartermaster',
name: 'Borin',
});
expect(npcs[0].markers).toEqual(['MERCHANT', 'EXCHANGE']);
});
it('refuses an NPC the character has not travelled to', async () => {
// Reachability comes from the character's own location, never the request.
const world = createWorld({ characterLocationId: 'somewhere-else' });
await expect(
world.service.getInteraction(CHARACTER_ID, 'borin-quartermaster'),
).rejects.toMatchObject({ code: 'NPC_UNAVAILABLE' });
});
it('refuses a disabled NPC', async () => {
const world = createWorld({ npcEnabled: false });
await expect(
world.service.getInteraction(CHARACTER_ID, 'borin-quartermaster'),
).rejects.toMatchObject({ code: 'NPC_NOT_FOUND' });
});
it('picks the highest-priority dialogue whose conditions hold (spec §11)', async () => {
const world = createWorld({
metCondition: true,
nodes: [
node({ key: 'default', text: 'Standard line', priority: 100 }),
node({
key: 'trusted',
text: 'Reputation line',
priority: 500,
conditions: [{ type: 'REGION_REPUTATION' }],
} as Partial<DialogueNode>),
],
});
const interaction = await world.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(interaction.dialogue?.text).toBe('Reputation line');
});
it('falls back to the unconditional line when a higher one does not apply', async () => {
const world = createWorld({
metCondition: false,
nodes: [
node({ key: 'default', text: 'Standard line', priority: 100 }),
node({
key: 'trusted',
text: 'Reputation line',
priority: 500,
conditions: [{ type: 'REGION_REPUTATION' }],
} as Partial<DialogueNode>),
],
});
const interaction = await world.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(interaction.dialogue?.text).toBe('Standard line');
});
it('records the visit, and only writes first-met once', async () => {
const fresh = createWorld({ existingState: null });
await fresh.service.getInteraction(CHARACTER_ID, 'borin-quartermaster');
expect(fresh.savedStates[0]).toMatchObject({
characterId: CHARACTER_ID,
npcId: NPC_ID,
flags: { met: true },
});
expect(fresh.savedStates[0].firstMetAt).toBeInstanceOf(Date);
const firstMetAt = new Date('2020-01-01T00:00:00.000Z');
const returning = createWorld({
existingState: { firstMetAt, flags: { met: true } },
});
await returning.service.getInteraction(CHARACTER_ID, 'borin-quartermaster');
expect(returning.savedStates[0].firstMetAt).toBe(firstMetAt);
});
it('offers only actions the backing data can honour (spec §5)', async () => {
const full = createWorld();
const withEverything = await full.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(withEverything.availableActions.map((a) => a.type)).toEqual([
'TALK',
'OPEN_SHOP',
'OPEN_EXCHANGE',
]);
// The NPC still declares MERCHANT, but the shop row is gone. A capability
// is descriptive; it must not conjure a button.
const noShop = createWorld({ hasShop: false });
const withoutShop = await noShop.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(withoutShop.availableActions.map((a) => a.type)).toEqual([
'TALK',
'OPEN_EXCHANGE',
]);
});
it('does not advertise a trade-in screen with nothing on it', async () => {
const world = createWorld({ exchangeRuleCount: 0 });
const interaction = await world.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(interaction.availableActions.map((a) => a.type)).toEqual([
'TALK',
'OPEN_SHOP',
]);
});
it('returns no dialogue rather than inventing one when nothing matches', async () => {
const world = createWorld({
metCondition: false,
nodes: [
node({
key: 'gated',
priority: 100,
conditions: [{ type: 'REGION_REPUTATION' }],
} as Partial<DialogueNode>),
],
});
const interaction = await world.service.getInteraction(
CHARACTER_ID,
'borin-quartermaster',
);
expect(interaction.dialogue).toBeNull();
});
});

View File

@@ -0,0 +1,266 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity';
import { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity';
import { NpcDefinition } from './entities/npc-definition.entity';
import { npcNotFound, npcUnavailable } from './npc.errors';
import {
DialogueNodeDto,
DialogueResponseDto,
NpcActionDto,
NpcInteractionDto,
NpcMarker,
NpcSummaryDto,
} from './npc.types';
/**
* Loads NPCs and works out what they currently offer (NPC spec §30).
*
* Deliberately not responsible for purchases, exchanges, reputation or item
* transfer -- those live in their own services (spec §30). This one answers
* "who is here, what do they say, and what can I do with them".
*/
@Injectable()
export class NpcService {
constructor(
private readonly dataSource: DataSource,
private readonly conditions: GameConditionService,
) {}
/** Every enabled NPC at a location, for the local view (spec §22, §24). */
async getNpcsAtLocation(locationId: string): Promise<NpcSummaryDto[]> {
const npcs = await this.dataSource.getRepository(NpcDefinition).find({
where: { locationId, enabled: true },
order: { key: 'ASC' },
});
const summaries: NpcSummaryDto[] = [];
for (const npc of npcs) {
summaries.push({
id: npc.id,
key: npc.key,
name: npc.name,
title: npc.title,
portraitPath: npc.portraitPath,
markers: await this.resolveMarkers(npc),
});
}
return summaries;
}
/**
* Everything the client needs to render one NPC screen (spec §23).
*
* The character's own location decides reachability, never the request, so
* a client cannot talk to a merchant in a town it has not travelled to
* (the same rule `WorldService.runLocalInteraction` applies).
*/
async getInteraction(
characterId: string,
npcKey: string,
): Promise<NpcInteractionDto> {
const npc = await this.requireReachableNpc(characterId, npcKey);
// Dialogue is resolved *before* the visit is recorded. `touchNpcState`
// sets the `met` flag, so a greeting node conditioned on `met = false`
// would never fire if the order were reversed.
const dialogue = await this.resolveDialogue(characterId, npc);
await this.touchNpcState(characterId, npc.id);
return {
npc: {
id: npc.id,
key: npc.key,
name: npc.name,
title: npc.title,
description: npc.description,
portraitPath: npc.portraitPath,
artworkPath: npc.artworkPath,
capabilities: npc.capabilities ?? [],
},
dialogue,
availableActions: await this.resolveActions(npc),
};
}
/** Loads an enabled NPC the character is currently standing with. */
async requireReachableNpc(
characterId: string,
npcKey: string,
): Promise<NpcDefinition> {
const npc = await this.dataSource
.getRepository(NpcDefinition)
.findOneBy({ key: npcKey, enabled: true });
if (!npc) {
throw npcNotFound();
}
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw npcNotFound();
}
if (character.currentLocationId !== npc.locationId) {
throw npcUnavailable();
}
return npc;
}
/**
* Picks the highest-priority dialogue whose conditions hold (spec §11).
*
* Ties break on key so a content mistake produces the same line every time
* rather than whatever the database happened to return first.
*/
private async resolveDialogue(
characterId: string,
npc: NpcDefinition,
): Promise<DialogueNodeDto | null> {
const nodes = await this.dataSource.getRepository(DialogueNode).find({
where: { npcId: npc.id, enabled: true },
order: { priority: 'DESC', key: 'ASC' },
});
for (const node of nodes) {
const met = await this.conditions.evaluate(
{ characterId, npcId: npc.id },
node.conditions,
);
if (!met) {
continue;
}
const responses: DialogueResponseDto[] = [];
for (const response of node.responses ?? []) {
const allowed = await this.conditions.evaluate(
{ characterId, npcId: npc.id },
response.conditions,
);
if (allowed) {
responses.push({
key: response.key,
text: response.text,
targetNodeKey: response.targetNodeKey ?? null,
});
}
}
return { key: node.key, text: node.text, responses };
}
return null;
}
/**
* The actions the server is willing to honour right now (spec §23).
*
* Driven by whether the backing data exists and is enabled, not by the
* declared capability list -- an NPC that claims MERCHANT but has no
* enabled shop offers no shop button (spec §5).
*/
private async resolveActions(npc: NpcDefinition): Promise<NpcActionDto[]> {
const actions: NpcActionDto[] = [
{ type: 'TALK', label: 'Talk', key: null },
];
const shop = await this.dataSource
.getRepository(NpcShop)
.findOneBy({ npcId: npc.id, enabled: true });
if (shop) {
actions.push({ type: 'OPEN_SHOP', label: 'Browse Wares', key: shop.key });
}
const profile = await this.findUsableExchangeProfile(npc.id);
if (profile) {
actions.push({
type: 'OPEN_EXCHANGE',
label: 'Trade In Goods',
key: profile.key,
});
}
return actions;
}
/** Markers for the local view. Only backed interactions get one (spec §24). */
private async resolveMarkers(npc: NpcDefinition): Promise<NpcMarker[]> {
const markers: NpcMarker[] = [];
const shop = await this.dataSource
.getRepository(NpcShop)
.findOneBy({ npcId: npc.id, enabled: true });
if (shop) {
markers.push('MERCHANT');
}
if (await this.findUsableExchangeProfile(npc.id)) {
markers.push('EXCHANGE');
}
return markers;
}
/**
* An enabled exchange profile that actually has an enabled rule.
*
* An empty profile would otherwise advertise a trade-in screen with nothing
* on it.
*/
private async findUsableExchangeProfile(
npcId: string,
): Promise<NpcExchangeProfile | null> {
const profile = await this.dataSource
.getRepository(NpcExchangeProfile)
.findOneBy({ npcId, enabled: true });
if (!profile) {
return null;
}
const ruleCount = await this.dataSource
.getRepository(ExchangeRule)
.countBy({ profileId: profile.id, enabled: true });
return ruleCount > 0 ? profile : null;
}
/**
* Records that this character has now spoken to this NPC (spec §7).
*
* `firstMetAt` is written once and never overwritten. The `met` flag it
* sets alongside is what a greeting node conditions on, which keeps "have
* we met before" in the same FLAG_SET vocabulary as every other gate rather
* than inventing a second mechanism for one line of dialogue.
*/
private async touchNpcState(
characterId: string,
npcId: string,
): Promise<void> {
const states = this.dataSource.getRepository(CharacterNpcState);
const existing = await states.findOneBy({ characterId, npcId });
const now = new Date();
if (existing) {
existing.lastInteractionAt = now;
existing.flags = { ...existing.flags, met: true };
await states.save(existing);
return;
}
await states.save(
states.create({
characterId,
npcId,
firstMetAt: now,
lastInteractionAt: now,
flags: { met: true },
}),
);
}
}

View File

@@ -0,0 +1,122 @@
import type { GameCondition } from '../conditions/game-condition.types';
/**
* What an NPC can do (NPC spec §5).
*
* Deliberately descriptive rather than behavioural: the capability list drives
* presentation and lets content declare intent, while the actual function
* comes from linked data (a shop row, an exchange profile). Nothing in the
* services branches on a capability to decide whether an action works --
* that is decided by whether the backing data exists (spec §5, §37.12).
*/
export enum NpcCapability {
DIALOGUE = 'DIALOGUE',
QUEST_GIVER = 'QUEST_GIVER',
QUEST_TURN_IN = 'QUEST_TURN_IN',
MERCHANT = 'MERCHANT',
REPUTATION_MERCHANT = 'REPUTATION_MERCHANT',
RESOURCE_EXCHANGE = 'RESOURCE_EXCHANGE',
BAG_MERCHANT = 'BAG_MERCHANT',
LORE = 'LORE',
TRAINER = 'TRAINER',
TRAVEL = 'TRAVEL',
SERVICE = 'SERVICE',
EVENT = 'EVENT',
}
/**
* Actions a dialogue node may trigger (spec §13).
*
* START_QUEST and COMPLETE_QUEST are part of the V1 vocabulary but have no
* quest system behind them yet (Slice 0.9). They are listed so content and
* the stored enum do not need rewriting later; `NpcService` refuses to offer
* an action it cannot actually carry out.
*/
export enum DialogueActionType {
OPEN_SHOP = 'OPEN_SHOP',
OPEN_EXCHANGE = 'OPEN_EXCHANGE',
START_QUEST = 'START_QUEST',
COMPLETE_QUEST = 'COMPLETE_QUEST',
GRANT_ITEM = 'GRANT_ITEM',
SET_FLAG = 'SET_FLAG',
}
/** Dialogue actions this build can carry out. Everything else is inert. */
export const SUPPORTED_DIALOGUE_ACTIONS: ReadonlySet<DialogueActionType> =
new Set([
DialogueActionType.OPEN_SHOP,
DialogueActionType.OPEN_EXCHANGE,
DialogueActionType.SET_FLAG,
]);
export interface DialogueAction {
type: DialogueActionType;
/** Target of the action: a shop key, an exchange profile key, a flag name. */
key?: string;
value?: string | number | boolean;
}
/** A selectable reply on a dialogue node (spec §12). */
export interface DialogueResponseContent {
key: string;
text: string;
targetNodeKey?: string;
conditions?: GameCondition[];
actions?: DialogueAction[];
}
export interface NpcSummaryDto {
id: string;
key: string;
name: string;
title: string | null;
portraitPath: string;
/** Markers the local view shows next to the NPC (spec §24). */
markers: NpcMarker[];
}
/**
* Presentation markers (spec §24).
*
* Only markers backed by a real, currently available interaction are emitted.
* The view does not turn every capability into a permanent symbol.
*/
export type NpcMarker =
'MERCHANT' | 'EXCHANGE' | 'QUEST_AVAILABLE' | 'QUEST_TURN_IN';
export interface DialogueResponseDto {
key: string;
text: string;
targetNodeKey: string | null;
}
export interface DialogueNodeDto {
key: string;
text: string;
responses: DialogueResponseDto[];
}
export type NpcActionType =
'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE' | 'VIEW_QUESTS';
export interface NpcActionDto {
type: NpcActionType;
label: string;
/** Shop key or exchange profile key the action targets. */
key: string | null;
}
export interface NpcInteractionDto {
npc: {
id: string;
key: string;
name: string;
title: string | null;
description: string | null;
portraitPath: string;
artworkPath: string | null;
capabilities: NpcCapability[];
};
dialogue: DialogueNodeDto | null;
availableActions: NpcActionDto[];
}

View File

@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module';
import { ExchangeRule } from '../exchanges/entities/exchange-rule.entity';
import { NpcExchangeProfile } from '../exchanges/entities/npc-exchange-profile.entity';
import { NpcShop } from '../shops/entities/npc-shop.entity';
import { CharacterNpcState } from './entities/character-npc-state.entity';
import { DialogueNode } from './entities/dialogue-node.entity';
import { NpcDefinition } from './entities/npc-definition.entity';
import { NpcController } from './npc.controller';
import { NpcService } from './npc.service';
/**
* People in the world (NPC spec §29).
*
* Reads shop and exchange rows to decide which actions an NPC currently
* offers, but owns neither transaction -- purchases and trade-ins belong to
* their own services (spec §30). Only the entities are imported here, so
* there is no module cycle with those features.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterNpcState,
DialogueNode,
NpcDefinition,
NpcExchangeProfile,
NpcShop,
ExchangeRule,
]),
ConditionsModule,
],
controllers: [NpcController],
providers: [NpcService],
exports: [NpcService],
})
export class NpcsModule {}

View File

@@ -3,11 +3,7 @@ import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity'; import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity';
import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity'; import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity';
import { import { RENOWN_BASE_STATS, RENOWN_MAX, RENOWN_MIN } from './renown-base-stats';
RENOWN_BASE_STATS,
RENOWN_MAX,
RENOWN_MIN,
} from './renown-base-stats';
import { import {
characterNotFound, characterNotFound,
renownMilestoneAlreadyCompleted, renownMilestoneAlreadyCompleted,

View File

@@ -61,7 +61,10 @@ export class ReputationService {
throw characterNotFound(); throw characterNotFound();
} }
const faction = await factions.findOneBy({ key: factionKey, enabled: true }); const faction = await factions.findOneBy({
key: factionKey,
enabled: true,
});
if (!faction) { if (!faction) {
throw reputationFactionNotFound(); throw reputationFactionNotFound();
} }

View File

@@ -6,9 +6,15 @@ import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity';
import { ItemRarity } from '../items/item-rarity.enum'; import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum'; import { ItemType } from '../items/item-type.enum';
import { LootCategory } from '../items/loot-category.enum';
import { LootService } from '../loot/loot.service'; import { LootService } from '../loot/loot.service';
import {
DEFAULT_LOOT_CAPACITY,
LootCapacityBudget,
LootCapacityService,
} from '../loot-bags/loot-capacity.service';
import type { LootCapacityDto } from '../loot-bags/loot-capacity.service';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import type { RandomSource } from '../shared/random-source';
import { CombatRewardService } from './combat-reward.service'; import { CombatRewardService } from './combat-reward.service';
import { CombatReward } from './entities/combat-reward.entity'; import { CombatReward } from './entities/combat-reward.entity';
import { CombatRewardItem } from './entities/combat-reward-item.entity'; import { CombatRewardItem } from './entities/combat-reward-item.entity';
@@ -22,6 +28,8 @@ const ASH_RAT_TABLE = '60000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002'; const ROAD_BANDIT_TABLE = '60000000-0000-4000-8000-000000000002';
const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002'; const BANDIT_BLADE = '50000000-0000-4000-8000-000000000002';
const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001'; const BANDIT_HOOD = '50000000-0000-4000-8000-000000000001';
const ASH_PELT = '50000000-0000-4000-8000-00000000000c';
const TOUGH_HIDE = '50000000-0000-4000-8000-00000000000e';
interface State { interface State {
characters: Character[]; characters: Character[];
@@ -142,15 +150,11 @@ function createState(overrides: Partial<State> = {}): State {
{ {
id: ASH_RAT_ID, id: ASH_RAT_ID,
key: 'ash-rat', key: 'ash-rat',
silverMin: 4,
silverMax: 7,
lootTableId: ASH_RAT_TABLE, lootTableId: ASH_RAT_TABLE,
} as MonsterDefinition, } as MonsterDefinition,
{ {
id: ROAD_BANDIT_ID, id: ROAD_BANDIT_ID,
key: 'road-bandit', key: 'road-bandit',
silverMin: 9,
silverMax: 15,
lootTableId: ROAD_BANDIT_TABLE, lootTableId: ROAD_BANDIT_TABLE,
} as MonsterDefinition, } as MonsterDefinition,
], ],
@@ -161,8 +165,27 @@ function createState(overrides: Partial<State> = {}): State {
name: 'Bandit Blade', name: 'Bandit Blade',
type: ItemType.EQUIPMENT, type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
lootCategory: null,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
} as ItemDefinition, } as ItemDefinition,
{
id: ASH_PELT,
key: 'ash-pelt',
name: 'Ashen Pelt',
type: ItemType.TRADE_GOOD,
rarity: ItemRarity.COMMON,
lootCategory: LootCategory.HIDE,
iconPath: '/images/items/ash-pelt.png',
} as ItemDefinition,
{
id: TOUGH_HIDE,
key: 'tough-hide',
name: 'Tough Hide',
type: ItemType.TRADE_GOOD,
rarity: ItemRarity.COMMON,
lootCategory: LootCategory.HIDE,
iconPath: '/images/items/tough-hide.png',
} as ItemDefinition,
], ],
characterItems: [], characterItems: [],
combatRewards: [], combatRewards: [],
@@ -187,16 +210,49 @@ function fakeLoot(
} as unknown as LootService; } as unknown as LootService;
} }
function fixedRandom(value: number): RandomSource { /**
return { next: () => value }; * A capacity service with hand-set free room per category.
*
* Hands out a real `LootCapacityBudget`, so these tests exercise the actual
* spend-down arithmetic rather than a mock of it. Where the numbers come from
* -- bags, owned items -- is `LootCapacityService`'s own spec.
*/
function fakeCapacity(
free: Partial<Record<LootCategory, number>> = {},
): LootCapacityService {
const roomFor = (category: LootCategory) =>
free[category] ?? DEFAULT_LOOT_CAPACITY;
return {
createBudget: () =>
Promise.resolve(
new LootCapacityBudget(
new Map(
Object.values(LootCategory).map((category) => [
category,
roomFor(category),
]),
),
),
),
getCapacities: (): Promise<LootCapacityDto[]> =>
Promise.resolve(
Object.values(LootCategory).map((category) => ({
category,
current: 0,
capacity: roomFor(category),
bag: null,
})),
),
} as unknown as LootCapacityService;
} }
function service( function service(
state: State, state: State,
loot: LootService = fakeLoot(), loot: LootService = fakeLoot(),
random: RandomSource = fixedRandom(0.5), capacity: LootCapacityService = fakeCapacity({ [LootCategory.HIDE]: 99 }),
): CombatRewardService { ): CombatRewardService {
return new CombatRewardService({} as never, loot, random); return new CombatRewardService({} as never, loot, capacity);
} }
describe('CombatRewardService', () => { describe('CombatRewardService', () => {
@@ -211,7 +267,6 @@ describe('CombatRewardService', () => {
), ),
).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' });
expect(state.combatRewards).toHaveLength(0); expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].silver).toBe(3);
}); });
it('rejects a LOST combat', async () => { it('rejects a LOST combat', async () => {
@@ -234,53 +289,54 @@ describe('CombatRewardService', () => {
combat(), combat(),
); );
expect(reward).toEqual({ silver: 6, items: [] }); expect(reward.items).toEqual([]);
expect(state.combatRewards).toHaveLength(1); expect(state.combatRewards).toHaveLength(1);
}); });
}); });
describe('Ash Rat', () => { describe('Ash Rat', () => {
it('grants a silver roll inside 4-7, persisted on the character', async () => { it('grants the guaranteed Ashen Pelt the loot table rolled', async () => {
const state = createState(); const state = createState();
const reward = await service( const reward = await service(
state, state,
fakeLoot(), fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fixedRandom(0),
).grantVictoryRewards(fakeManager(state), combat()); ).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(4); expect(reward.items).toEqual([
expect(state.characters[0].silver).toBe(7); {
characterItemId: state.characterItems[0].id,
item: {
key: 'ash-pelt',
name: 'Ashen Pelt',
type: ItemType.TRADE_GOOD,
rarity: ItemRarity.COMMON,
iconPath: '/images/items/ash-pelt.png',
lootCategory: LootCategory.HIDE,
},
quantity: 1,
quantityLeftBehind: 0,
},
]);
}); });
it('rolls the top of the silver range from the top of the random range', async () => { // Playable Slice 0.7 V2 §7: a normal kill hands over goods, never currency.
it('grants no Silver for a normal kill', async () => {
const state = createState(); const state = createState();
const reward = await service( const reward = await service(
state, state,
fakeLoot(), fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fixedRandom(0.99),
).grantVictoryRewards(fakeManager(state), combat()); ).grantVictoryRewards(fakeManager(state), combat());
expect(reward.silver).toBe(7); expect(reward).not.toHaveProperty('silver');
expect(state.characters[0].silver).toBe(3);
}); });
}); });
describe('Road Bandit', () => { describe('Road Bandit', () => {
const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID }); const banditCombat = combat({ monsterDefinitionId: ROAD_BANDIT_ID });
it('grants a silver roll inside 9-15', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot(),
fixedRandom(0),
).grantVictoryRewards(fakeManager(state), banditCombat);
expect(reward.silver).toBe(9);
});
it('persists a dropped Bandit Blade as a CharacterItem and references it in the reward', async () => { it('persists a dropped Bandit Blade as a CharacterItem and references it in the reward', async () => {
const state = createState(); const state = createState();
@@ -302,10 +358,13 @@ describe('CombatRewardService', () => {
item: { item: {
key: 'bandit-blade', key: 'bandit-blade',
name: 'Bandit Blade', name: 'Bandit Blade',
type: ItemType.EQUIPMENT,
rarity: ItemRarity.COMMON, rarity: ItemRarity.COMMON,
iconPath: '/images/items/bandit-blade.png', iconPath: '/images/items/bandit-blade.png',
lootCategory: null,
}, },
quantity: 1, quantity: 1,
quantityLeftBehind: 0,
}, },
]); ]);
expect(state.combatRewardItems).toHaveLength(1); expect(state.combatRewardItems).toHaveLength(1);
@@ -348,25 +407,163 @@ describe('CombatRewardService', () => {
}); });
}); });
it('grants no renown for a normal monster kill', async () => { it('grants neither renown nor Silver for a normal monster kill', async () => {
const state = createState(); const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const subject = service(state, loot, fixedRandom(0)); const subject = service(state, loot);
await subject.grantVictoryRewards(fakeManager(state), combat()); await subject.grantVictoryRewards(fakeManager(state), combat());
// Renown comes from milestones only (spec §36). Killing things must never // Renown comes from milestones and Silver from merchant exchange only
// move it -- that is the whole point of replacing XP with Renown, so this // (slice 0.6.5 spec §36, slice 0.7 V2 §7). Killing things must move
// asserts the prohibition rather than trusting that nothing wired it up. // neither -- that is the whole point of the new progression model, so this
// asserts the prohibition rather than trusting nothing wired it up.
expect(state.characters[0].renown).toBe(4); expect(state.characters[0].renown).toBe(4);
expect(state.characters[0].silver).toBe(7); expect(state.characters[0].silver).toBe(3);
});
describe('loot-bag capacity (slice 0.7.5 §9, §10)', () => {
it('carries the first pelt when the bagless default of 1 is all there is', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fakeCapacity(),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.items[0]).toMatchObject({
quantity: 1,
quantityLeftBehind: 0,
});
expect(state.characterItems[0].quantity).toBe(1);
});
it('leaves a pelt behind once the category is full', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fakeCapacity({ [LootCategory.HIDE]: 0 }),
).grantVictoryRewards(fakeManager(state), combat());
expect(reward.items[0]).toMatchObject({
characterItemId: null,
quantity: 0,
quantityLeftBehind: 1,
});
// Nothing was carried, so no stack may have been created.
expect(state.characterItems).toHaveLength(0);
});
it('keeps the victory and its reward record valid when nothing fit', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fakeCapacity({ [LootCategory.HIDE]: 0 }),
).grantVictoryRewards(fakeManager(state), combat());
// §9: a full bag must never invalidate the win. The reward exists, and
// the refused drop is persisted so a refresh can still explain it.
expect(state.combatRewards).toHaveLength(1);
expect(state.combatRewardItems).toHaveLength(1);
expect(state.combatRewardItems[0]).toMatchObject({
quantity: 0,
quantityLeftBehind: 1,
});
expect(reward.items).toHaveLength(1);
});
it('grants only what fits and reports the rest', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: TOUGH_HIDE, quantity: 2 }),
fakeCapacity({ [LootCategory.HIDE]: 1 }),
).grantVictoryRewards(fakeManager(state), combat());
// §10: HIDE 4/5 with a Tough Hide x2 reward grants 1, leaves 1.
expect(reward.items[0]).toMatchObject({
quantity: 1,
quantityLeftBehind: 1,
});
expect(state.characterItems[0].quantity).toBe(1);
});
it('spends one budget across two goods of the same category', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot(
{ itemDefinitionId: ASH_PELT, quantity: 1 },
{ itemDefinitionId: TOUGH_HIDE, quantity: 1 },
),
fakeCapacity({ [LootCategory.HIDE]: 1 }),
).grantVictoryRewards(fakeManager(state), combat());
// Both are HIDE and only one slot is free, so the second must not slip
// through the same gap the first already took.
const byKey = new Map(reward.items.map((i) => [i.item.key, i]));
expect(byKey.get('ash-pelt')).toMatchObject({
quantity: 1,
quantityLeftBehind: 0,
});
expect(byKey.get('tough-hide')).toMatchObject({
quantity: 0,
quantityLeftBehind: 1,
});
});
it('still grants equipment when the trade-good category is full', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot(
{ itemDefinitionId: ASH_PELT, quantity: 1 },
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 },
),
fakeCapacity({ [LootCategory.HIDE]: 0 }),
).grantVictoryRewards(fakeManager(state), combat());
// §9: equipment must never be lost because the hide bag is full.
const blade = reward.items.find((i) => i.item.key === 'bandit-blade');
expect(blade).toMatchObject({ quantity: 1, quantityLeftBehind: 0 });
expect(state.characterItems).toEqual([
expect.objectContaining({
itemDefinitionId: BANDIT_BLADE,
quantity: 1,
}),
]);
});
it('reports the carrying state alongside the reward', async () => {
const state = createState();
const reward = await service(
state,
fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }),
fakeCapacity({ [LootCategory.HIDE]: 5 }),
).grantVictoryRewards(fakeManager(state), combat());
// §11: the victory screen must be able to show capacity without a
// second request.
expect(reward.capacities.map((entry) => entry.category)).toEqual(
Object.values(LootCategory),
);
});
}); });
describe('idempotency', () => { describe('idempotency', () => {
it('grants once and returns the same persisted reward on a repeat call', async () => { it('grants once and returns the same persisted reward on a repeat call', async () => {
const state = createState(); const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const subject = service(state, loot, fixedRandom(0)); const subject = service(state, loot);
const manager = fakeManager(state); const manager = fakeManager(state);
const first = await subject.grantVictoryRewards(manager, combat()); const first = await subject.grantVictoryRewards(manager, combat());
@@ -377,7 +574,6 @@ describe('CombatRewardService', () => {
expect(state.combatRewardItems).toHaveLength(1); expect(state.combatRewardItems).toHaveLength(1);
expect(state.characterItems).toHaveLength(1); expect(state.characterItems).toHaveLength(1);
expect(state.characterItems[0].quantity).toBe(1); expect(state.characterItems[0].quantity).toBe(1);
expect(state.characters[0].silver).toBe(7);
expect(loot.rollLoot).toHaveBeenCalledTimes(1); expect(loot.rollLoot).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -394,7 +590,7 @@ describe('CombatRewardService', () => {
it('replays the persisted reward without rerolling', async () => { it('replays the persisted reward without rerolling', async () => {
const state = createState(); const state = createState();
const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 });
const subject = service(state, loot, fixedRandom(0)); const subject = service(state, loot);
const manager = fakeManager(state); const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, combat()); const granted = await subject.grantVictoryRewards(manager, combat());
@@ -411,7 +607,7 @@ describe('CombatRewardService', () => {
const subject = new CombatRewardService( const subject = new CombatRewardService(
dataSource as never, dataSource as never,
loot, loot,
fixedRandom(0), fakeCapacity({ [LootCategory.HIDE]: 99 }),
); );
const manager = fakeManager(state); const manager = fakeManager(state);
@@ -439,7 +635,7 @@ describe('CombatRewardService', () => {
// Every rolled item definition is resolved before any mutation, so a // Every rolled item definition is resolved before any mutation, so a
// missing one must leave no reward row and no character grant behind. // missing one must leave no reward row and no character grant behind.
expect(state.combatRewards).toHaveLength(0); expect(state.combatRewards).toHaveLength(0);
expect(state.characters[0].silver).toBe(3); expect(state.characterItems).toHaveLength(0);
}); });
}); });
@@ -473,7 +669,7 @@ describe('CombatRewardService', () => {
{ itemDefinitionId: BANDIT_BLADE, quantity: 1 }, { itemDefinitionId: BANDIT_BLADE, quantity: 1 },
{ itemDefinitionId: BANDIT_HOOD, quantity: 1 }, { itemDefinitionId: BANDIT_HOOD, quantity: 1 },
); );
const subject = service(state, loot, fixedRandom(0)); const subject = service(state, loot);
const manager = fakeManager(state); const manager = fakeManager(state);
const granted = await subject.grantVictoryRewards(manager, banditCombat); const granted = await subject.grantVictoryRewards(manager, banditCombat);

View File

@@ -1,34 +1,53 @@
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CombatStatus } from '../combat/combat-status.enum'; import { CombatStatus } from '../combat/combat-status.enum';
import { Combat } from '../combat/entities/combat.entity'; import { Combat } from '../combat/entities/combat.entity';
import { CharacterItem } from '../items/entities/character-item.entity'; import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity';
import { ItemRarity } from '../items/item-rarity.enum'; import { ItemRarity } from '../items/item-rarity.enum';
import { ItemType } from '../items/item-type.enum';
import { LootCategory } from '../items/loot-category.enum';
import { LootService } from '../loot/loot.service'; import { LootService } from '../loot/loot.service';
import { LootCapacityService } from '../loot-bags/loot-capacity.service';
import type { LootCapacityDto } from '../loot-bags/loot-capacity.service';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { RANDOM_SOURCE } from '../shared/random-source';
import type { RandomSource } from '../shared/random-source';
import { rollInclusive } from '../shared/roll-range';
import { CombatReward } from './entities/combat-reward.entity'; import { CombatReward } from './entities/combat-reward.entity';
import { CombatRewardItem } from './entities/combat-reward-item.entity'; import { CombatRewardItem } from './entities/combat-reward-item.entity';
import { combatNotWon, rewardStateInvalid } from './rewards.errors'; import { combatNotWon, rewardStateInvalid } from './rewards.errors';
export interface CombatRewardItemDto { export interface CombatRewardItemDto {
characterItemId: string; /** Null when the whole drop was left behind — no stack was created. */
characterItemId: string | null;
item: { item: {
key: string; key: string;
name: string; name: string;
// The loot summary groups by this (Trade Goods / Equipment /
// Consumables, 0.7 §9) instead of hard-coding item keys in the UI.
type: ItemType;
rarity: ItemRarity; rarity: ItemRarity;
iconPath: string; iconPath: string;
/** The carrying bucket this counts against; null when uncapped. */
lootCategory: LootCategory | null;
}; };
/** How much reached the character. 0 when the bag was already full. */
quantity: number; quantity: number;
/** How much the bag refused, so the summary can say so (0.7.5 §9, §10). */
quantityLeftBehind: number;
} }
/**
* What a victory actually hands over (0.7 §7, 0.7.5 §11).
*
* Items only. A normal kill grants no XP, Silver, regional reputation or
* World Renown -- those reach the player through merchant exchange and
* milestones instead.
*
* `capacities` is the carrying state *after* this reward, so the victory
* screen can show the player they are now full without a second request.
*/
export interface CombatRewardDto { export interface CombatRewardDto {
silver: number;
items: CombatRewardItemDto[]; items: CombatRewardItemDto[];
capacities: LootCapacityDto[];
} }
// Both DataSource and EntityManager expose this; naming it keeps the read path // Both DataSource and EntityManager expose this; naming it keeps the read path
@@ -40,7 +59,7 @@ export class CombatRewardService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly lootService: LootService, private readonly lootService: LootService,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource, private readonly lootCapacity: LootCapacityService,
) {} ) {}
/** /**
@@ -50,8 +69,12 @@ export class CombatRewardService {
* already holds a pessimistic write lock on the combat row — so either * already holds a pessimistic write lock on the combat row — so either
* everything below commits or nothing does. * everything below commits or nothing does.
* *
* Roll order is fixed: silver first, then the loot table in `position` * The loot table is rolled in `position` order and nothing else is rolled:
* order. Tests depend on it. * a normal victory grants no Silver (0.7 §7). Tests depend on that order.
*
* A full loot bag never invalidates the victory (0.7.5 §9): the roll still
* happens, equipment still lands, and only the trade goods that do not fit
* are recorded as left behind.
*/ */
async grantVictoryRewards( async grantVictoryRewards(
manager: EntityManager, manager: EntityManager,
@@ -75,17 +98,12 @@ export class CombatRewardService {
throw rewardStateInvalid(); throw rewardStateInvalid();
} }
const silver = rollInclusive(
this.randomSource,
monster.silverMin,
monster.silverMax,
);
const roll = await this.lootService.rollLoot(monster.lootTableId, manager); const roll = await this.lootService.rollLoot(monster.lootTableId, manager);
// Resolve every rolled item definition up front, before any mutation, so // Resolve every rolled item definition up front, before any mutation, so
// a missing definition throws `rewardStateInvalid()` before the // a missing definition throws `rewardStateInvalid()` before a
// character's silver is touched or a `CombatReward` row is created. // `CombatReward` row or any character item is created. This keeps a
// This keeps a failed grant from leaving partial writes behind. // failed grant from leaving partial writes behind.
const definitions = manager.getRepository(ItemDefinition); const definitions = manager.getRepository(ItemDefinition);
const resolvedDefinitions = new Map<string, ItemDefinition>(); const resolvedDefinitions = new Map<string, ItemDefinition>();
for (const rolled of roll.items) { for (const rolled of roll.items) {
@@ -101,22 +119,13 @@ export class CombatRewardService {
resolvedDefinitions.set(rolled.itemDefinitionId, definition); resolvedDefinitions.set(rolled.itemDefinitionId, definition);
} }
const characters = manager.getRepository(Character); // No character row is touched here any more: a victory grants items only
const character = await characters.findOne({ // (spec §7). `CombatService.performAction` already holds the character
where: { id: combat.characterId }, // lock for the rest of the round.
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw rewardStateInvalid();
}
character.silver += silver;
await characters.save(character);
const reward = await rewards.save( const reward = await rewards.save(
rewards.create({ rewards.create({
combatId: combat.id, combatId: combat.id,
characterId: combat.characterId, characterId: combat.characterId,
silverGranted: silver,
}), }),
); );
@@ -127,9 +136,26 @@ export class CombatRewardService {
dto: CombatRewardItemDto; dto: CombatRewardItemDto;
}> = []; }> = [];
// One budget for the whole reward, spent down item by item, so two hides
// in the same drop cannot both claim the last free slot (0.7.5 §10).
// Taken inside the caller's transaction, which already holds the
// character lock, so a concurrent fight cannot fill the bag underneath us.
const budget = await this.lootCapacity.createBudget(
combat.characterId,
manager,
);
for (const rolled of roll.items) { for (const rolled of roll.items) {
const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!; const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!;
const grantedQuantity = budget.take(
definition.lootCategory,
rolled.quantity,
);
const leftBehind = rolled.quantity - grantedQuantity;
let characterItem: CharacterItem | null = null;
if (grantedQuantity > 0) {
const existingStack = await characterItems.findOne({ const existingStack = await characterItems.findOne({
where: { where: {
characterId: combat.characterId, characterId: combat.characterId,
@@ -137,30 +163,37 @@ export class CombatRewardService {
}, },
lock: { mode: 'pessimistic_write' }, lock: { mode: 'pessimistic_write' },
}); });
// Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28). // Duplicates stack; Slice 0.4 adds no duplicate protection (0.4 §28).
const characterItem = existingStack characterItem = existingStack
? Object.assign(existingStack, { ? Object.assign(existingStack, {
quantity: existingStack.quantity + rolled.quantity, quantity: existingStack.quantity + grantedQuantity,
}) })
: characterItems.create({ : characterItems.create({
characterId: combat.characterId, characterId: combat.characterId,
itemDefinitionId: rolled.itemDefinitionId, itemDefinitionId: rolled.itemDefinitionId,
quantity: rolled.quantity, quantity: grantedQuantity,
}); });
await characterItems.save(characterItem); await characterItems.save(characterItem);
}
await rewardItems.save( await rewardItems.save(
rewardItems.create({ rewardItems.create({
combatRewardId: reward.id, combatRewardId: reward.id,
characterItemId: characterItem.id, characterItemId: characterItem?.id ?? null,
itemDefinitionId: definition.id, itemDefinitionId: definition.id,
quantity: rolled.quantity, quantity: grantedQuantity,
quantityLeftBehind: leftBehind,
}), }),
); );
granted.push({ granted.push({
itemDefinitionId: rolled.itemDefinitionId, itemDefinitionId: rolled.itemDefinitionId,
dto: this.toItemDto(characterItem.id, definition, rolled.quantity), dto: this.toItemDto(
characterItem?.id ?? null,
definition,
grantedQuantity,
leftBehind,
),
}); });
} }
@@ -170,9 +203,14 @@ export class CombatRewardService {
granted.sort((a, b) => granted.sort((a, b) =>
a.itemDefinitionId.localeCompare(b.itemDefinitionId), a.itemDefinitionId.localeCompare(b.itemDefinitionId),
); );
const items = granted.map((entry) => entry.dto);
return { silver, items }; return {
items: granted.map((entry) => entry.dto),
capacities: await this.lootCapacity.getCapacities(
combat.characterId,
manager,
),
};
} }
/** Reads a persisted reward so a refresh replays it (spec §25, §48). */ /** Reads a persisted reward so a refresh replays it (spec §25, §48). */
@@ -215,32 +253,42 @@ export class CombatRewardService {
rewardItem.characterItemId, rewardItem.characterItemId,
definition, definition,
rewardItem.quantity, rewardItem.quantity,
rewardItem.quantityLeftBehind,
), ),
); );
} }
return { return {
silver: reward.silverGranted,
items, items,
// Read live rather than snapshotted: a replay should show what the
// character can carry now, which is what the player acts on.
capacities: await this.lootCapacity.getCapacities(
reward.characterId,
scope,
),
}; };
} }
private toItemDto( private toItemDto(
characterItemId: string, characterItemId: string | null,
definition: ItemDefinition, definition: ItemDefinition,
quantity: number, quantity: number,
quantityLeftBehind: number,
): CombatRewardItemDto { ): CombatRewardItemDto {
// Drop chance, roll results, and loot-table ids never leave the server // Drop chance, roll results, and loot-table ids never leave the server
// (spec §26). // (0.4 §26).
return { return {
characterItemId, characterItemId,
item: { item: {
key: definition.key, key: definition.key,
name: definition.name, name: definition.name,
type: definition.type,
rarity: definition.rarity, rarity: definition.rarity,
iconPath: definition.iconPath, iconPath: definition.iconPath,
lootCategory: definition.lootCategory,
}, },
quantity, quantity,
quantityLeftBehind,
}; };
} }
} }

View File

@@ -17,6 +17,10 @@ import { CombatReward } from './combat-reward.entity';
* Needed because `CharacterItem.quantity` is a running stack total: after a * Needed because `CharacterItem.quantity` is a running stack total: after a
* duplicate drop it no longer says how much *this* victory granted, and a * duplicate drop it no longer says how much *this* victory granted, and a
* refreshed reward screen must replay the original result (spec §25, §48). * refreshed reward screen must replay the original result (spec §25, §48).
*
* Since Playable Slice 0.7.5 a row also records what did *not* fit: loot
* refused by a full bag is still part of what happened in this fight, and the
* summary has to say so after a refresh as much as immediately (0.7.5 §9).
*/ */
@Entity({ name: 'combat_reward_items' }) @Entity({ name: 'combat_reward_items' })
@Index('IDX_combat_reward_items_reward', ['combatRewardId']) @Index('IDX_combat_reward_items_reward', ['combatRewardId'])
@@ -34,15 +38,23 @@ export class CombatRewardItem {
@Column({ name: 'combat_reward_id', type: 'uuid' }) @Column({ name: 'combat_reward_id', type: 'uuid' })
combatRewardId!: string; combatRewardId!: string;
@Column({ name: 'character_item_id', type: 'uuid' }) // Null when the whole drop was left behind: no stack was created, so there
characterItemId!: string; // is no character item to point at (0.7.5 §9). Also nulled later if the
// stack is spent -- see the relation below.
@Column({ name: 'character_item_id', type: 'uuid', nullable: true })
characterItemId!: string | null;
@Column({ name: 'item_definition_id', type: 'uuid' }) @Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string; itemDefinitionId!: string;
/** How much actually reached the character. May be 0 on a full bag. */
@Column({ name: 'quantity', type: 'integer' }) @Column({ name: 'quantity', type: 'integer' })
quantity!: number; quantity!: number;
/** How much the bag refused. 0 for everything that fit (0.7.5 §10). */
@Column({ name: 'quantity_left_behind', type: 'integer', default: 0 })
quantityLeftBehind!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;
@@ -50,9 +62,13 @@ export class CombatRewardItem {
@JoinColumn({ name: 'combat_reward_id' }) @JoinColumn({ name: 'combat_reward_id' })
combatReward!: CombatReward; combatReward!: CombatReward;
@ManyToOne(() => CharacterItem, { onDelete: 'RESTRICT' }) // SET NULL rather than RESTRICT: Slice 0.8 introduced the first code path
// that consumes a stack entirely (trading the last pelt to a merchant), and
// RESTRICT made that fail outright. The reward record still says what
// dropped; it just no longer points at a stack that is gone.
@ManyToOne(() => CharacterItem, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'character_item_id' }) @JoinColumn({ name: 'character_item_id' })
characterItem!: CharacterItem; characterItem!: CharacterItem | null;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' }) @JoinColumn({ name: 'item_definition_id' })

View File

@@ -13,6 +13,9 @@ import { Combat } from '../../combat/entities/combat.entity';
/** /**
* Proof that one combat has already been rewarded (spec §8). * Proof that one combat has already been rewarded (spec §8).
* *
* Carries no currency column: a normal victory grants items only, never
* Silver (Playable Slice 0.7 V2 spec §7).
*
* The unique index on `combatId` is the database half of the idempotency * The unique index on `combatId` is the database half of the idempotency
* invariant; `CombatRewardService` is the service half. * invariant; `CombatRewardService` is the service half.
*/ */
@@ -29,9 +32,6 @@ export class CombatReward {
@Column({ name: 'character_id', type: 'uuid' }) @Column({ name: 'character_id', type: 'uuid' })
characterId!: string; characterId!: string;
@Column({ name: 'silver_granted', type: 'integer' })
silverGranted!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; createdAt!: Date;

View File

@@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity'; import { CharacterItem } from '../items/entities/character-item.entity';
import { ItemDefinition } from '../items/entities/item-definition.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity';
import { LootModule } from '../loot/loot.module'; import { LootModule } from '../loot/loot.module';
import { LootBagsModule } from '../loot-bags/loot-bags.module';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source';
import { CombatRewardService } from './combat-reward.service'; import { CombatRewardService } from './combat-reward.service';
@@ -21,6 +22,7 @@ import { CombatRewardItem } from './entities/combat-reward-item.entity';
CombatRewardItem, CombatRewardItem,
]), ]),
LootModule, LootModule,
LootBagsModule,
], ],
providers: [ providers: [
CombatRewardService, CombatRewardService,

View File

@@ -0,0 +1,15 @@
import { IsInt, IsString, Max, Min } from 'class-validator';
export class ShopPurchaseDto {
@IsString()
itemKey!: string;
/**
* How many lots to buy. Bounded so one request cannot ask for a quantity
* whose price overflows before the affordability check can reject it.
*/
@IsInt()
@Min(1)
@Max(99)
quantity!: number;
}

View File

@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
/**
* A shop belonging to an NPC (NPC spec §15).
*
* A shop is a thing an NPC *has*, not a kind of NPC it *is*, which is why an
* NPC may own none, one, or later several (spec §15, §37.1).
*/
@Entity({ name: 'npc_shops' })
@Index('IDX_npc_shops_key', ['key'], { unique: true })
@Index('IDX_npc_shops_npc', ['npcId'])
export class NpcShop {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'npc_id', type: 'uuid' })
npcId!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcDefinition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'npc_id' })
npc!: NpcDefinition;
}

View File

@@ -0,0 +1,77 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type { GameCondition } from '../../conditions/game-condition.types';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { NpcShop } from './npc-shop.entity';
/**
* One thing a shop sells (NPC spec §15, §16).
*
* Availability rides on the offer's own conditions rather than on a separate
* shop per reputation rank (spec §16). Slice 0.8 seeds no conditions at all --
* gated offers are Slice 0.8.5 -- but the column exists now so that slice is
* content work rather than a schema change.
*/
@Entity({ name: 'shop_offers' })
@Index('IDX_shop_offers_shop_item', ['shopId', 'itemDefinitionId'], {
unique: true,
})
export class ShopOffer {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'shop_id', type: 'uuid' })
shopId!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
/**
* Which currency `price` is denominated in. Only SILVER exists today; the
* column keeps later currencies (spec §16 mentions Dämmermarken) from
* needing a migration to the price column itself.
*/
@Column({ name: 'currency_type', type: 'varchar', length: 50 })
currencyType!: string;
@Column({ name: 'price', type: 'integer' })
price!: number;
/** How many the offer sells at once. */
@Column({ name: 'quantity', type: 'integer', default: 1 })
quantity!: number;
@Column({ name: 'repeatable', type: 'boolean', default: true })
repeatable!: boolean;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder!: number;
@Column({ name: 'conditions', type: 'jsonb', default: () => "'[]'::jsonb" })
conditions!: GameCondition[];
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => NpcShop, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'shop_id' })
shop!: NpcShop;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { ShopPurchaseDto } from './dto/shop-purchase.dto';
import {
ShopPurchaseResultDto,
ShopService,
ShopViewDto,
} from './shop.service';
/** Shop endpoints for one merchant (NPC spec §22). */
@Controller('merchants/:merchantKey/shop')
export class ShopController {
constructor(private readonly shopService: ShopService) {}
@Get()
getShop(@Param('merchantKey') merchantKey: string): Promise<ShopViewDto> {
return this.shopService.getShopView(DEMO_CHARACTER_ID, merchantKey);
}
@Post('purchase')
purchase(
@Param('merchantKey') merchantKey: string,
@Body() request: ShopPurchaseDto,
): Promise<ShopPurchaseResultDto> {
return this.shopService.purchase(
DEMO_CHARACTER_ID,
merchantKey,
request.itemKey,
request.quantity,
);
}
}

View File

@@ -0,0 +1,76 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type ShopErrorCode =
| 'SHOP_NOT_FOUND'
| 'SHOP_DISABLED'
| 'SHOP_OFFER_NOT_FOUND'
| 'SHOP_OFFER_LOCKED'
| 'SHOP_INVALID_QUANTITY'
| 'SHOP_INSUFFICIENT_SILVER';
export class ShopDomainError extends HttpException {
constructor(
public readonly code: ShopErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function shopNotFound(): ShopDomainError {
return new ShopDomainError(
'SHOP_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This merchant has nothing to sell.',
);
}
export function shopDisabled(): ShopDomainError {
return new ShopDomainError(
'SHOP_DISABLED',
HttpStatus.CONFLICT,
'This shop is closed.',
);
}
export function shopOfferNotFound(): ShopDomainError {
return new ShopDomainError(
'SHOP_OFFER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This merchant does not stock that.',
);
}
/**
* The offer exists but its conditions are not met.
*
* Slice 0.8 seeds no gated offers, but the check is enforced from the start:
* Slice 0.8.5 adds locked offers as content, and a gate that only existed in
* the UI would be no gate at all (NPC spec §33, §37.9).
*/
export function shopOfferLocked(): ShopDomainError {
return new ShopDomainError(
'SHOP_OFFER_LOCKED',
HttpStatus.FORBIDDEN,
'You have not earned the right to buy this yet.',
);
}
export function shopInvalidQuantity(): ShopDomainError {
return new ShopDomainError(
'SHOP_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive whole number.',
);
}
export function shopInsufficientSilver(): ShopDomainError {
return new ShopDomainError(
'SHOP_INSUFFICIENT_SILVER',
HttpStatus.CONFLICT,
'You cannot afford that.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,264 @@
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import { NpcService } from '../npcs/npc.service';
import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity';
import { ShopService } from './shop.service';
const CHARACTER_ID = 'character-1';
const MERCHANT_KEY = 'borin-quartermaster';
interface Fixture {
silver?: number;
shopEnabled?: boolean;
hasShop?: boolean;
offerUnlocked?: boolean;
offerRepeatable?: boolean;
offerQuantity?: number;
ownedPotions?: number | null;
}
function createWorld(fixture: Fixture = {}) {
const character = {
id: CHARACTER_ID,
silver: fixture.silver ?? 100,
} as Character;
const grantedItems: Array<Record<string, unknown>> = [];
const owned =
fixture.ownedPotions === undefined || fixture.ownedPotions === null
? null
: {
characterId: CHARACTER_ID,
itemDefinitionId: 'item-potion',
quantity: fixture.ownedPotions,
};
const offers = [
{
id: 'offer-1',
shopId: 'shop-1',
itemDefinitionId: 'item-potion',
currencyType: 'SILVER',
price: 12,
quantity: fixture.offerQuantity ?? 1,
repeatable: fixture.offerRepeatable ?? true,
sortOrder: 1,
conditions: [],
enabled: true,
itemDefinition: {
id: 'item-potion',
key: 'small-healing-potion',
name: 'Small Healing Potion',
description: 'A bitter draught.',
iconPath: '/images/items/potion.png',
},
},
] as unknown as ShopOffer[];
const repositories = (entity: unknown) => {
if (entity === Character) {
return {
findOne: () => Promise.resolve(character),
findOneBy: () => Promise.resolve(character),
save: (row: Character) => Promise.resolve(row),
};
}
if (entity === NpcShop) {
return {
findOneBy: () =>
Promise.resolve(
(fixture.hasShop ?? true)
? {
id: 'shop-1',
key: 'borin-supplies',
name: "Quartermaster's Supplies",
enabled: fixture.shopEnabled ?? true,
}
: null,
),
};
}
if (entity === ShopOffer) {
return { find: () => Promise.resolve(offers) };
}
if (entity === CharacterItem) {
return {
findOne: () => Promise.resolve(owned),
create: (row: Record<string, unknown>) => row,
save: async (row: Record<string, unknown>) => {
grantedItems.push(row);
return row;
},
};
}
throw new Error('Unexpected repository');
};
const manager = { getRepository: repositories } as unknown as EntityManager;
const dataSource = {
getRepository: repositories,
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
run(manager),
} as unknown as DataSource;
const conditions = {
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
} as unknown as GameConditionService;
const npcs = {
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
} as unknown as NpcService;
return {
service: new ShopService(dataSource, conditions, npcs),
character,
grantedItems,
owned,
};
}
describe('ShopService', () => {
it('lists offers with the price the server holds', async () => {
const world = createWorld({ silver: 100 });
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
expect(view.silver).toBe(100);
expect(view.offers[0]).toMatchObject({
itemKey: 'small-healing-potion',
price: 12,
unlocked: true,
affordable: true,
});
});
it('marks an offer the character cannot afford without hiding it', async () => {
const world = createWorld({ silver: 3 });
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
expect(view.offers[0]).toMatchObject({ unlocked: true, affordable: false });
});
it('debits Silver and grants the item', async () => {
const world = createWorld({ silver: 50 });
const result = await world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
2,
);
expect(result.silverSpent).toBe(24);
expect(world.character.silver).toBe(26);
expect(world.grantedItems[0]).toMatchObject({ quantity: 2 });
});
it('stacks onto an existing pile rather than starting a second one', async () => {
const world = createWorld({ silver: 50, ownedPotions: 3 });
await world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
1,
);
expect(world.grantedItems[0]).toMatchObject({ quantity: 4 });
});
it('refuses a purchase the character cannot afford, leaving Silver intact', async () => {
const world = createWorld({ silver: 5 });
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
1,
),
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
expect(world.character.silver).toBe(5);
expect(world.grantedItems).toHaveLength(0);
});
it('refuses a locked offer even when the request asks for it directly', async () => {
// The gate is enforced server-side, so hiding it in the UI is not the
// protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
// gekauft werden").
const world = createWorld({ offerUnlocked: false, silver: 1000 });
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
1,
),
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
expect(world.character.silver).toBe(1000);
});
it('refuses an item the shop does not stock', async () => {
const world = createWorld();
await expect(
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
});
it('refuses a non-positive quantity', async () => {
const world = createWorld();
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
0,
),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
});
it('refuses to buy a one-off offer more than once in a request', async () => {
const world = createWorld({ offerRepeatable: false });
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
2,
),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
});
it('refuses a closed shop', async () => {
const world = createWorld({ shopEnabled: false });
await expect(
world.service.getShopView(CHARACTER_ID, MERCHANT_KEY),
).rejects.toMatchObject({ code: 'SHOP_DISABLED' });
});
it('grants the offer bundle size, not the request count', async () => {
// An offer that sells three at a time, bought twice, is six items.
const world = createWorld({ silver: 100, offerQuantity: 3 });
const result = await world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'small-healing-potion',
2,
);
expect(result.quantity).toBe(6);
expect(result.silverSpent).toBe(24);
});
});

View File

@@ -0,0 +1,243 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import { NpcService } from '../npcs/npc.service';
import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity';
import {
characterNotFound,
shopDisabled,
shopInsufficientSilver,
shopInvalidQuantity,
shopNotFound,
shopOfferLocked,
shopOfferNotFound,
} from './shop.errors';
export const SILVER_CURRENCY = 'SILVER';
export interface ShopOfferDto {
itemKey: string;
itemName: string;
itemDescription: string;
iconPath: string;
currencyType: string;
price: number;
quantity: number;
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
unlocked: boolean;
/** True when the character simply cannot afford an otherwise open offer. */
affordable: boolean;
}
export interface ShopViewDto {
shopKey: string;
shopName: string;
npcKey: string;
silver: number;
offers: ShopOfferDto[];
}
export interface ShopPurchaseResultDto {
shopKey: string;
itemKey: string;
itemName: string;
quantity: number;
silverSpent: number;
silverBalance: number;
}
/**
* Sells goods for Silver (NPC spec §15, §30).
*
* Prices and availability are read from content on every call. The request
* names an item and a count and nothing else, so a client cannot set its own
* price or open a locked offer (spec §37.9, §33).
*/
@Injectable()
export class ShopService {
constructor(
private readonly dataSource: DataSource,
private readonly conditions: GameConditionService,
private readonly npcs: NpcService,
) {}
async getShopView(
characterId: string,
merchantKey: string,
): Promise<ShopViewDto> {
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
const character = await this.dataSource
.getRepository(Character)
.findOneBy({ id: characterId });
if (!character) {
throw characterNotFound();
}
const offers = await this.dataSource.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true },
relations: { itemDefinition: true },
order: { sortOrder: 'ASC' },
});
const view: ShopOfferDto[] = [];
for (const offer of offers) {
const unlocked = await this.conditions.evaluate(
{ characterId, npcId },
offer.conditions,
);
view.push({
itemKey: offer.itemDefinition.key,
itemName: offer.itemDefinition.name,
itemDescription: offer.itemDefinition.description,
iconPath: offer.itemDefinition.iconPath,
currencyType: offer.currencyType,
price: offer.price,
quantity: offer.quantity,
unlocked,
affordable: character.silver >= offer.price,
});
}
return {
shopKey: shop.key,
shopName: shop.name,
npcKey: merchantKey,
silver: character.silver,
offers: view,
};
}
/**
* Buys `quantity` lots of one offer, atomically (spec §31).
*
* Silver is debited and the item granted in the same transaction, so a
* failure cannot leave the character paid-up and empty-handed.
*/
async purchase(
characterId: string,
merchantKey: string,
itemKey: string,
quantity: number,
): Promise<ShopPurchaseResultDto> {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw shopInvalidQuantity();
}
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
// Matched on the joined definition's business key: the offer table is
// keyed by item definition id, while the request carries the stable key.
const offers = await manager.getRepository(ShopOffer).find({
where: { shopId: shop.id, enabled: true },
relations: { itemDefinition: true },
});
const match = offers.find(
(candidate) => candidate.itemDefinition.key === itemKey,
);
if (!match) {
throw shopOfferNotFound();
}
const unlocked = await this.conditions.evaluate(
{ characterId, npcId },
match.conditions,
manager,
);
if (!unlocked) {
throw shopOfferLocked();
}
if (!match.repeatable && quantity > 1) {
throw shopInvalidQuantity();
}
const silverSpent = match.price * quantity;
if (character.silver < silverSpent) {
throw shopInsufficientSilver();
}
character.silver -= silverSpent;
await characters.save(character);
await this.grantItem(
manager,
characterId,
match.itemDefinitionId,
match.quantity * quantity,
);
return {
shopKey: shop.key,
itemKey,
itemName: match.itemDefinition.name,
quantity: match.quantity * quantity,
silverSpent,
silverBalance: character.silver,
};
});
}
/**
* Adds to an existing stack or starts a new one.
*
* Purchases deliberately ignore loot-bag capacity: bags limit trade goods
* carried out of a hunt, and equipment and consumables are unaffected by
* them (Slice 0.7.5 §8).
*/
private async grantItem(
manager: { getRepository: DataSource['getRepository'] },
characterId: string,
itemDefinitionId: string,
quantity: number,
): Promise<void> {
const characterItems = manager.getRepository(CharacterItem);
const existing = await characterItems.findOne({
where: { characterId, itemDefinitionId },
});
if (existing) {
existing.quantity += quantity;
await characterItems.save(existing);
return;
}
await characterItems.save(
characterItems.create({ characterId, itemDefinitionId, quantity }),
);
}
private async requireShop(
characterId: string,
merchantKey: string,
): Promise<{ shop: NpcShop; npcId: string }> {
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
const shop = await this.dataSource
.getRepository(NpcShop)
.findOneBy({ npcId: npc.id });
if (!shop) {
throw shopNotFound();
}
if (!shop.enabled) {
throw shopDisabled();
}
return { shop, npcId: npc.id };
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { ConditionsModule } from '../conditions/conditions.module';
import { CharacterItem } from '../items/entities/character-item.entity';
import { NpcsModule } from '../npcs/npcs.module';
import { NpcShop } from './entities/npc-shop.entity';
import { ShopOffer } from './entities/shop-offer.entity';
import { ShopController } from './shop.controller';
import { ShopService } from './shop.service';
/** Buying things for Silver (NPC spec §15, §29). */
@Module({
imports: [
TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]),
ConditionsModule,
NpcsModule,
],
controllers: [ShopController],
providers: [ShopService],
exports: [ShopService],
})
export class ShopsModule {}

View File

@@ -169,11 +169,7 @@ function createState(): FakeState {
'south-gate', 'south-gate',
'Graufurt South Gate', 'Graufurt South Gate',
); );
const burnedRoad = location( const burnedRoad = location(BURNED_ROAD_ID, 'burned-road', 'Burned Road');
BURNED_ROAD_ID,
'burned-road',
'Burned Road',
);
const character: Character = { const character: Character = {
id: CHARACTER_ID, id: CHARACTER_ID,
name: 'Aric Duskwalker', name: 'Aric Duskwalker',

View File

@@ -1,10 +0,0 @@
import { IsInt, IsString, Min } from 'class-validator';
export class TurnInDto {
@IsString()
turnInKey!: string;
@IsInt()
@Min(1)
quantity!: number;
}

View File

@@ -1,54 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ItemDefinition } from '../../items/entities/item-definition.entity';
import { ReputationFaction } from '../../reputation/entities/reputation-faction.entity';
@Entity({ name: 'turn_in_definitions' })
@Index('IDX_turn_in_definitions_key', ['key'], { unique: true })
export class TurnInDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'item_definition_id', type: 'uuid' })
itemDefinitionId!: string;
@Column({ name: 'faction_id', type: 'uuid' })
factionId!: string;
@Column({ name: 'silver_reward_per_item', type: 'integer' })
silverRewardPerItem!: number;
@Column({ name: 'reputation_reward_per_item', type: 'integer' })
reputationRewardPerItem!: number;
@Column({ name: 'repeatable', type: 'boolean' })
repeatable!: boolean;
@Column({ name: 'enabled', type: 'boolean' })
enabled!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'item_definition_id' })
itemDefinition!: ItemDefinition;
@ManyToOne(() => ReputationFaction, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'faction_id' })
faction!: ReputationFaction;
}

View File

@@ -1,81 +0,0 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
describe('TurnInController', () => {
let app: INestApplication<App>;
const turnIn = jest.fn();
beforeEach(async () => {
turnIn.mockReset();
const module = await Test.createTestingModule({
controllers: [TurnInController],
providers: [{ provide: TurnInService, useValue: { turnIn } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates POST /api/turn-ins with only turnInKey and quantity', async () => {
const result = {
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
};
turnIn.mockResolvedValue(result);
const response = await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 })
.expect(201);
expect(turnIn).toHaveBeenCalledWith(
DEMO_CHARACTER_ID,
'ash-pelt-border-guard',
3,
);
expect(response.body).toEqual(result);
});
it('rejects server-owned reward fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({
turnInKey: 'ash-pelt-border-guard',
quantity: 3,
silverGranted: 999,
reputationReward: 999,
})
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
it('rejects a missing quantity', async () => {
await request(app.getHttpServer())
.post('/api/turn-ins')
.send({ turnInKey: 'ash-pelt-border-guard' })
.expect(400);
expect(turnIn).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { TurnInDto } from './dto/turn-in.dto';
import { TurnInResult, TurnInService } from './turn-in.service';
@Controller('turn-ins')
export class TurnInController {
constructor(private readonly turnInService: TurnInService) {}
@Post()
turnIn(@Body() request: TurnInDto): Promise<TurnInResult> {
return this.turnInService.turnIn(
DEMO_CHARACTER_ID,
request.turnInKey,
request.quantity,
);
}
}

View File

@@ -1,51 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type TurnInErrorCode =
| 'TURN_IN_NOT_FOUND'
| 'TURN_IN_DISABLED'
| 'TURN_IN_INVALID_QUANTITY'
| 'TURN_IN_INSUFFICIENT_QUANTITY';
export class TurnInDomainError extends HttpException {
constructor(
public readonly code: TurnInErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function turnInNotFound(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This turn-in could not be found.',
);
}
export function turnInDisabled(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_DISABLED',
HttpStatus.CONFLICT,
'This turn-in is not currently available.',
);
}
export function turnInInvalidQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INVALID_QUANTITY',
HttpStatus.BAD_REQUEST,
'Quantity must be a positive integer.',
);
}
export function turnInInsufficientQuantity(): TurnInDomainError {
return new TurnInDomainError(
'TURN_IN_INSUFFICIENT_QUANTITY',
HttpStatus.CONFLICT,
'The character does not own enough of this item.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationModule } from '../reputation/reputation.module';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInController } from './turn-in.controller';
import { TurnInService } from './turn-in.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Character,
CharacterItem,
TurnInDefinition,
ReputationFaction,
]),
ReputationModule,
],
controllers: [TurnInController],
providers: [TurnInService],
exports: [TurnInService],
})
export class TurnInModule {}

View File

@@ -1,371 +0,0 @@
import { HttpException } from '@nestjs/common';
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { CharacterReputation } from '../reputation/entities/character-reputation.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import { ReputationService } from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import { TurnInDomainError } from './turn-in.errors';
import { TurnInService } from './turn-in.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const FACTION_ID = '80000000-0000-4000-8000-000000000001';
const ITEM_DEFINITION_ID = '50000000-0000-4000-8000-00000000000c';
const CHARACTER_ITEM_ID = '95000000-0000-4000-8000-000000000001';
interface State {
characters: Character[];
characterItems: CharacterItem[];
turnIns: TurnInDefinition[];
factions: ReputationFaction[];
characterReputation: CharacterReputation[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly rows: T[],
private readonly prefix: string,
private readonly inTransaction: boolean,
) {}
findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock && !this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
return Promise.resolve(
this.rows.find((row) => this.matches(row, options.where)) ?? null,
);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(
this.rows.find((row) => this.matches(row, where)) ?? null,
);
}
find(options: { where?: Partial<T> } = {}): Promise<T[]> {
return Promise.resolve(
options.where
? this.rows.filter((row) => this.matches(row, options.where!))
: [...this.rows],
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = `${this.prefix}-${this.rows.length + 1}`;
}
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
this.rows.push(entity);
} else {
this.rows[index] = entity;
}
return Promise.resolve(entity);
}
remove(entity: T): Promise<T> {
const index = this.rows.findIndex((row) => row.id === entity.id);
if (index !== -1) {
this.rows.splice(index, 1);
}
return Promise.resolve(entity);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
}
}
class FakeDataSource {
constructor(public state: State) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return this.repoFor(target, false);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return work({
getRepository: <U extends { id: string }>(target: EntityTarget<U>) =>
this.repoFor(target, true),
} as unknown as EntityManager);
}
private repoFor<T extends { id: string }>(
target: EntityTarget<T>,
inTransaction: boolean,
) {
if (target === Character)
return new FakeRepository(
this.state.characters,
'character',
inTransaction,
) as never;
if (target === CharacterItem)
return new FakeRepository(
this.state.characterItems,
'char-item',
inTransaction,
) as never;
if (target === TurnInDefinition)
return new FakeRepository(
this.state.turnIns,
'turn-in',
inTransaction,
) as never;
if (target === ReputationFaction)
return new FakeRepository(
this.state.factions,
'faction',
inTransaction,
) as never;
if (target === CharacterReputation)
return new FakeRepository(
this.state.characterReputation,
'char-rep',
inTransaction,
) as never;
throw new Error('Unsupported repository');
}
}
function character(overrides: Partial<Character> = {}): Character {
return { id: CHARACTER_ID, silver: 10, ...overrides } as Character;
}
function characterItem(overrides: Partial<CharacterItem> = {}): CharacterItem {
return {
id: CHARACTER_ITEM_ID,
characterId: CHARACTER_ID,
itemDefinitionId: ITEM_DEFINITION_ID,
quantity: 5,
...overrides,
} as CharacterItem;
}
function turnInDefinition(
overrides: Partial<TurnInDefinition> = {},
): TurnInDefinition {
return {
id: 'turn-in-1',
key: 'ash-pelt-border-guard',
itemDefinitionId: ITEM_DEFINITION_ID,
factionId: FACTION_ID,
silverRewardPerItem: 4,
reputationRewardPerItem: 1,
repeatable: true,
enabled: true,
...overrides,
} as TurnInDefinition;
}
function faction(
overrides: Partial<ReputationFaction> = {},
): ReputationFaction {
return {
id: FACTION_ID,
key: 'border-guard',
name: 'Border Watch',
enabled: true,
...overrides,
} as ReputationFaction;
}
function createState(overrides: Partial<State> = {}): State {
return {
characters: [character()],
characterItems: [characterItem()],
turnIns: [turnInDefinition()],
factions: [faction()],
characterReputation: [],
...overrides,
};
}
function createService(state: State) {
const dataSource = new FakeDataSource(state);
const reputationService = new ReputationService(
dataSource as unknown as DataSource,
);
return {
service: new TurnInService(
dataSource as unknown as DataSource,
reputationService,
),
state,
};
}
async function expectTurnInDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(TurnInDomainError);
if (!(error instanceof TurnInDomainError)) {
throw new Error('Expected TurnInDomainError');
}
expect(error.code).toBe(code);
}
/**
* `characterNotFound` is shared from `travel.errors`, so it is not a
* TurnInDomainError. Assert on the wire contract -- code plus status --
* the way `reputation.service.spec.ts` does for the same shared error.
*/
async function expectHttpErrorWithCode(
promise: Promise<unknown>,
code: string,
status: number,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(HttpException);
if (!(error instanceof HttpException)) {
throw new Error('Expected HttpException');
}
expect(error.getStatus()).toBe(status);
expect(error.getResponse()).toMatchObject({ code });
}
describe('TurnInService', () => {
describe('turnIn', () => {
it('consumes the exact quantity, grants silver and reputation atomically', async () => {
const { service, state } = createService(createState());
const result = await service.turnIn(
CHARACTER_ID,
'ash-pelt-border-guard',
3,
);
expect(result).toEqual({
turnInKey: 'ash-pelt-border-guard',
quantityConsumed: 3,
silverGranted: 12,
reputationResult: {
factionKey: 'border-guard',
previousReputation: 0,
newReputation: 3,
previousRank: 'STRANGER',
newRank: 'STRANGER',
rankChanged: false,
},
});
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(22);
expect(state.characterReputation[0].reputation).toBe(3);
});
it('deletes the CharacterItem row once its quantity reaches 0', async () => {
const { service, state } = createService(
createState({ characterItems: [characterItem({ quantity: 3 })] }),
);
await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3);
expect(state.characterItems).toHaveLength(0);
});
it('rejects turning in more than the character owns, mutating nothing', async () => {
const { service, state } = createService(
createState({ characterItems: [characterItem({ quantity: 2 })] }),
);
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3),
'TURN_IN_INSUFFICIENT_QUANTITY',
);
expect(state.characterItems[0].quantity).toBe(2);
expect(state.characters[0].silver).toBe(10);
expect(state.characterReputation).toHaveLength(0);
});
it('rejects a zero or negative quantity', async () => {
const { service } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 0),
'TURN_IN_INVALID_QUANTITY',
);
});
it('rejects an unknown turn-in key, mutating nothing', async () => {
const { service, state } = createService(createState());
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'unknown-turn-in', 1),
'TURN_IN_NOT_FOUND',
);
expect(state.characters[0].silver).toBe(10);
});
it('rejects an unknown character, mutating nothing', async () => {
const { service, state } = createService(createState());
await expectHttpErrorWithCode(
service.turnIn('unknown-character', 'ash-pelt-border-guard', 1),
'CHARACTER_NOT_FOUND',
404,
);
expect(state.characterItems[0].quantity).toBe(5);
expect(state.characters[0].silver).toBe(10);
expect(state.characterReputation).toHaveLength(0);
});
it('rejects a disabled turn-in', async () => {
const state = createState({
turnIns: [turnInDefinition({ enabled: false })],
});
const { service } = createService(state);
await expectTurnInDomainError(
service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 1),
'TURN_IN_DISABLED',
);
});
it('computes multi-item rewards server-side from the definition, not from client input', async () => {
const state = createState({
turnIns: [
turnInDefinition({
silverRewardPerItem: 12,
reputationRewardPerItem: 4,
}),
],
});
const { service, state: resultState } = createService(state);
const result = await service.turnIn(
CHARACTER_ID,
'ash-pelt-border-guard',
5,
);
expect(result.silverGranted).toBe(60);
expect(result.reputationResult.newReputation).toBe(20);
expect(resultState.characters[0].silver).toBe(70);
});
});
});

View File

@@ -1,108 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterItem } from '../items/entities/character-item.entity';
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
import {
ReputationGrantResult,
ReputationService,
} from '../reputation/reputation.service';
import { TurnInDefinition } from './entities/turn-in-definition.entity';
import {
characterNotFound,
turnInDisabled,
turnInInsufficientQuantity,
turnInInvalidQuantity,
turnInNotFound,
} from './turn-in.errors';
export interface TurnInResult {
turnInKey: string;
quantityConsumed: number;
silverGranted: number;
reputationResult: ReputationGrantResult;
}
@Injectable()
export class TurnInService {
constructor(
private readonly dataSource: DataSource,
private readonly reputationService: ReputationService,
) {}
/**
* Consumes loot and grants Silver + Reputation atomically (spec §20,
* §31): a failed turn-in leaves items, silver, and reputation completely
* untouched. The reward math is entirely server-computed from persisted
* content -- the client supplies only `turnInKey`/`quantity` (spec §25).
*/
async turnIn(
characterId: string,
turnInKey: string,
quantity: number,
): Promise<TurnInResult> {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw turnInInvalidQuantity();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const characterItems = manager.getRepository(CharacterItem);
const turnIns = manager.getRepository(TurnInDefinition);
const factions = manager.getRepository(ReputationFaction);
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const definition = await turnIns.findOneBy({ key: turnInKey });
if (!definition) {
throw turnInNotFound();
}
if (!definition.enabled) {
throw turnInDisabled();
}
const characterItem = await characterItems.findOne({
where: { characterId, itemDefinitionId: definition.itemDefinitionId },
lock: { mode: 'pessimistic_write' },
});
if (!characterItem || characterItem.quantity < quantity) {
throw turnInInsufficientQuantity();
}
if (characterItem.quantity === quantity) {
await characterItems.remove(characterItem);
} else {
characterItem.quantity -= quantity;
await characterItems.save(characterItem);
}
const silverGranted = definition.silverRewardPerItem * quantity;
character.silver += silverGranted;
await characters.save(character);
const faction = await factions.findOneBy({ id: definition.factionId });
if (!faction) {
throw turnInNotFound();
}
const reputationResult = await this.reputationService.grantReputation(
characterId,
faction.key,
definition.reputationRewardPerItem * quantity,
manager,
);
return {
turnInKey,
quantityConsumed: quantity,
silverGranted,
reputationResult,
};
});
}
}

View File

@@ -46,6 +46,13 @@ export interface LocationPointOfInterestContent {
enabled: boolean; enabled: boolean;
resultTitle?: string; resultTitle?: string;
resultText?: string; resultText?: string;
/**
* Names a real NPC (NPC spec §24), which turns this hotspot into a doorway
* to that person's screen instead of an authored one-shot text reveal. A
* hotspot may carry result text or an `npcKey`, never both -- the wounded
* scout on the Burned Road stays a piece of scenery, Borin does not.
*/
npcKey?: string;
} }
/** /**
@@ -61,6 +68,7 @@ export interface LocationPrimaryActionContent {
iconKey: string; iconKey: string;
enabled: boolean; enabled: boolean;
poiKey?: string; poiKey?: string;
npcKey?: string;
} }
export interface LocationRewardPreviewContent { export interface LocationRewardPreviewContent {
@@ -78,6 +86,7 @@ export interface LocalLocationPointOfInterestDto {
xPercent: number; xPercent: number;
yPercent: number; yPercent: number;
enabled: boolean; enabled: boolean;
npcKey?: string;
} }
export interface LocalLocationPrimaryActionDto { export interface LocalLocationPrimaryActionDto {
@@ -88,6 +97,7 @@ export interface LocalLocationPrimaryActionDto {
iconKey: string; iconKey: string;
enabled: boolean; enabled: boolean;
poiKey?: string; poiKey?: string;
npcKey?: string;
} }
export interface EncounterPreviewDto { export interface EncounterPreviewDto {
@@ -122,5 +132,6 @@ export function toPointOfInterestDto(
xPercent: poi.xPercent, xPercent: poi.xPercent,
yPercent: poi.yPercent, yPercent: poi.yPercent,
enabled: poi.enabled, enabled: poi.enabled,
...(poi.npcKey === undefined ? {} : { npcKey: poi.npcKey }),
}; };
} }

View File

@@ -130,8 +130,7 @@ function currentLocation(): LocationDefinition {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'Graufurt South Gate', name: 'Graufurt South Gate',
description: description: 'The South Gate is the safe starting point heading south.',
'The South Gate is the safe starting point heading south.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
maxRecommendedLevel: 1, maxRecommendedLevel: 1,
@@ -245,8 +244,7 @@ describe('WorldService', () => {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
key: 'south-gate', key: 'south-gate',
name: 'Graufurt South Gate', name: 'Graufurt South Gate',
description: description: 'The South Gate is the safe starting point heading south.',
'The South Gate is the safe starting point heading south.',
regionKey: 'ashen-fields', regionKey: 'ashen-fields',
minRecommendedLevel: 1, minRecommendedLevel: 1,
maxRecommendedLevel: 1, maxRecommendedLevel: 1,
@@ -324,10 +322,7 @@ describe('WorldService', () => {
const result = await service.getCurrentLocation(CHARACTER_ID); const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.possibleMonsters).toEqual([ expect(result.possibleMonsters).toEqual(['Ash Rat', 'Road Bandit']);
'Ash Rat',
'Road Bandit',
]);
expect(findLocationMonsters).toHaveBeenCalledWith({ expect(findLocationMonsters).toHaveBeenCalledWith({
where: { locationId: BURNED_ROAD_ID, enabled: true }, where: { locationId: BURNED_ROAD_ID, enabled: true },
relations: { monster: true }, relations: { monster: true },

View File

@@ -265,9 +265,12 @@ describe('Visible vertical slice smoke (e2e)', () => {
} }
expect(atBurnedRoad.key).toBe('burned-road'); expect(atBurnedRoad.key).toBe('burned-road');
// Ordered by encounter weight, heaviest first (spec §3).
expect(atBurnedRoad.possibleMonsters).toEqual([ expect(atBurnedRoad.possibleMonsters).toEqual([
'Ash Rat', 'Ash Rat',
'Feral Road Hound',
'Road Bandit', 'Road Bandit',
'Charred Raider',
]); ]);
const firstHunt = await request(app.getHttpServer()) const firstHunt = await request(app.getHttpServer())
@@ -277,13 +280,22 @@ describe('Visible vertical slice smoke (e2e)', () => {
expect(typeof firstHunt.body.id).toBe('string'); expect(typeof firstHunt.body.id).toBe('string');
expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' }); expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' });
expect(firstHunt.body.encounters).toHaveLength(3); expect(firstHunt.body.encounters).toHaveLength(3);
for (const encounter of firstHunt.body.encounters as Array<{ const encounters = firstHunt.body.encounters as Array<{
id: string; id: string;
monster: { key: string }; monster: { key: string; flavorText: string | null };
dangerRating: string; dangerRating: string;
}>) { encounterType: string;
}>;
for (const encounter of encounters) {
expect(typeof encounter.id).toBe('string'); expect(typeof encounter.id).toBe('string');
expect(['ash-rat', 'road-bandit']).toContain(encounter.monster.key); expect([
'ash-rat',
'wild-road-dog',
'road-bandit',
'charred-looter',
]).toContain(encounter.monster.key);
expect(typeof encounter.monster.flavorText).toBe('string');
expect(['NORMAL', 'RARE']).toContain(encounter.encounterType);
expect([ expect([
'WEAK', 'WEAK',
'MATCH', 'MATCH',
@@ -293,6 +305,37 @@ describe('Visible vertical slice smoke (e2e)', () => {
]).toContain(encounter.dangerRating); ]).toContain(encounter.dangerRating);
} }
// The cards are a real choice between different enemies, so no monster
// may fill two of them (spec §3).
const rolledKeys = encounters.map((encounter) => encounter.monster.key);
expect(new Set(rolledKeys).size).toBe(rolledKeys.length);
// Only the Charred Raider is marked rare, so the mark stays meaningful.
for (const encounter of encounters) {
expect(encounter.encounterType === 'RARE').toBe(
encounter.monster.key === 'charred-looter',
);
}
const capacities = await request(app.getHttpServer())
.get('/api/loot-bags/capacities')
.expect(200);
// Slice 0.7.5 §11: the carrying state is server-derived and covers every
// known loot category.
expect(
(capacities.body as Array<{ category: string }>).map(
(entry) => entry.category,
),
).toEqual(['HIDE', 'RAIDER_TROPHY']);
for (const entry of capacities.body as Array<{
current: number;
capacity: number;
}>) {
expect(entry.capacity).toBeGreaterThanOrEqual(1);
expect(entry.current).toBeGreaterThanOrEqual(0);
}
const secondHunt = await request(app.getHttpServer()) const secondHunt = await request(app.getHttpServer())
.post('/api/hunts') .post('/api/hunts')
.expect(201); .expect(201);
@@ -321,6 +364,146 @@ describe('Visible vertical slice smoke (e2e)', () => {
} }
}, 30_000); }, 30_000);
it('sells goods to Borin in Graufurt for Silver and reputation, and frees bag capacity', async () => {
// Slice 0.8 §14: the merchant is reachable, trading is server-priced and
// atomic, and handing goods over frees carrying capacity.
interface LocationBody {
id: string;
key: string;
connections: Array<{
targetLocation: { id: string; key: string };
travelDurationSeconds: number;
}>;
}
interface Offer {
itemKey: string;
quantityCarried: number;
silverPerStep: number;
}
const origin = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
const originLocation = origin.body as LocationBody;
if (originLocation.key !== 'south-gate') {
const toGate = originLocation.connections.find(
(connection) => connection.targetLocation.key === 'south-gate',
);
expect(toGate).toBeDefined();
await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId: toGate!.targetLocation.id })
.expect(201);
await pollUntilTravelCompletes(app, toGate!.travelDurationSeconds);
}
const interaction = await request(app.getHttpServer())
.get('/api/npcs/borin-quartermaster/interaction')
.expect(200);
const npcBody = interaction.body as {
npc: { key: string; name: string };
dialogue: { text: string } | null;
availableActions: Array<{ type: string }>;
};
expect(npcBody.npc).toMatchObject({
key: 'borin-quartermaster',
name: 'Borin',
});
// One person, several jobs -- the composition model (NPC spec §2).
expect(npcBody.availableActions.map((action) => action.type)).toEqual(
expect.arrayContaining(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']),
);
expect(typeof npcBody.dialogue?.text).toBe('string');
const view = await request(app.getHttpServer())
.get('/api/merchants/borin-quartermaster/trade-in')
.expect(200);
const offers = (view.body as { offers: Offer[] }).offers;
// All four Burned Road trade goods are accepted (slice §5).
expect(offers.map((offer) => offer.itemKey).sort()).toEqual([
'ash-pelt',
'bandit-insignia',
'charred-raider-insignia',
'tough-hide',
]);
// An item this merchant does not take is refused, whatever the client
// claims (slice §13).
await request(app.getHttpServer())
.post('/api/merchants/borin-quartermaster/trade-in')
.send({ items: [{ itemKey: 'worn-short-sword', quantity: 1 }] })
.expect(409);
const carried = offers.find((offer) => offer.quantityCarried > 0);
if (!carried) {
// Nothing to sell on this run. Everything above is still covered; the
// trade itself is exercised whenever a hunt has produced goods.
return;
}
const before = await request(app.getHttpServer())
.get('/api/characters/me')
.expect(200);
const silverBefore = (before.body as { silver: number }).silver;
// More than is carried must be refused outright, leaving Silver alone.
await request(app.getHttpServer())
.post('/api/merchants/borin-quartermaster/trade-in')
.send({
items: [
{ itemKey: carried.itemKey, quantity: carried.quantityCarried + 1 },
],
})
.expect(409);
const unchanged = await request(app.getHttpServer())
.get('/api/characters/me')
.expect(200);
expect((unchanged.body as { silver: number }).silver).toBe(silverBefore);
const traded = await request(app.getHttpServer())
.post('/api/merchants/borin-quartermaster/trade-in')
.send({ items: [{ itemKey: carried.itemKey, quantity: 1 }] })
.expect(201);
const result = traded.body as {
consumed: Array<{ itemKey: string; quantity: number }>;
rewards: { silver: number; regionalReputation: number };
balances: { silver: number };
capacities: Array<{ category: string }>;
};
expect(result.consumed).toEqual([
expect.objectContaining({ itemKey: carried.itemKey, quantity: 1 }),
]);
expect(result.rewards.silver).toBe(carried.silverPerStep);
expect(result.balances.silver).toBe(silverBefore + carried.silverPerStep);
expect(result.rewards.regionalReputation).toBeGreaterThan(0);
// Capacity is derived from what is carried, so the trade frees it
// immediately (slice §11).
expect(result.capacities.length).toBeGreaterThan(0);
const after = await request(app.getHttpServer())
.get('/api/merchants/borin-quartermaster/trade-in')
.expect(200);
const afterOffer = (after.body as { offers: Offer[] }).offers.find(
(offer) => offer.itemKey === carried.itemKey,
);
expect(afterOffer?.quantityCarried).toBe(carried.quantityCarried - 1);
if (originLocation.key !== 'south-gate') {
await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId: originLocation.id })
.expect(201);
await pollUntilTravelCompletes(app, 30);
}
}, 30_000);
async function pollUntilTravelCompletes( async function pollUntilTravelCompletes(
application: INestApplication<App>, application: INestApplication<App>,
travelDurationSeconds: number, travelDurationSeconds: number,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -35,6 +35,13 @@ export const routes: Routes = [
(module) => module.CombatPageComponent, (module) => module.CombatPageComponent,
), ),
}, },
{
path: 'npc/:npcKey',
loadComponent: () =>
import('./features/npc/merchant-page.component').then(
(module) => module.MerchantPageComponent,
),
},
{ {
path: 'inventory', path: 'inventory',
loadComponent: () => loadComponent: () =>

Some files were not shown because too many files have changed in this diff Show More