diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d8d3f37..23c9278 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,15 +1,19 @@ import { Module } from '@nestjs/common'; import { CharactersModule } from './characters/characters.module'; import { CombatModule } from './combat/combat.module'; +import { ConditionsModule } from './conditions/conditions.module'; import { DatabaseModule } from './database/database.module'; import { EquipmentModule } from './equipment/equipment.module'; +import { ExchangesModule } from './exchanges/exchanges.module'; import { HealthModule } from './health/health.module'; import { HuntingModule } from './hunting/hunting.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 { ReputationModule } from './reputation/reputation.module'; +import { ShopsModule } from './shops/shops.module'; import { TravelModule } from './travel/travel.module'; -import { TurnInModule } from './turn-in/turn-in.module'; import { WorldModule } from './world/world.module'; @Module({ @@ -23,9 +27,13 @@ import { WorldModule } from './world/world.module'; CombatModule, EquipmentModule, InventoryModule, + LootBagsModule, RenownModule, ReputationModule, - TurnInModule, + ConditionsModule, + NpcsModule, + ShopsModule, + ExchangesModule, ], }) export class AppModule {} diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts index a992aac..87c2953 100644 --- a/apps/api/src/characters/character-stats.service.spec.ts +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -159,7 +159,10 @@ describe('CharacterStatsService', () => { const scope = fakeScope([]); 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.hpRegenSince).toEqual(anchor); diff --git a/apps/api/src/characters/character-vitals.service.spec.ts b/apps/api/src/characters/character-vitals.service.spec.ts index 06d697c..4afdaa0 100644 --- a/apps/api/src/characters/character-vitals.service.spec.ts +++ b/apps/api/src/characters/character-vitals.service.spec.ts @@ -30,7 +30,10 @@ describe('CharacterVitalsService', () => { const { clock } = fakeClock('2026-08-21T12:00:00.000Z'); 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); }); diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts index 57afd73..7c5eec2 100644 --- a/apps/api/src/characters/characters.service.ts +++ b/apps/api/src/characters/characters.service.ts @@ -34,7 +34,9 @@ export class CharactersService { maxHp: stats.maxHp, attack: stats.attack, hpRegenPerSecond: stats.hpRegenPerSecond, - hpRegenSince: stats.hpRegenSince ? stats.hpRegenSince.toISOString() : null, + hpRegenSince: stats.hpRegenSince + ? stats.hpRegenSince.toISOString() + : null, currentLocation: { id: character.currentLocation.id, key: character.currentLocation.key, diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index b23e08e..ab6d9a3 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -3,10 +3,15 @@ import { CombatEngineService, UnsupportedCombatActionError, } 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 { CombatStatus } from './combat-status.enum'; import { Combatant } from './combatant.enum'; +import { StatusEffectType } from './status-effect.enum'; +import type { MonsterAbilities } from '../monsters/monster-abilities'; function baseState( overrides: Partial = {}, @@ -19,15 +24,44 @@ function baseState( maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 }, }, - monster: { - currentHp: 45, - maxHp: 45, - stats: { attack: 5, armor: 0 }, - }, + // The Ash Rat baseline: no telegraph, no status effect (spec §4). + monster: monster(), ...overrides, }; } +function monster( + overrides: Partial = {}, +): 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 { + 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', () => { 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', () => { const state = baseState({ - monster: { - currentHp: 45, - maxHp: 45, + monster: withAbilities(ROAD_BANDIT_ABILITIES, { stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, - }, + }), }); 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', () => { - const round3State = baseState({ round: 3 }); + const round3State = baseState({ + round: 3, + monster: withAbilities(ROAD_BANDIT_ABILITIES), + }); const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK, @@ -311,11 +346,10 @@ describe('CombatEngineService', () => { it('does not resolve a pending Heavy Attack when the monster is killed this round', () => { const state = baseState({ - monster: { + monster: withAbilities(ROAD_BANDIT_ABILITIES, { currentHp: 10, - maxHp: 45, stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, - }, + }), }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); @@ -353,11 +387,9 @@ describe('CombatEngineService', () => { it('DEFEND mitigates a resolving Heavy Attack by half', () => { const state = baseState({ - monster: { - currentHp: 45, - maxHp: 45, + monster: withAbilities(ROAD_BANDIT_ABILITIES, { stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, - }, + }), }); const result = engine.resolveAction(state, { action: CombatAction.DEFEND }); @@ -379,4 +411,318 @@ describe('CombatEngineService', () => { expect(result.state.monster.stats.pendingAction).toBeUndefined(); 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); + }); + }); }); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index 76a1117..9cc7d2b 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -3,6 +3,7 @@ import { CombatAction } from './combat-action.enum'; import { calculateDamage } from './combat-damage'; import { Combatant } from './combatant.enum'; import { + ActiveStatusEffect, CombatActionInput, CombatEngineCombatant, CombatEngineEvent, @@ -11,6 +12,11 @@ import { } from './combat-engine.types'; import { CombatEventType } from './combat-event-type.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 { constructor(action: string) { @@ -18,11 +24,10 @@ export class UnsupportedCombatActionError extends Error { } } -// The monster telegraphs a Heavy Attack instead of striking every third -// round it acts, then resolves it the round after unless SHIELD_BASH -// interrupts it. A fixed cadence (not RNG) keeps combat deterministic -// (Playable Slice 0.6 spec §10/§11). -const TELEGRAPH_ROUND_INTERVAL = 3; +// Player-side action modifiers. These are character abilities rather than +// monster content, so they stay constants here (Playable Slice 0.6 spec +// §10/§11). What the *monster* does now comes from its own definition +// (Playable Slice 0.7 V2 spec §4, §8) instead of a rule every enemy shares. const HEAVY_ATTACK_MULTIPLIER = 1.6; const SHIELD_BASH_MULTIPLIER = 0.7; const DEFEND_MITIGATION_MULTIPLIER = 0.5; @@ -141,6 +146,14 @@ export class CombatEngineService { 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( state: CombatEngineState, player: CombatEngineCombatant, @@ -172,18 +185,22 @@ export class CombatEngineService { if (!interrupted) { this.resolveMonsterTurn(state.round, monster, player, defended, events); + } - if (player.currentHp <= 0) { - events.push({ - source: Combatant.MONSTER, - target: Combatant.PLAYER, - type: CombatEventType.COMBAT_LOST, - }); - return { - state: { ...state, player, monster, status: CombatStatus.LOST }, - events, - }; - } + if (player.currentHp > 0) { + this.tickStatusEffects(player, events); + } + + if (player.currentHp <= 0) { + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.COMBAT_LOST, + }); + return { + state: { ...state, player, monster, status: CombatStatus.LOST }, + events, + }; } return { @@ -206,25 +223,21 @@ export class CombatEngineService { events: CombatEngineEvent[], ): void { const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1; + const abilities = monster.stats.abilities ?? {}; if (monster.stats.pendingAction === 'HEAVY_ATTACK') { monster.stats.pendingAction = undefined; - const damage = calculateDamage( - monster.stats, - player.stats.armor, - HEAVY_ATTACK_MULTIPLIER * defendMultiplier, + this.strikePlayer( + monster, + player, + (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; } - if (round % TELEGRAPH_ROUND_INTERVAL === 0) { + if (this.shouldTrigger(abilities.telegraph, round)) { monster.stats.pendingAction = 'HEAVY_ATTACK'; events.push({ source: Combatant.MONSTER, @@ -234,10 +247,26 @@ export class CombatEngineService { 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( monster.stats, player.stats.armor, - defendMultiplier, + multiplier, ); player.currentHp = Math.max(0, player.currentHp - damage); 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( combatant: CombatEngineCombatant, ): CombatEngineCombatant { - return { ...combatant, stats: { ...combatant.stats } }; + return { + ...combatant, + stats: { + ...combatant.stats, + statusEffects: combatant.stats.statusEffects?.map((effect) => ({ + ...effect, + })), + }, + }; } } diff --git a/apps/api/src/combat/combat-engine.types.ts b/apps/api/src/combat/combat-engine.types.ts index 31be226..fa089f8 100644 --- a/apps/api/src/combat/combat-engine.types.ts +++ b/apps/api/src/combat/combat-engine.types.ts @@ -2,12 +2,28 @@ import { Combatant } from './combatant.enum'; import { CombatAction } from './combat-action.enum'; import { CombatEventType } from './combat-event-type.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 // 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. 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 { attack: number; weaponDamage?: number; @@ -18,6 +34,12 @@ export interface CombatEngineCombatantStats { // Monster-only: set when it telegraphs, cleared when the action resolves // or is interrupted. Optional because the player's stats never carry it. pendingAction?: CombatIntent; + // Monster-only: 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 { @@ -42,6 +64,7 @@ export interface CombatEngineEvent { target: Combatant; type: CombatEventType; amount?: number; + statusEffect?: StatusEffectType; } export interface CombatEngineResult { diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts index 16a1458..0e9835f 100644 --- a/apps/api/src/combat/combat-equipment-integration.spec.ts +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -281,9 +281,7 @@ function fakeTravelService(): TravelService { function fakeRewardService(): CombatRewardService { return { - grantVictoryRewards: jest - .fn() - .mockResolvedValue({ silver: 0, items: [] }), + grantVictoryRewards: jest.fn().mockResolvedValue({ silver: 0, items: [] }), loadRewards: jest.fn().mockResolvedValue(null), } as unknown as CombatRewardService; } diff --git a/apps/api/src/combat/combat-event-type.enum.ts b/apps/api/src/combat/combat-event-type.enum.ts index 6fea0f3..938a542 100644 --- a/apps/api/src/combat/combat-event-type.enum.ts +++ b/apps/api/src/combat/combat-event-type.enum.ts @@ -4,6 +4,9 @@ export enum CombatEventType { DEFEND = 'DEFEND', TELEGRAPH = 'TELEGRAPH', INTERRUPT = 'INTERRUPT', + STATUS_APPLIED = 'STATUS_APPLIED', + STATUS_DAMAGE = 'STATUS_DAMAGE', + STATUS_EXPIRED = 'STATUS_EXPIRED', COMBAT_WON = 'COMBAT_WON', COMBAT_LOST = 'COMBAT_LOST', } diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 4012909..7de0813 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -198,8 +198,10 @@ function monster( maxHp: 45, attack: 5, armor: 0, - silverMin: 4, - silverMax: 7, + flavorText: null, + // 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', createdAt: 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, potionsRemaining: 2, potionsMax: 2, + statusEffects: [], }); expect(combat.monster).toEqual({ key: 'ash-rat', @@ -366,7 +369,10 @@ describe('CombatService', () => { it('pauses regeneration on the character once a combat starts', async () => { const state = createState({ 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 }); @@ -601,7 +607,9 @@ describe('CombatService', () => { }); 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); 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 () => { - 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); 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 () => { - 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); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); @@ -777,7 +789,19 @@ describe('CombatService', () => { }); 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); const telegraphed = await service.performAction( diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 7bfbb43..1bcedbb 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -14,7 +14,11 @@ import { TravelService } from '../travel/travel.service'; import { TravelStatus } from '../travel/travel-status.enum'; import { CombatAction } from './combat-action.enum'; import { CombatEngineService } from './combat-engine.service'; -import { CombatEngineState, CombatIntent } from './combat-engine.types'; +import { + ActiveStatusEffect, + CombatEngineState, + CombatIntent, +} from './combat-engine.types'; import { characterNotFound, characterTooWounded, @@ -30,18 +34,30 @@ import { } from './combat.errors'; import { CombatStatus } from './combat-status.enum'; 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 // persistent consumable inventory. const STARTING_POTION_COUNT = 2; +export interface CombatStatusEffectDto { + type: StatusEffectType; + remainingRounds: number; + damagePerRound: number; +} + export interface CombatPlayerDto { name: string; maxHp: number; currentHp: number; potionsRemaining: number; potionsMax: number; + statusEffects: CombatStatusEffectDto[]; } export interface CombatMonsterDto { @@ -61,6 +77,7 @@ export interface CombatEventDto { source: string; target: string; amount?: number; + statusEffect?: StatusEffectType; } export interface CombatDto { @@ -163,7 +180,11 @@ export class CombatService { armor: playerStats.armor, potionsRemaining: STARTING_POTION_COUNT, }, - monsterState: { attack: monster.attack, armor: monster.armor }, + monsterState: { + attack: monster.attack, + armor: monster.armor, + abilities: monster.abilities ?? {}, + }, completedAt: null, }); await combats.save(combat); @@ -223,11 +244,10 @@ export class CombatService { const combatEvents = manager.getRepository(CombatEvent); // Lock the character before the combat row here, matching the order - // startCombat already uses (character, then combat). grantVictoryRewards - // locks the character again later in this same transaction, which is a - // no-op re-lock — but locking it first here keeps both code paths - // consistent and avoids a lock-order inversion that could deadlock two - // concurrent requests against the same character. Do not reorder this. + // startCombat already uses (character, then combat). Keeping both code + // paths on the same order avoids a lock-order inversion that could + // deadlock two concurrent requests against the same character. Do not + // reorder this. const character = await this.lockCharacter(characters, characterId); const combat = await combats.findOne({ @@ -256,7 +276,7 @@ export class CombatService { combat.playerCurrentHp = result.state.player.currentHp; combat.monsterCurrentHp = result.state.monster.currentHp; 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) { combat.completedAt = new Date(); this.characterVitals.resume(character, combat.playerCurrentHp); @@ -284,6 +304,7 @@ export class CombatService { source: event.source, target: event.target, amount: event.amount ?? null, + statusEffect: event.statusEffect ?? null, }); await combatEvents.save(entity); } @@ -308,7 +329,13 @@ export class CombatService { 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, potionsRemaining: combat.playerState.potionsRemaining, potionsMax: STARTING_POTION_COUNT, + statusEffects: this.toStatusEffectDtos( + combat.playerState.statusEffects, + ), }, monster: { key: monster.key, @@ -437,8 +467,19 @@ export class CombatService { source: event.source, target: event.target, amount: event.amount ?? undefined, + statusEffect: event.statusEffect ?? undefined, })), rewards, }; } + + private toStatusEffectDtos( + effects: ActiveStatusEffect[] | undefined, + ): CombatStatusEffectDto[] { + return (effects ?? []).map((effect) => ({ + type: effect.type, + remainingRounds: effect.remainingRounds, + damagePerRound: effect.damagePerRound, + })); + } } diff --git a/apps/api/src/combat/entities/combat-event.entity.ts b/apps/api/src/combat/entities/combat-event.entity.ts index 1d5c2f9..e2c8fc4 100644 --- a/apps/api/src/combat/entities/combat-event.entity.ts +++ b/apps/api/src/combat/entities/combat-event.entity.ts @@ -9,6 +9,7 @@ import { } from 'typeorm'; import { Combatant } from '../combatant.enum'; import { CombatEventType } from '../combat-event-type.enum'; +import { StatusEffectType } from '../status-effect.enum'; import { Combat } from './combat.entity'; @Entity({ name: 'combat_events' }) @@ -55,6 +56,17 @@ export class CombatEvent { @Column({ name: 'amount', type: 'integer', nullable: true }) 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' }) createdAt!: Date; diff --git a/apps/api/src/combat/entities/combat.entity.ts b/apps/api/src/combat/entities/combat.entity.ts index 5b2091f..ed0bea8 100644 --- a/apps/api/src/combat/entities/combat.entity.ts +++ b/apps/api/src/combat/entities/combat.entity.ts @@ -11,13 +11,21 @@ import { import { Character } from '../../characters/entities/character.entity'; import { HuntEncounter } from '../../hunting/entities/hunt-encounter.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 type { MonsterAbilities } from '../../monsters/monster-abilities'; export interface CombatCombatantState { attack: number; armor: number; 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 { @@ -69,7 +77,7 @@ export class Combat { playerState!: CombatPlayerState; @Column({ name: 'monster_state', type: 'jsonb' }) - monsterState!: CombatCombatantState; + monsterState!: CombatMonsterState; @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; diff --git a/apps/api/src/combat/status-effect.enum.ts b/apps/api/src/combat/status-effect.enum.ts new file mode 100644 index 0000000..ee6630a --- /dev/null +++ b/apps/api/src/combat/status-effect.enum.ts @@ -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', +} diff --git a/apps/api/src/conditions/conditions.module.ts b/apps/api/src/conditions/conditions.module.ts new file mode 100644 index 0000000..60cce83 --- /dev/null +++ b/apps/api/src/conditions/conditions.module.ts @@ -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 {} diff --git a/apps/api/src/conditions/game-condition.service.spec.ts b/apps/api/src/conditions/game-condition.service.spec.ts new file mode 100644 index 0000000..8cf3375 --- /dev/null +++ b/apps/api/src/conditions/game-condition.service.spec.ts @@ -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 | 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]); + }); +}); diff --git a/apps/api/src/conditions/game-condition.service.ts b/apps/api/src/conditions/game-condition.service.ts new file mode 100644 index 0000000..f338e77 --- /dev/null +++ b/apps/api/src/conditions/game-condition.service.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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, + ); + } +} diff --git a/apps/api/src/conditions/game-condition.types.ts b/apps/api/src/conditions/game-condition.types.ts new file mode 100644 index 0000000..d4ad473 --- /dev/null +++ b/apps/api/src/conditions/game-condition.types.ts @@ -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 = + 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; + } +} diff --git a/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts index 65890b6..1a0c0f6 100644 --- a/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts +++ b/apps/api/src/database/migrations/1792000000000-AddHpRegeneration.ts @@ -20,6 +20,8 @@ export class AddHpRegeneration1792000000000 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "hp_regen_since"'); + await queryRunner.query( + 'ALTER TABLE "characters" DROP COLUMN "hp_regen_since"', + ); } } diff --git a/apps/api/src/database/migrations/1793000000000-CompleteBurnedRoad.ts b/apps/api/src/database/migrations/1793000000000-CompleteBurnedRoad.ts new file mode 100644 index 0000000..ee3811e --- /dev/null +++ b/apps/api/src/database/migrations/1793000000000-CompleteBurnedRoad.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CompleteBurnedRoad1793000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // --- 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 { + // 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"', + ); + } +} diff --git a/apps/api/src/database/migrations/1794000000000-CreateLootBags.ts b/apps/api/src/database/migrations/1794000000000-CreateLootBags.ts new file mode 100644 index 0000000..30b1b1b --- /dev/null +++ b/apps/api/src/database/migrations/1794000000000-CreateLootBags.ts @@ -0,0 +1,133 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLootBags1794000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // --- 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 { + // 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"'); + } +} diff --git a/apps/api/src/database/migrations/1795000000000-CreateNpcSystem.ts b/apps/api/src/database/migrations/1795000000000-CreateNpcSystem.ts new file mode 100644 index 0000000..bb240a2 --- /dev/null +++ b/apps/api/src/database/migrations/1795000000000-CreateNpcSystem.ts @@ -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 { + 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 { + // 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"`); + } +} diff --git a/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts index 109fbbe..47c250e 100644 --- a/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts +++ b/apps/api/src/database/migrations/add-hp-regeneration.migration.spec.ts @@ -7,7 +7,8 @@ describe('characters.hp_regen_since schema', () => { const metadata = getMetadataArgsStorage(); const column = metadata.columns.find( (candidate) => - candidate.target === Character && candidate.propertyName === 'hpRegenSince', + candidate.target === Character && + candidate.propertyName === 'hpRegenSince', ); expect(column).toBeDefined(); diff --git a/apps/api/src/database/migrations/complete-burned-road.migration.spec.ts b/apps/api/src/database/migrations/complete-burned-road.migration.spec.ts new file mode 100644 index 0000000..4bab270 --- /dev/null +++ b/apps/api/src/database/migrations/complete-burned-road.migration.spec.ts @@ -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', + ]), + ); + }); +}); diff --git a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts index 56f3c40..87c9bca 100644 --- a/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts +++ b/apps/api/src/database/migrations/loot-and-rewards.migration.spec.ts @@ -79,8 +79,11 @@ describe('loot and rewards schema', () => { propertyName: 'combatReward', 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({ - onDelete: 'RESTRICT', + onDelete: 'SET NULL', propertyName: 'characterItem', target: CombatRewardItem, }), diff --git a/apps/api/src/database/migrations/loot-bags.migration.spec.ts b/apps/api/src/database/migrations/loot-bags.migration.spec.ts new file mode 100644 index 0000000..1bd917e --- /dev/null +++ b/apps/api/src/database/migrations/loot-bags.migration.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/api/src/database/migrations/npc-system.migration.spec.ts b/apps/api/src/database/migrations/npc-system.migration.spec.ts new file mode 100644 index 0000000..a860f0f --- /dev/null +++ b/apps/api/src/database/migrations/npc-system.migration.spec.ts @@ -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 { + 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 { + 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'); + }); +}); diff --git a/apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts b/apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts index 81cb956..cd7b186 100644 --- a/apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts +++ b/apps/api/src/database/migrations/renown-and-reputation-entities.metadata.spec.ts @@ -8,7 +8,6 @@ import { ReputationFaction } from '../../reputation/entities/reputation-faction. import { CharacterReputation } from '../../reputation/entities/character-reputation.entity'; import { RenownMilestoneDefinition } from '../../renown/entities/renown-milestone-definition.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[] { return getMetadataArgsStorage() @@ -71,7 +70,7 @@ describe('Slice 0.6.5 entity metadata', () => { ).toBe(true); }); - it('TurnInDefinition has a unique key', () => { - expect(uniqueIndexFor(TurnInDefinition, ['key'])).toBe(true); - }); + // TurnInDefinition's metadata assertion lived here until Slice 0.8 retired + // that entity in favour of ExchangeRule. Its replacement is covered by + // `npc-system.migration.spec.ts`. }); diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts index f63ad86..f5dc4da 100644 --- a/apps/api/src/database/seeds/item-content.ts +++ b/apps/api/src/database/seeds/item-content.ts @@ -1,11 +1,14 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemRarity } from '../../items/item-rarity.enum'; import { ItemType } from '../../items/item-type.enum'; +import { LootCategory } from '../../items/loot-category.enum'; import { ASH_RAT_LOOT_TABLE_ID, + CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, ItemKey, ROAD_BANDIT_LOOT_TABLE_ID, + WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; export interface SeedItemDefinition { @@ -16,6 +19,7 @@ export interface SeedItemDefinition { type: ItemType; equipmentSlot: EquipmentSlot | null; rarity: ItemRarity; + lootCategory: LootCategory | null; tier: number; weaponDamage: number; bonusHp: number; @@ -38,6 +42,8 @@ function item( 'weaponDamage' | 'bonusHp' | 'bonusAttack' | 'bonusArmor' > > = {}, + // Only trade goods carry one; everything else is uncapped (0.7.5 §4, §8). + lootCategory: LootCategory | null = null, ): SeedItemDefinition { return { id: ITEM_IDS[key], @@ -47,6 +53,7 @@ function item( type, equipmentSlot, rarity, + lootCategory, tier: 1, weaponDamage: stats.weaponDamage ?? 0, bonusHp: stats.bonusHp ?? 0, @@ -161,23 +168,49 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [ null, ItemRarity.COMMON, ), - // A Trade Good (spec §17): turned in to the Border Watch for Silver and - // reputation rather than crafted with. + // Trade Goods (spec §17): turned in to the Border Watch for Silver and + // 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( 'ash-pelt', - 'Ash Pelt', + 'Ashen Pelt', 'Singed pelt, tough as leather and grey with drifting ash.', ItemType.TRADE_GOOD, null, 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( 'bandit-insignia', - 'Bandit Insignia', + 'Raider Insignia', 'A roughly stamped token marking a road bandit as one of their band.', ItemType.TROPHY, null, 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', 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 { @@ -218,17 +261,33 @@ function entry( } /** - * Drop chances from docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28. - * Every entry is an independent roll (spec §17), rolled in `position` order. + * Equipment drop chances come from + * docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md §27–28; 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 - * omitted here because Slice 0.4 implements no consumables (spec §16). + * `position` values of pre-existing entries are left where they were: the + * 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[] = [ - 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(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, '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'), ]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts index ab3d498..54b6036 100644 --- a/apps/api/src/database/seeds/item.constants.ts +++ b/apps/api/src/database/seeds/item.constants.ts @@ -13,9 +13,17 @@ export const ITEM_IDS = { 'small-healing-potion': '50000000-0000-4000-8000-00000000000b', 'ash-pelt': '50000000-0000-4000-8000-00000000000c', 'bandit-insignia': '50000000-0000-4000-8000-00000000000d', + 'tough-hide': '50000000-0000-4000-8000-00000000000e', + 'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f', } as const; 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 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'; diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index 2293981..3275c4e 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -126,14 +126,15 @@ export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = { // promise a drop the roll does not guarantee (spec §8, "Mögliche // Belohnungen"). // - // 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 - // monster seeded on this road now rolls silverMin/silverMax = 0 (design R7), - // so a kill here yields neither. Silver reaches the player through turn-ins - // instead. Leaving either entry in place would break this list's own rule. + // Silver and experience were both dropped from this preview by slice 0.6.5, + // and Playable Slice 0.7 V2 §7 makes that permanent: a normal kill grants + // no XP, Silver, regional reputation or World Renown, and the monster + // definitions no longer carry a currency range at all. Silver reaches the + // player through turn-ins instead. Leaving either entry in place would + // break this list's own rule. localRewardPreview: [ { 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: '"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', title: 'Road South', @@ -192,6 +207,16 @@ export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { enabled: true, 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', label: 'Read the notice', diff --git a/apps/api/src/database/seeds/loot-bag-content.ts b/apps/api/src/database/seeds/loot-bag-content.ts new file mode 100644 index 0000000..dea6c3f --- /dev/null +++ b/apps/api/src/database/seeds/loot-bag-content.ts @@ -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', + }, +]; diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts new file mode 100644 index 0000000..3f48340 --- /dev/null +++ b/apps/api/src/database/seeds/npc-content.ts @@ -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, + }, +]; diff --git a/apps/api/src/database/seeds/turn-in-content.ts b/apps/api/src/database/seeds/turn-in-content.ts deleted file mode 100644 index 34be562..0000000 --- a/apps/api/src/database/seeds/turn-in-content.ts +++ /dev/null @@ -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, - }, -]; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 59418bf..e4e774d 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -5,17 +5,27 @@ import { CharacterItem } from '../../items/entities/character-item.entity'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.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 { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.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 { ASH_RAT_LOOT_TABLE_ID, + CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID, + WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; import { seedVisibleVerticalSlice } from './vertical-slice.seed'; @@ -88,7 +98,15 @@ function createDataSource( characterItemRepository: InMemoryRepository = new InMemoryRepository(), characterEquipmentRepository: 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 { return { getRepository: jest.fn((entity: unknown) => { @@ -103,7 +121,16 @@ function createDataSource( if (entity === CharacterItem) return characterItemRepository; if (entity === CharacterEquipment) return characterEquipmentRepository; 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'); }), @@ -182,67 +209,90 @@ describe('seedVisibleVerticalSlice', () => { expect.objectContaining({ key: 'ash-rat', name: 'Ash Rat', + monsterCategory: 'BEAST', level: 1, maxHp: 45, attack: 5, armor: 0, - silverMin: 0, - silverMax: 0, artworkPath: '/images/monsters/ash-rat.png', + // Pure baseline combat: no telegraph, no status effect (spec §4). + abilities: {}, }), expect.objectContaining({ key: 'road-bandit', name: 'Road Bandit', + monsterCategory: 'HUMANOID', level: 2, maxHp: 75, attack: 9, armor: 5, - silverMin: 0, - silverMax: 0, artworkPath: '/images/monsters/road-bandit.png', + abilities: { telegraph: { roundInterval: 3, damageMultiplier: 1.6 } }, }), expect.objectContaining({ key: 'wild-road-dog', name: 'Feral Road Hound', + monsterCategory: 'BEAST', level: 1, - silverMin: 0, - silverMax: 0, artworkPath: '/images/monsters/wild-road-dog.png', iconPath: '/images/combat/icons/wild-road-dog-128.png', + abilities: { + bleed: { roundInterval: 3, damagePerRound: 5, durationRounds: 2 }, + }, }), expect.objectContaining({ key: 'charred-looter', name: 'Charred Raider', + monsterCategory: 'HUMANOID', level: 2, - silverMin: 0, - silverMax: 0, artworkPath: '/images/monsters/charred-looter.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.arrayContaining([ expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ASH_RAT_MONSTER_ID, - weight: 40, + weight: 50, + encounterType: 'NORMAL', }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: WILD_ROAD_DOG_MONSTER_ID, weight: 30, + encounterType: 'NORMAL', }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ROAD_BANDIT_MONSTER_ID, - weight: 20, + weight: 18, + encounterType: 'NORMAL', }), + // The rare find, at 2 out of 100 (spec §3). expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: CHARRED_LOOTER_MONSTER_ID, - weight: 10, + weight: 2, + encounterType: 'RARE', }), ]), ['locationId', 'monsterId'], @@ -328,7 +378,8 @@ describe('seedVisibleVerticalSlice', () => { 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. expect( (southGate.localPointsOfInterest as { type: string }[]).some( @@ -415,7 +466,7 @@ describe('seedVisibleVerticalSlice', () => { await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - expect(itemRepository.rows).toHaveLength(13); + expect(itemRepository.rows).toHaveLength(15); expect(itemRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -431,35 +482,89 @@ describe('seedVisibleVerticalSlice', () => { }), expect.objectContaining({ key: 'ash-pelt', - name: 'Ash Pelt', + name: 'Ashen Pelt', type: 'TRADE_GOOD', equipmentSlot: null, + lootCategory: 'HIDE', + }), + expect.objectContaining({ + key: 'tough-hide', + name: 'Tough Hide', + type: 'TRADE_GOOD', + equipmentSlot: null, + lootCategory: 'HIDE', }), expect.objectContaining({ key: 'bandit-insignia', - name: 'Bandit Insignia', + name: 'Raider Insignia', type: 'TROPHY', 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); - expect(lootEntryRepository.rows).toHaveLength(6); + // Equipment and consumables stay outside the carrying system entirely + // (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.arrayContaining([ + expect.objectContaining({ + lootTableId, + itemDefinitionId, + dropChance: '1.0000', + 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({ - lootTableId: ASH_RAT_LOOT_TABLE_ID, - itemDefinitionId: ITEM_IDS['ash-pelt'], - position: 1, - dropChance: '0.6000', - }), expect.objectContaining({ lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, itemDefinitionId: ITEM_IDS['bandit-blade'], position: 1, 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', lootTableId: ASH_RAT_LOOT_TABLE_ID, }), + expect.objectContaining({ + key: 'wild-road-dog', + lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID, + }), expect.objectContaining({ key: 'road-bandit', 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 () => { const locationRepository = new InMemoryRepository(); const connectionRepository = new InMemoryRepository(); @@ -631,8 +822,10 @@ describe('seedVisibleVerticalSlice', () => { expect(faction).toMatchObject({ name: 'Border Watch', enabled: true }); }); - it('seeds both turn-in definitions', async () => { - const turnInDefinitionRepository = new InMemoryRepository(); + it('seeds Borin with his shop and every trade-in rule', async () => { + const npcDefinitionRepository = new InMemoryRepository(); + const npcShopRepository = new InMemoryRepository(); + const exchangeRuleRepository = new InMemoryRepository(); const dataSource = createDataSource( new InMemoryRepository(), new InMemoryRepository(), @@ -645,13 +838,70 @@ describe('seedVisibleVerticalSlice', () => { 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); - expect( - turnInDefinitionRepository.rows.map((row: Row) => row.key).sort(), - ).toEqual(['ash-pelt-border-guard', 'bandit-insignia-border-guard']); + // Non-repeatable, so routine trading cannot pump a power rank + // (Slice 0.6.5 §2.2). + expect(renownMilestoneRepository.rows).toHaveLength(1); + expect(renownMilestoneRepository.rows[0]).toMatchObject({ + key: 'first-goods-returned', + repeatable: false, + enabled: true, + }); }); }); diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index d4900a0..b7a0f47 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -11,13 +11,22 @@ import { EquipmentSlot } from '../../items/equipment-slot.enum'; import { ItemDefinition } from '../../items/entities/item-definition.entity'; import { LootTableEntry } from '../../loot/entities/loot-table-entry.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 { MonsterCategory } from '../../monsters/monster-category.enum'; import { LocationMonster } from '../../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.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 { ITEM_DEFINITIONS, LOOT_TABLES, @@ -25,15 +34,30 @@ import { } from './item-content'; import { ASH_RAT_LOOT_TABLE_ID, + CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, ROAD_BANDIT_LOOT_TABLE_ID, + WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; import { BURNED_ROAD_LOCAL_CONTENT, SOUTH_GATE_LOCAL_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 { 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 { ASH_RAT_MONSTER_ID, BURNED_ROAD_ID, @@ -54,9 +78,20 @@ export async function seedVisibleVerticalSlice( const itemRepository = dataSource.getRepository(ItemDefinition); const lootTableRepository = dataSource.getRepository(LootTable); const lootEntryRepository = dataSource.getRepository(LootTableEntry); + const lootBagDefinitionRepository = + dataSource.getRepository(LootBagDefinition); const reputationFactionRepository = 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 = [ { @@ -136,20 +171,27 @@ export async function seedVisibleVerticalSlice( 'lootTableId', 'itemDefinitionId', ]); + await lootBagDefinitionRepository.upsert(LOOT_BAG_DEFINITIONS, ['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 = [ { id: ASH_RAT_MONSTER_ID, key: 'ash-rat', name: 'Ash Rat', + monsterCategory: MonsterCategory.BEAST, level: 1, maxHp: 45, attack: 5, armor: 0, - silverMin: 0, - silverMax: 0, + flavorText: 'Scrawny and quick, it feeds on whatever the fires left.', + // Pure baseline combat: no telegraph, no status effect, short fight. + abilities: {}, artworkPath: '/images/monsters/ash-rat.png', iconPath: '/images/combat/icons/ash-rat-128.png', lootTableId: ASH_RAT_LOOT_TABLE_ID, @@ -158,28 +200,39 @@ export async function seedVisibleVerticalSlice( id: WILD_ROAD_DOG_MONSTER_ID, key: 'wild-road-dog', name: 'Feral Road Hound', + monsterCategory: MonsterCategory.BEAST, level: 1, maxHp: 55, attack: 7, armor: 0, - silverMin: 0, - silverMax: 0, + flavorText: + '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', iconPath: '/images/combat/icons/wild-road-dog-128.png', - // Shares the beast table: both are scorched road animals that leave a - // pelt behind. A table of its own waits for content that differs. - lootTableId: ASH_RAT_LOOT_TABLE_ID, + lootTableId: WILD_ROAD_DOG_LOOT_TABLE_ID, }, { id: ROAD_BANDIT_MONSTER_ID, key: 'road-bandit', name: 'Road Bandit', + monsterCategory: MonsterCategory.HUMANOID, level: 2, maxHp: 75, attack: 9, armor: 5, - silverMin: 0, - silverMax: 0, + flavorText: + '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', iconPath: '/images/combat/icons/road-bandit-128.png', lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, @@ -188,16 +241,22 @@ export async function seedVisibleVerticalSlice( id: CHARRED_LOOTER_MONSTER_ID, key: 'charred-looter', name: 'Charred Raider', + monsterCategory: MonsterCategory.HUMANOID, level: 2, maxHp: 85, attack: 11, armor: 6, - silverMin: 0, - silverMax: 0, + flavorText: + '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', iconPath: '/images/combat/icons/charred-looter-128.png', - // Shares the raider table: same gear, taken from the same caravans. - lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, + lootTableId: CHARRED_LOOTER_LOOT_TABLE_ID, }, ]; @@ -217,22 +276,30 @@ export async function seedVisibleVerticalSlice( monsterIds.set(key, existingMonster?.id ?? id); } - // Weights read as "how often you meet this on the road". They also drive the - // location's danger rating, which is computed from the weighted average of - // the pool rather than from its single worst entry. - const encounterWeights: Readonly> = { - 'ash-rat': 40, - 'wild-road-dog': 30, - 'road-bandit': 20, - 'charred-looter': 10, - }; + // Weights read as "how often you meet this on the road" (spec §3). They also + // drive the location's danger rating, which is computed from the weighted + // average of the pool rather than from its single worst entry. + // + // The Charred Raider is deliberately the odd one out at weight 2: it is a + // rare find, and `EncounterType.RARE` is what the hunt card reads to mark it + // as such. + 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( - Object.entries(encounterWeights).map(([key, weight]) => ({ + encounterPool.map(({ key, weight, encounterType }) => ({ locationId: burnedRoadId, monsterId: monsterIds.get(key) as string, weight, - encounterType: EncounterType.NORMAL, + encounterType, enabled: true, })), ['locationId', 'monsterId'], @@ -294,4 +361,62 @@ export async function seedVisibleVerticalSlice( 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', + ]); } diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts index 98c87e5..ff56e91 100644 --- a/apps/api/src/equipment/equipment.service.ts +++ b/apps/api/src/equipment/equipment.service.ts @@ -110,7 +110,10 @@ export class EquipmentService { throw itemNotEquippable(); } - const statsBeforeChange = await this.characterStats.calculate(character, manager); + const statsBeforeChange = await this.characterStats.calculate( + character, + manager, + ); this.characterVitals.settle(character, statsBeforeChange.maxHp); await characters.save(character); diff --git a/apps/api/src/exchanges/dto/exchange.dto.ts b/apps/api/src/exchanges/dto/exchange.dto.ts new file mode 100644 index 0000000..8a63a24 --- /dev/null +++ b/apps/api/src/exchanges/dto/exchange.dto.ts @@ -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[]; +} diff --git a/apps/api/src/exchanges/entities/exchange-rule.entity.ts b/apps/api/src/exchanges/entities/exchange-rule.entity.ts new file mode 100644 index 0000000..34ab882 --- /dev/null +++ b/apps/api/src/exchanges/entities/exchange-rule.entity.ts @@ -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; +} diff --git a/apps/api/src/exchanges/entities/npc-exchange-profile.entity.ts b/apps/api/src/exchanges/entities/npc-exchange-profile.entity.ts new file mode 100644 index 0000000..edbd393 --- /dev/null +++ b/apps/api/src/exchanges/entities/npc-exchange-profile.entity.ts @@ -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; +} diff --git a/apps/api/src/exchanges/exchange.controller.ts b/apps/api/src/exchanges/exchange.controller.ts new file mode 100644 index 0000000..67a1d52 --- /dev/null +++ b/apps/api/src/exchanges/exchange.controller.ts @@ -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 { + return this.exchangeService.getMerchantExchangeView( + DEMO_CHARACTER_ID, + merchantKey, + ); + } + + @Post('trade-in') + tradeIn( + @Param('merchantKey') merchantKey: string, + @Body() request: ExchangeRequestDto, + ): Promise { + return this.exchangeService.exchangeWithMerchant( + DEMO_CHARACTER_ID, + merchantKey, + request.items, + ); + } +} diff --git a/apps/api/src/exchanges/exchange.errors.ts b/apps/api/src/exchanges/exchange.errors.ts new file mode 100644 index 0000000..0603303 --- /dev/null +++ b/apps/api/src/exchanges/exchange.errors.ts @@ -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'; diff --git a/apps/api/src/exchanges/exchange.service.spec.ts b/apps/api/src/exchanges/exchange.service.spec.ts new file mode 100644 index 0000000..454dd27 --- /dev/null +++ b/apps/api/src/exchanges/exchange.service.spec.ts @@ -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 { + 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), + 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), + ]; + + 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 (run: (m: EntityManager) => Promise) => + 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); + }); +}); diff --git a/apps/api/src/exchanges/exchange.service.ts b/apps/api/src/exchanges/exchange.service.ts new file mode 100644 index 0000000..53e1dd5 --- /dev/null +++ b/apps/api/src/exchanges/exchange.service.ts @@ -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 { + 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 { + const profileKey = await this.resolveMerchantProfileKey( + characterId, + merchantKey, + ); + return this.exchange(characterId, profileKey, requested); + } + + private async resolveMerchantProfileKey( + characterId: string, + merchantKey: string, + ): Promise { + 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 { + 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 { + 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(); + 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 { + if (!requested || requested.length === 0) { + throw exchangeEmptyRequest(); + } + + const merged = new Map(); + 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 { + 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, + ): 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, + ): Promise> { + if (itemDefinitionIds.length === 0) { + return new Map(); + } + + const owned = await scope.getRepository(CharacterItem).find({ + where: { characterId, itemDefinitionId: In(itemDefinitionIds) }, + }); + + const totals = new Map(); + for (const item of owned) { + totals.set( + item.itemDefinitionId, + (totals.get(item.itemDefinitionId) ?? 0) + item.quantity, + ); + } + return totals; + } +} diff --git a/apps/api/src/exchanges/exchanges.module.ts b/apps/api/src/exchanges/exchanges.module.ts new file mode 100644 index 0000000..e56d583 --- /dev/null +++ b/apps/api/src/exchanges/exchanges.module.ts @@ -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 {} diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 87d14fc..1c42bfc 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -20,8 +20,12 @@ const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001'; const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002'; const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Ash Rat 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_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 { characters: Character[]; @@ -259,8 +263,8 @@ function monsterDefinition( maxHp: 20, attack: 3, armor: 0, - silverMin: 1, - silverMax: 3, + flavorText: null, + abilities: {}, artworkPath: `/assets/monsters/${key}.webp`, createdAt: 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, weight: number, enabled = true, + encounterType: EncounterType = EncounterType.NORMAL, ): LocationMonster { return { id, locationId, monsterId: monster.id, weight, - encounterType: EncounterType.NORMAL, + encounterType, enabled, monster, } 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 { return { characters: [character(safeLocation())], @@ -364,23 +413,7 @@ describe('HuntingService', () => { }); it('starts a valid hunt with exactly three saved encounters', async () => { - const monsterA = monsterDefinition( - 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 state = stateAtHuntingGround(burnedRoadPool()); const { dataSource, service } = createService({ state, randomSource: fakeRandomSource([0.1, 0.1, 0.1]), @@ -444,52 +477,109 @@ describe('HuntingService', () => { }); it('picks monsters deterministically from canned RandomSource rolls', async () => { - const monsterA = monsterDefinition( - 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), - ]; - // 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A + const state = stateAtHuntingGround(burnedRoadPool()); + // Weights 50/30/18/2. Each pick is removed from the pool before the next + // roll, so the totals shrink: 0.1*100=10 -> Ash Rat (<50); the hound and + // the bandit remain in front of the rare, so 0.1*50=5 -> Feral Road Hound + // and 0.1*20=2 -> Road Bandit. const { dataSource, service } = createService({ state, - randomSource: fakeRandomSource([0.1, 0.9, 0.1]), + randomSource: fakeRandomSource([0.1, 0.1, 0.1]), }); const result = await service.startHunt(CHARACTER_ID); expect(result.encounters.map((e) => e.monster.key)).toEqual([ - 'aschenratte', - 'strassenraeuber', - 'aschenratte', + 'ash-rat', + 'wild-road-dog', + 'road-bandit', ]); const persisted = [...dataSource.state.huntEncounters].sort( (a, b) => a.position - b.position, ); expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([ MONSTER_A_ID, + MONSTER_C_ID, MONSTER_B_ID, - MONSTER_A_ID, ]); }); - it('supersedes the previous active hunt when a new hunt is started', async () => { - const monsterA = monsterDefinition( - MONSTER_A_ID, - 'aschenratte', - 'Ash Rat', + it('rolls the rare Charred Raider from a canned roll inside its 2% band', async () => { + const state = stateAtHuntingGround(burnedRoadPool()); + // 0.99*100 = 99, past the 98 the three common entries cover, so the last + // 2 points of weight -- the rare -- take the first card. + 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(); state.characters[0].currentLocationId = HUNTING_LOCATION_ID; 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 () => { - const monsterA = monsterDefinition( - 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 state = stateAtHuntingGround(burnedRoadPool()); const { dataSource, service } = createService({ state, - randomSource: fakeRandomSource([0.1, 0.9, 0.1]), + randomSource: fakeRandomSource([0.99, 0.1, 0.1]), }); await service.startHunt(CHARACTER_ID); @@ -550,9 +624,9 @@ describe('HuntingService', () => { ); const ids = encounters.map((e) => e.id); expect(new Set(ids).size).toBe(3); - expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID); - expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID); - expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID); + expect(encounters[0].monsterDefinitionId).toBe(MONSTER_D_ID); + expect(encounters[1].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 () => { @@ -592,22 +666,7 @@ describe('HuntingService', () => { }); it('marks every freshly rolled encounter as AVAILABLE', async () => { - const monsterA = monsterDefinition( - 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 state = stateAtHuntingGround(burnedRoadPool()); const { dataSource, service } = createService({ state, randomSource: fakeRandomSource([0.1, 0.1, 0.1]), diff --git a/apps/api/src/hunting/hunting.service.ts b/apps/api/src/hunting/hunting.service.ts index dc759c7..439ee54 100644 --- a/apps/api/src/hunting/hunting.service.ts +++ b/apps/api/src/hunting/hunting.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; +import { EncounterType } from '../monsters/entities/encounter-type.enum'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import type { LocationSummary } from '../travel/travel.service'; @@ -25,6 +26,8 @@ export interface MonsterSummary { name: string; level: number; artworkPath: string; + /** Short atmosphere line for the encounter card; null when unauthored. */ + flavorText: string | null; } export interface HuntEncounterDto { @@ -32,6 +35,8 @@ export interface HuntEncounterDto { monster: MonsterSummary; dangerRating: DangerRating; status: HuntEncounterStatus; + /** Lets the card mark a rare find without knowing any monster keys. */ + encounterType: EncounterType; } export interface HuntResultDto { @@ -40,7 +45,9 @@ export interface HuntResultDto { 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() export class HuntingService { @@ -103,20 +110,27 @@ export class HuntingService { }); await txHunts.save(hunt); - const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT); + const picked = this.rollEncounters(pool, MAX_ENCOUNTER_COUNT); const encounterDtos: HuntEncounterDto[] = []; - for (let position = 0; position < pickedMonsters.length; position += 1) { - const monster = pickedMonsters[position]; + for (let position = 0; position < picked.length; position += 1) { + const entry = picked[position]; const encounter = txEncounters.create({ huntId: hunt.id, - monsterDefinitionId: monster.id, + monsterDefinitionId: entry.monster.id, position, status: HuntEncounterStatus.AVAILABLE, }); await txEncounters.save(encounter); - encounterDtos.push(this.toEncounterDto(encounter, monster, character)); + encounterDtos.push( + this.toEncounterDto( + encounter, + entry.monster, + character, + entry.encounterType, + ), + ); } return { @@ -161,19 +175,38 @@ export class HuntingService { 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 { id: hunt.id, location: this.toLocationSummary(character.currentLocation), 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> { + 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( encounter: HuntEncounter, monster: MonsterDefinition, character: Character, + encounterType: EncounterType, ): HuntEncounterDto { return { id: encounter.id, @@ -182,41 +215,54 @@ export class HuntingService { name: monster.name, level: monster.level, artworkPath: monster.artworkPath, + flavorText: monster.flavorText ?? null, }, dangerRating: calculateDangerRating( { attack: character.baseAttack, armor: 0, hp: character.baseHp }, { attack: monster.attack, armor: monster.armor, hp: monster.maxHp }, ), status: encounter.status, + encounterType, }; } /** - * Rolls `count` independent weighted picks from `pool`. Each slot walks - * the pool in the order it was supplied, accumulating weight, and picks - * the first entry whose cumulative weight exceeds the roll - * (roll < cumulative). Pure and deterministic given a RandomSource, so - * it is trivially unit-testable with canned `next()` values. + * Draws up to `count` *distinct* monsters from `pool` (spec §3). + * + * Each slot is one weighted pick over the entries still available, and the + * winner is then removed so the same monster cannot fill two cards -- the + * 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( pool: LocationMonster[], count: number, - ): MonsterDefinition[] { - const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0); - const picks: MonsterDefinition[] = []; - for (let i = 0; i < count; i += 1) { + ): LocationMonster[] { + const remaining = [...pool]; + const picks: LocationMonster[] = []; + + while (picks.length < count && remaining.length > 0) { + const totalWeight = remaining.reduce((sum, e) => sum + e.weight, 0); const roll = this.randomSource.next() * totalWeight; let cumulative = 0; - let picked: LocationMonster = pool[pool.length - 1]; - for (const entry of pool) { - cumulative += entry.weight; + let pickedIndex = remaining.length - 1; + for (let index = 0; index < remaining.length; index += 1) { + cumulative += remaining[index].weight; if (roll < cumulative) { - picked = entry; + pickedIndex = index; break; } } - picks.push(picked.monster); + picks.push(remaining.splice(pickedIndex, 1)[0]); } + return picks; } diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts index dcc1dac..3993f76 100644 --- a/apps/api/src/inventory/inventory.service.spec.ts +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -19,7 +19,8 @@ function characterItem(overrides: Partial = {}): CharacterItem { itemDefinition: { key: 'worn-short-sword', 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, type: ItemType.EQUIPMENT, equipmentSlot: EquipmentSlot.WEAPON, @@ -67,7 +68,8 @@ describe('InventoryService', () => { item: { key: 'worn-short-sword', 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', equipmentSlot: 'WEAPON', weaponDamage: 8, diff --git a/apps/api/src/items/entities/item-definition.entity.ts b/apps/api/src/items/entities/item-definition.entity.ts index 616be6e..6d2385a 100644 --- a/apps/api/src/items/entities/item-definition.entity.ts +++ b/apps/api/src/items/entities/item-definition.entity.ts @@ -9,6 +9,7 @@ import { import { EquipmentSlot } from '../equipment-slot.enum'; import { ItemRarity } from '../item-rarity.enum'; import { ItemType } from '../item-type.enum'; +import { LootCategory } from '../loot-category.enum'; @Entity({ name: 'item_definitions' }) @Index('IDX_item_definitions_key', ['key'], { unique: true }) @@ -50,6 +51,18 @@ export class ItemDefinition { }) 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' }) tier!: number; diff --git a/apps/api/src/items/loot-category.enum.ts b/apps/api/src/items/loot-category.enum.ts new file mode 100644 index 0000000..80e011e --- /dev/null +++ b/apps/api/src/items/loot-category.enum.ts @@ -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', +} diff --git a/apps/api/src/loot-bags/entities/character-loot-bag.entity.ts b/apps/api/src/loot-bags/entities/character-loot-bag.entity.ts new file mode 100644 index 0000000..106c9a5 --- /dev/null +++ b/apps/api/src/loot-bags/entities/character-loot-bag.entity.ts @@ -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; +} diff --git a/apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts b/apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts new file mode 100644 index 0000000..0e19905 --- /dev/null +++ b/apps/api/src/loot-bags/entities/loot-bag-definition.entity.ts @@ -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; +} diff --git a/apps/api/src/loot-bags/loot-bags.controller.ts b/apps/api/src/loot-bags/loot-bags.controller.ts new file mode 100644 index 0000000..1b48f38 --- /dev/null +++ b/apps/api/src/loot-bags/loot-bags.controller.ts @@ -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 { + return this.lootCapacity.getCapacities(DEMO_CHARACTER_ID); + } +} diff --git a/apps/api/src/loot-bags/loot-bags.module.ts b/apps/api/src/loot-bags/loot-bags.module.ts new file mode 100644 index 0000000..9eeb71a --- /dev/null +++ b/apps/api/src/loot-bags/loot-bags.module.ts @@ -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 {} diff --git a/apps/api/src/loot-bags/loot-capacity.service.spec.ts b/apps/api/src/loot-bags/loot-capacity.service.spec.ts new file mode 100644 index 0000000..2f4a327 --- /dev/null +++ b/apps/api/src/loot-bags/loot-capacity.service.spec.ts @@ -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 { + constructor(private readonly rows: T[]) {} + + find(options: { where: Partial }): Promise { + 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: (target: EntityTarget) => { + 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 = {}) { + 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); + }); + }); +}); diff --git a/apps/api/src/loot-bags/loot-capacity.service.ts b/apps/api/src/loot-bags/loot-capacity.service.ts new file mode 100644 index 0000000..500e27d --- /dev/null +++ b/apps/api/src/loot-bags/loot-capacity.service.ts @@ -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; + +/** + * 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 { + 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 { + 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> { + const items = await db.getRepository(CharacterItem).find({ + where: { characterId }, + relations: { itemDefinition: true }, + }); + + const totals = new Map(); + 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> { + const owned = await db.getRepository(CharacterLootBag).find({ + where: { characterId, active: true }, + relations: { lootBagDefinition: true }, + }); + + const best = new Map(); + 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) {} + + /** + * 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; + } +} diff --git a/apps/api/src/loot/loot.service.spec.ts b/apps/api/src/loot/loot.service.spec.ts index 6366fca..e44f4f0 100644 --- a/apps/api/src/loot/loot.service.spec.ts +++ b/apps/api/src/loot/loot.service.spec.ts @@ -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 () => { const service = new LootService( dataSourceWith(ashRatEntries), diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts index 4a94aa6..89eb855 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -9,6 +9,8 @@ import { UpdateDateColumn, } from 'typeorm'; import { LootTable } from '../../loot/entities/loot-table.entity'; +import { MonsterCategory } from '../monster-category.enum'; +import type { MonsterAbilities } from '../monster-abilities'; @Entity({ name: 'monster_definitions' }) @Index('IDX_monster_definitions_key', ['key'], { unique: true }) @@ -22,6 +24,16 @@ export class MonsterDefinition { @Column({ name: 'name', type: 'varchar', length: 150 }) 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' }) level!: number; @@ -34,11 +46,15 @@ export class MonsterDefinition { @Column({ name: 'armor', type: 'integer' }) armor!: number; - @Column({ name: 'silver_min', type: 'integer' }) - silverMin!: number; + // Short line of atmosphere shown on the hunt encounter card (spec §9). + // 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' }) - silverMax!: number; + // Combat mechanics as content, not code (spec §4, §8). `{}` is a monster + // with no special mechanic. + @Column({ name: 'abilities', type: 'jsonb', default: () => "'{}'::jsonb" }) + abilities!: MonsterAbilities; @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) artworkPath!: string; diff --git a/apps/api/src/monsters/monster-abilities.ts b/apps/api/src/monsters/monster-abilities.ts new file mode 100644 index 0000000..fbf61a1 --- /dev/null +++ b/apps/api/src/monsters/monster-abilities.ts @@ -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 = {}; diff --git a/apps/api/src/monsters/monster-category.enum.ts b/apps/api/src/monsters/monster-category.enum.ts new file mode 100644 index 0000000..41fa54a --- /dev/null +++ b/apps/api/src/monsters/monster-category.enum.ts @@ -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', +} diff --git a/apps/api/src/npcs/entities/character-npc-state.entity.ts b/apps/api/src/npcs/entities/character-npc-state.entity.ts new file mode 100644 index 0000000..3edca08 --- /dev/null +++ b/apps/api/src/npcs/entities/character-npc-state.entity.ts @@ -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; + + @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; +} diff --git a/apps/api/src/npcs/entities/dialogue-node.entity.ts b/apps/api/src/npcs/entities/dialogue-node.entity.ts new file mode 100644 index 0000000..1da7610 --- /dev/null +++ b/apps/api/src/npcs/entities/dialogue-node.entity.ts @@ -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; +} diff --git a/apps/api/src/npcs/entities/npc-definition.entity.ts b/apps/api/src/npcs/entities/npc-definition.entity.ts new file mode 100644 index 0000000..8fdcecd --- /dev/null +++ b/apps/api/src/npcs/entities/npc-definition.entity.ts @@ -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; +} diff --git a/apps/api/src/npcs/npc.controller.ts b/apps/api/src/npcs/npc.controller.ts new file mode 100644 index 0000000..63fece6 --- /dev/null +++ b/apps/api/src/npcs/npc.controller.ts @@ -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 { + 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 { + return this.npcService.getInteraction(DEMO_CHARACTER_ID, npcKey); + } +} diff --git a/apps/api/src/npcs/npc.errors.ts b/apps/api/src/npcs/npc.errors.ts new file mode 100644 index 0000000..747bf10 --- /dev/null +++ b/apps/api/src/npcs/npc.errors.ts @@ -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.', + ); +} diff --git a/apps/api/src/npcs/npc.service.spec.ts b/apps/api/src/npcs/npc.service.spec.ts new file mode 100644 index 0000000..9bc5a75 --- /dev/null +++ b/apps/api/src/npcs/npc.service.spec.ts @@ -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>; + metCondition?: boolean; + hasShop?: boolean; + shopEnabled?: boolean; + hasExchangeProfile?: boolean; + exchangeRuleCount?: number; + existingState?: Record | null; +} + +function createWorld(fixture: Fixture = {}) { + const savedStates: Array> = []; + + 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) => row, + save: async (row: Record) => { + 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): Partial { + 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), + ], + }); + + 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), + ], + }); + + 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), + ], + }); + + const interaction = await world.service.getInteraction( + CHARACTER_ID, + 'borin-quartermaster', + ); + + expect(interaction.dialogue).toBeNull(); + }); +}); diff --git a/apps/api/src/npcs/npc.service.ts b/apps/api/src/npcs/npc.service.ts new file mode 100644 index 0000000..87f322a --- /dev/null +++ b/apps/api/src/npcs/npc.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }, + }), + ); + } +} diff --git a/apps/api/src/npcs/npc.types.ts b/apps/api/src/npcs/npc.types.ts new file mode 100644 index 0000000..a53ce26 --- /dev/null +++ b/apps/api/src/npcs/npc.types.ts @@ -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 = + 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[]; +} diff --git a/apps/api/src/npcs/npcs.module.ts b/apps/api/src/npcs/npcs.module.ts new file mode 100644 index 0000000..a73bf7e --- /dev/null +++ b/apps/api/src/npcs/npcs.module.ts @@ -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 {} diff --git a/apps/api/src/renown/renown.service.ts b/apps/api/src/renown/renown.service.ts index 0f420a0..2c43248 100644 --- a/apps/api/src/renown/renown.service.ts +++ b/apps/api/src/renown/renown.service.ts @@ -3,11 +3,7 @@ import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity'; import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity'; -import { - RENOWN_BASE_STATS, - RENOWN_MAX, - RENOWN_MIN, -} from './renown-base-stats'; +import { RENOWN_BASE_STATS, RENOWN_MAX, RENOWN_MIN } from './renown-base-stats'; import { characterNotFound, renownMilestoneAlreadyCompleted, diff --git a/apps/api/src/reputation/reputation.service.ts b/apps/api/src/reputation/reputation.service.ts index a1e3503..a194f72 100644 --- a/apps/api/src/reputation/reputation.service.ts +++ b/apps/api/src/reputation/reputation.service.ts @@ -61,7 +61,10 @@ export class ReputationService { throw characterNotFound(); } - const faction = await factions.findOneBy({ key: factionKey, enabled: true }); + const faction = await factions.findOneBy({ + key: factionKey, + enabled: true, + }); if (!faction) { throw reputationFactionNotFound(); } diff --git a/apps/api/src/rewards/combat-reward.service.spec.ts b/apps/api/src/rewards/combat-reward.service.spec.ts index e67e6eb..69851d4 100644 --- a/apps/api/src/rewards/combat-reward.service.spec.ts +++ b/apps/api/src/rewards/combat-reward.service.spec.ts @@ -6,9 +6,15 @@ import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; 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 { + 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 type { RandomSource } from '../shared/random-source'; import { CombatRewardService } from './combat-reward.service'; import { CombatReward } from './entities/combat-reward.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 BANDIT_BLADE = '50000000-0000-4000-8000-000000000002'; 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 { characters: Character[]; @@ -142,15 +150,11 @@ function createState(overrides: Partial = {}): State { { id: ASH_RAT_ID, key: 'ash-rat', - silverMin: 4, - silverMax: 7, lootTableId: ASH_RAT_TABLE, } as MonsterDefinition, { id: ROAD_BANDIT_ID, key: 'road-bandit', - silverMin: 9, - silverMax: 15, lootTableId: ROAD_BANDIT_TABLE, } as MonsterDefinition, ], @@ -161,8 +165,27 @@ function createState(overrides: Partial = {}): State { name: 'Bandit Blade', type: ItemType.EQUIPMENT, rarity: ItemRarity.COMMON, + lootCategory: null, iconPath: '/images/items/bandit-blade.png', } 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: [], combatRewards: [], @@ -187,16 +210,49 @@ function fakeLoot( } 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> = {}, +): 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 => + Promise.resolve( + Object.values(LootCategory).map((category) => ({ + category, + current: 0, + capacity: roomFor(category), + bag: null, + })), + ), + } as unknown as LootCapacityService; } function service( state: State, loot: LootService = fakeLoot(), - random: RandomSource = fixedRandom(0.5), + capacity: LootCapacityService = fakeCapacity({ [LootCategory.HIDE]: 99 }), ): CombatRewardService { - return new CombatRewardService({} as never, loot, random); + return new CombatRewardService({} as never, loot, capacity); } describe('CombatRewardService', () => { @@ -211,7 +267,6 @@ describe('CombatRewardService', () => { ), ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); expect(state.combatRewards).toHaveLength(0); - expect(state.characters[0].silver).toBe(3); }); it('rejects a LOST combat', async () => { @@ -234,53 +289,54 @@ describe('CombatRewardService', () => { combat(), ); - expect(reward).toEqual({ silver: 6, items: [] }); + expect(reward.items).toEqual([]); expect(state.combatRewards).toHaveLength(1); }); }); 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 reward = await service( state, - fakeLoot(), - fixedRandom(0), + fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }), ).grantVictoryRewards(fakeManager(state), combat()); - expect(reward.silver).toBe(4); - expect(state.characters[0].silver).toBe(7); + expect(reward.items).toEqual([ + { + 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 reward = await service( state, - fakeLoot(), - fixedRandom(0.99), + fakeLoot({ itemDefinitionId: ASH_PELT, quantity: 1 }), ).grantVictoryRewards(fakeManager(state), combat()); - expect(reward.silver).toBe(7); + expect(reward).not.toHaveProperty('silver'); + expect(state.characters[0].silver).toBe(3); }); }); describe('Road Bandit', () => { 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 () => { const state = createState(); @@ -302,10 +358,13 @@ describe('CombatRewardService', () => { item: { key: 'bandit-blade', name: 'Bandit Blade', + type: ItemType.EQUIPMENT, rarity: ItemRarity.COMMON, iconPath: '/images/items/bandit-blade.png', + lootCategory: null, }, quantity: 1, + quantityLeftBehind: 0, }, ]); 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 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()); - // Renown comes from milestones only (spec §36). Killing things must never - // move it -- that is the whole point of replacing XP with Renown, so this - // asserts the prohibition rather than trusting that nothing wired it up. + // Renown comes from milestones and Silver from merchant exchange only + // (slice 0.6.5 spec §36, slice 0.7 V2 §7). Killing things must move + // 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].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', () => { it('grants once and returns the same persisted reward on a repeat call', async () => { const state = createState(); 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 first = await subject.grantVictoryRewards(manager, combat()); @@ -377,7 +574,6 @@ describe('CombatRewardService', () => { expect(state.combatRewardItems).toHaveLength(1); expect(state.characterItems).toHaveLength(1); expect(state.characterItems[0].quantity).toBe(1); - expect(state.characters[0].silver).toBe(7); expect(loot.rollLoot).toHaveBeenCalledTimes(1); }); }); @@ -394,7 +590,7 @@ describe('CombatRewardService', () => { it('replays the persisted reward without rerolling', async () => { const state = createState(); 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 granted = await subject.grantVictoryRewards(manager, combat()); @@ -411,7 +607,7 @@ describe('CombatRewardService', () => { const subject = new CombatRewardService( dataSource as never, loot, - fixedRandom(0), + fakeCapacity({ [LootCategory.HIDE]: 99 }), ); const manager = fakeManager(state); @@ -439,7 +635,7 @@ describe('CombatRewardService', () => { // Every rolled item definition is resolved before any mutation, so a // missing one must leave no reward row and no character grant behind. 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_HOOD, quantity: 1 }, ); - const subject = service(state, loot, fixedRandom(0)); + const subject = service(state, loot); const manager = fakeManager(state); const granted = await subject.grantVictoryRewards(manager, banditCombat); diff --git a/apps/api/src/rewards/combat-reward.service.ts b/apps/api/src/rewards/combat-reward.service.ts index 349047e..b3783a4 100644 --- a/apps/api/src/rewards/combat-reward.service.ts +++ b/apps/api/src/rewards/combat-reward.service.ts @@ -1,34 +1,53 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; -import { Character } from '../characters/entities/character.entity'; import { CombatStatus } from '../combat/combat-status.enum'; import { Combat } from '../combat/entities/combat.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; 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 { 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 { 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 { CombatRewardItem } from './entities/combat-reward-item.entity'; import { combatNotWon, rewardStateInvalid } from './rewards.errors'; export interface CombatRewardItemDto { - characterItemId: string; + /** Null when the whole drop was left behind — no stack was created. */ + characterItemId: string | null; item: { key: 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; 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; + /** 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 { - silver: number; items: CombatRewardItemDto[]; + capacities: LootCapacityDto[]; } // Both DataSource and EntityManager expose this; naming it keeps the read path @@ -40,7 +59,7 @@ export class CombatRewardService { constructor( private readonly dataSource: DataSource, 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 * everything below commits or nothing does. * - * Roll order is fixed: silver first, then the loot table in `position` - * order. Tests depend on it. + * The loot table is rolled in `position` order and nothing else is rolled: + * 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( manager: EntityManager, @@ -75,17 +98,12 @@ export class CombatRewardService { throw rewardStateInvalid(); } - const silver = rollInclusive( - this.randomSource, - monster.silverMin, - monster.silverMax, - ); const roll = await this.lootService.rollLoot(monster.lootTableId, manager); // Resolve every rolled item definition up front, before any mutation, so - // a missing definition throws `rewardStateInvalid()` before the - // character's silver is touched or a `CombatReward` row is created. - // This keeps a failed grant from leaving partial writes behind. + // a missing definition throws `rewardStateInvalid()` before a + // `CombatReward` row or any character item is created. This keeps a + // failed grant from leaving partial writes behind. const definitions = manager.getRepository(ItemDefinition); const resolvedDefinitions = new Map(); for (const rolled of roll.items) { @@ -101,22 +119,13 @@ export class CombatRewardService { resolvedDefinitions.set(rolled.itemDefinitionId, definition); } - const characters = manager.getRepository(Character); - const character = await characters.findOne({ - where: { id: combat.characterId }, - lock: { mode: 'pessimistic_write' }, - }); - if (!character) { - throw rewardStateInvalid(); - } - character.silver += silver; - await characters.save(character); - + // No character row is touched here any more: a victory grants items only + // (spec §7). `CombatService.performAction` already holds the character + // lock for the rest of the round. const reward = await rewards.save( rewards.create({ combatId: combat.id, characterId: combat.characterId, - silverGranted: silver, }), ); @@ -127,40 +136,64 @@ export class CombatRewardService { 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) { const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!; - const existingStack = await characterItems.findOne({ - where: { - characterId: combat.characterId, - itemDefinitionId: rolled.itemDefinitionId, - }, - lock: { mode: 'pessimistic_write' }, - }); - // Duplicates stack; Slice 0.4 adds no duplicate protection (spec §28). - const characterItem = existingStack - ? Object.assign(existingStack, { - quantity: existingStack.quantity + rolled.quantity, - }) - : characterItems.create({ + 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({ + where: { characterId: combat.characterId, itemDefinitionId: rolled.itemDefinitionId, - quantity: rolled.quantity, - }); - await characterItems.save(characterItem); + }, + lock: { mode: 'pessimistic_write' }, + }); + // Duplicates stack; Slice 0.4 adds no duplicate protection (0.4 §28). + characterItem = existingStack + ? Object.assign(existingStack, { + quantity: existingStack.quantity + grantedQuantity, + }) + : characterItems.create({ + characterId: combat.characterId, + itemDefinitionId: rolled.itemDefinitionId, + quantity: grantedQuantity, + }); + await characterItems.save(characterItem); + } await rewardItems.save( rewardItems.create({ combatRewardId: reward.id, - characterItemId: characterItem.id, + characterItemId: characterItem?.id ?? null, itemDefinitionId: definition.id, - quantity: rolled.quantity, + quantity: grantedQuantity, + quantityLeftBehind: leftBehind, }), ); granted.push({ 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) => 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). */ @@ -215,32 +253,42 @@ export class CombatRewardService { rewardItem.characterItemId, definition, rewardItem.quantity, + rewardItem.quantityLeftBehind, ), ); } return { - silver: reward.silverGranted, 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( - characterItemId: string, + characterItemId: string | null, definition: ItemDefinition, quantity: number, + quantityLeftBehind: number, ): CombatRewardItemDto { // Drop chance, roll results, and loot-table ids never leave the server - // (spec §26). + // (0.4 §26). return { characterItemId, item: { key: definition.key, name: definition.name, + type: definition.type, rarity: definition.rarity, iconPath: definition.iconPath, + lootCategory: definition.lootCategory, }, quantity, + quantityLeftBehind, }; } } diff --git a/apps/api/src/rewards/entities/combat-reward-item.entity.ts b/apps/api/src/rewards/entities/combat-reward-item.entity.ts index 13804e5..bbd1d4b 100644 --- a/apps/api/src/rewards/entities/combat-reward-item.entity.ts +++ b/apps/api/src/rewards/entities/combat-reward-item.entity.ts @@ -17,6 +17,10 @@ import { CombatReward } from './combat-reward.entity'; * Needed because `CharacterItem.quantity` is a running stack total: after 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). + * + * 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' }) @Index('IDX_combat_reward_items_reward', ['combatRewardId']) @@ -34,15 +38,23 @@ export class CombatRewardItem { @Column({ name: 'combat_reward_id', type: 'uuid' }) combatRewardId!: string; - @Column({ name: 'character_item_id', type: 'uuid' }) - characterItemId!: string; + // Null when the whole drop was left behind: no stack was created, so there + // 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' }) itemDefinitionId!: string; + /** How much actually reached the character. May be 0 on a full bag. */ @Column({ name: 'quantity', type: 'integer' }) 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' }) createdAt!: Date; @@ -50,9 +62,13 @@ export class CombatRewardItem { @JoinColumn({ name: 'combat_reward_id' }) 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' }) - characterItem!: CharacterItem; + characterItem!: CharacterItem | null; @ManyToOne(() => ItemDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'item_definition_id' }) diff --git a/apps/api/src/rewards/entities/combat-reward.entity.ts b/apps/api/src/rewards/entities/combat-reward.entity.ts index ce242d2..c906f8e 100644 --- a/apps/api/src/rewards/entities/combat-reward.entity.ts +++ b/apps/api/src/rewards/entities/combat-reward.entity.ts @@ -13,6 +13,9 @@ import { Combat } from '../../combat/entities/combat.entity'; /** * 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 * invariant; `CombatRewardService` is the service half. */ @@ -29,9 +32,6 @@ export class CombatReward { @Column({ name: 'character_id', type: 'uuid' }) characterId!: string; - @Column({ name: 'silver_granted', type: 'integer' }) - silverGranted!: number; - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; diff --git a/apps/api/src/rewards/rewards.module.ts b/apps/api/src/rewards/rewards.module.ts index 3b2419e..e1e0e3d 100644 --- a/apps/api/src/rewards/rewards.module.ts +++ b/apps/api/src/rewards/rewards.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { CharacterItem } from '../items/entities/character-item.entity'; import { ItemDefinition } from '../items/entities/item-definition.entity'; import { LootModule } from '../loot/loot.module'; +import { LootBagsModule } from '../loot-bags/loot-bags.module'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { RANDOM_SOURCE, systemRandomSource } from '../shared/random-source'; import { CombatRewardService } from './combat-reward.service'; @@ -21,6 +22,7 @@ import { CombatRewardItem } from './entities/combat-reward-item.entity'; CombatRewardItem, ]), LootModule, + LootBagsModule, ], providers: [ CombatRewardService, diff --git a/apps/api/src/shops/dto/shop-purchase.dto.ts b/apps/api/src/shops/dto/shop-purchase.dto.ts new file mode 100644 index 0000000..d925b04 --- /dev/null +++ b/apps/api/src/shops/dto/shop-purchase.dto.ts @@ -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; +} diff --git a/apps/api/src/shops/entities/npc-shop.entity.ts b/apps/api/src/shops/entities/npc-shop.entity.ts new file mode 100644 index 0000000..e448260 --- /dev/null +++ b/apps/api/src/shops/entities/npc-shop.entity.ts @@ -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; +} diff --git a/apps/api/src/shops/entities/shop-offer.entity.ts b/apps/api/src/shops/entities/shop-offer.entity.ts new file mode 100644 index 0000000..d5cedcf --- /dev/null +++ b/apps/api/src/shops/entities/shop-offer.entity.ts @@ -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; +} diff --git a/apps/api/src/shops/shop.controller.ts b/apps/api/src/shops/shop.controller.ts new file mode 100644 index 0000000..5cc6bd4 --- /dev/null +++ b/apps/api/src/shops/shop.controller.ts @@ -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 { + return this.shopService.getShopView(DEMO_CHARACTER_ID, merchantKey); + } + + @Post('purchase') + purchase( + @Param('merchantKey') merchantKey: string, + @Body() request: ShopPurchaseDto, + ): Promise { + return this.shopService.purchase( + DEMO_CHARACTER_ID, + merchantKey, + request.itemKey, + request.quantity, + ); + } +} diff --git a/apps/api/src/shops/shop.errors.ts b/apps/api/src/shops/shop.errors.ts new file mode 100644 index 0000000..7431145 --- /dev/null +++ b/apps/api/src/shops/shop.errors.ts @@ -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'; diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts new file mode 100644 index 0000000..8b8b984 --- /dev/null +++ b/apps/api/src/shops/shop.service.spec.ts @@ -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> = []; + 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) => row, + save: async (row: Record) => { + grantedItems.push(row); + return row; + }, + }; + } + throw new Error('Unexpected repository'); + }; + + const manager = { getRepository: repositories } as unknown as EntityManager; + const dataSource = { + getRepository: repositories, + transaction: async (run: (m: EntityManager) => Promise) => + 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); + }); +}); diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts new file mode 100644 index 0000000..006bebd --- /dev/null +++ b/apps/api/src/shops/shop.service.ts @@ -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 { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/shops/shops.module.ts b/apps/api/src/shops/shops.module.ts new file mode 100644 index 0000000..8647f2a --- /dev/null +++ b/apps/api/src/shops/shops.module.ts @@ -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 {} diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index e2ba237..daa28c4 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -169,11 +169,7 @@ function createState(): FakeState { 'south-gate', 'Graufurt South Gate', ); - const burnedRoad = location( - BURNED_ROAD_ID, - 'burned-road', - 'Burned Road', - ); + const burnedRoad = location(BURNED_ROAD_ID, 'burned-road', 'Burned Road'); const character: Character = { id: CHARACTER_ID, name: 'Aric Duskwalker', diff --git a/apps/api/src/turn-in/dto/turn-in.dto.ts b/apps/api/src/turn-in/dto/turn-in.dto.ts deleted file mode 100644 index 2a54f66..0000000 --- a/apps/api/src/turn-in/dto/turn-in.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsInt, IsString, Min } from 'class-validator'; - -export class TurnInDto { - @IsString() - turnInKey!: string; - - @IsInt() - @Min(1) - quantity!: number; -} diff --git a/apps/api/src/turn-in/entities/turn-in-definition.entity.ts b/apps/api/src/turn-in/entities/turn-in-definition.entity.ts deleted file mode 100644 index fc02277..0000000 --- a/apps/api/src/turn-in/entities/turn-in-definition.entity.ts +++ /dev/null @@ -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; -} diff --git a/apps/api/src/turn-in/turn-in.controller.spec.ts b/apps/api/src/turn-in/turn-in.controller.spec.ts deleted file mode 100644 index 426f4d2..0000000 --- a/apps/api/src/turn-in/turn-in.controller.spec.ts +++ /dev/null @@ -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; - const turnIn = jest.fn(); - - beforeEach(async () => { - turnIn.mockReset(); - const module = await Test.createTestingModule({ - controllers: [TurnInController], - providers: [{ provide: TurnInService, useValue: { turnIn } }], - }).compile(); - - app = module.createNestApplication(); - 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(); - }); -}); diff --git a/apps/api/src/turn-in/turn-in.controller.ts b/apps/api/src/turn-in/turn-in.controller.ts deleted file mode 100644 index 1e3aabe..0000000 --- a/apps/api/src/turn-in/turn-in.controller.ts +++ /dev/null @@ -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 { - return this.turnInService.turnIn( - DEMO_CHARACTER_ID, - request.turnInKey, - request.quantity, - ); - } -} diff --git a/apps/api/src/turn-in/turn-in.errors.ts b/apps/api/src/turn-in/turn-in.errors.ts deleted file mode 100644 index 830709a..0000000 --- a/apps/api/src/turn-in/turn-in.errors.ts +++ /dev/null @@ -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'; diff --git a/apps/api/src/turn-in/turn-in.module.ts b/apps/api/src/turn-in/turn-in.module.ts deleted file mode 100644 index 23f9c4e..0000000 --- a/apps/api/src/turn-in/turn-in.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/api/src/turn-in/turn-in.service.spec.ts b/apps/api/src/turn-in/turn-in.service.spec.ts deleted file mode 100644 index dbdc3be..0000000 --- a/apps/api/src/turn-in/turn-in.service.spec.ts +++ /dev/null @@ -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 { - constructor( - private readonly rows: T[], - private readonly prefix: string, - private readonly inTransaction: boolean, - ) {} - - findOne(options: { - where: Partial; - lock?: { mode: string }; - }): Promise { - 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): Promise { - return Promise.resolve( - this.rows.find((row) => this.matches(row, where)) ?? null, - ); - } - - find(options: { where?: Partial } = {}): Promise { - return Promise.resolve( - options.where - ? this.rows.filter((row) => this.matches(row, options.where!)) - : [...this.rows], - ); - } - - create(values: Partial): T { - return { ...values } as T; - } - - save(entity: T): Promise { - 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 { - 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): boolean { - return Object.entries(where).every( - ([key, value]) => row[key as keyof T] === value, - ); - } -} - -class FakeDataSource { - constructor(public state: State) {} - - getRepository(target: EntityTarget) { - return this.repoFor(target, false); - } - - async transaction( - work: (manager: EntityManager) => Promise, - ): Promise { - return work({ - getRepository: (target: EntityTarget) => - this.repoFor(target, true), - } as unknown as EntityManager); - } - - private repoFor( - target: EntityTarget, - 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 { - return { id: CHARACTER_ID, silver: 10, ...overrides } as Character; -} - -function characterItem(overrides: Partial = {}): CharacterItem { - return { - id: CHARACTER_ITEM_ID, - characterId: CHARACTER_ID, - itemDefinitionId: ITEM_DEFINITION_ID, - quantity: 5, - ...overrides, - } as CharacterItem; -} - -function turnInDefinition( - overrides: Partial = {}, -): 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 { - return { - id: FACTION_ID, - key: 'border-guard', - name: 'Border Watch', - enabled: true, - ...overrides, - } as ReputationFaction; -} - -function createState(overrides: Partial = {}): 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, - code: string, -): Promise { - 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, - code: string, - status: number, -): Promise { - 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); - }); - }); -}); diff --git a/apps/api/src/turn-in/turn-in.service.ts b/apps/api/src/turn-in/turn-in.service.ts deleted file mode 100644 index d788102..0000000 --- a/apps/api/src/turn-in/turn-in.service.ts +++ /dev/null @@ -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 { - 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, - }; - }); - } -} diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts index 9a7a72f..54dcf2e 100644 --- a/apps/api/src/world/local-location.types.ts +++ b/apps/api/src/world/local-location.types.ts @@ -46,6 +46,13 @@ export interface LocationPointOfInterestContent { enabled: boolean; resultTitle?: 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; enabled: boolean; poiKey?: string; + npcKey?: string; } export interface LocationRewardPreviewContent { @@ -78,6 +86,7 @@ export interface LocalLocationPointOfInterestDto { xPercent: number; yPercent: number; enabled: boolean; + npcKey?: string; } export interface LocalLocationPrimaryActionDto { @@ -88,6 +97,7 @@ export interface LocalLocationPrimaryActionDto { iconKey: string; enabled: boolean; poiKey?: string; + npcKey?: string; } export interface EncounterPreviewDto { @@ -122,5 +132,6 @@ export function toPointOfInterestDto( xPercent: poi.xPercent, yPercent: poi.yPercent, enabled: poi.enabled, + ...(poi.npcKey === undefined ? {} : { npcKey: poi.npcKey }), }; } diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index 6a50e9d..bd3c32e 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -130,8 +130,7 @@ function currentLocation(): LocationDefinition { id: SOUTH_GATE_ID, key: 'south-gate', name: 'Graufurt South Gate', - description: - 'The South Gate is the safe starting point heading south.', + description: 'The South Gate is the safe starting point heading south.', regionKey: 'ashen-fields', minRecommendedLevel: 1, maxRecommendedLevel: 1, @@ -245,8 +244,7 @@ describe('WorldService', () => { id: SOUTH_GATE_ID, key: 'south-gate', name: 'Graufurt South Gate', - description: - 'The South Gate is the safe starting point heading south.', + description: 'The South Gate is the safe starting point heading south.', regionKey: 'ashen-fields', minRecommendedLevel: 1, maxRecommendedLevel: 1, @@ -324,10 +322,7 @@ describe('WorldService', () => { const result = await service.getCurrentLocation(CHARACTER_ID); - expect(result.possibleMonsters).toEqual([ - 'Ash Rat', - 'Road Bandit', - ]); + expect(result.possibleMonsters).toEqual(['Ash Rat', 'Road Bandit']); expect(findLocationMonsters).toHaveBeenCalledWith({ where: { locationId: BURNED_ROAD_ID, enabled: true }, relations: { monster: true }, diff --git a/apps/api/test/visible-slice.e2e-spec.ts b/apps/api/test/visible-slice.e2e-spec.ts index ce5f8e8..696aae9 100644 --- a/apps/api/test/visible-slice.e2e-spec.ts +++ b/apps/api/test/visible-slice.e2e-spec.ts @@ -265,9 +265,12 @@ describe('Visible vertical slice smoke (e2e)', () => { } expect(atBurnedRoad.key).toBe('burned-road'); + // Ordered by encounter weight, heaviest first (spec §3). expect(atBurnedRoad.possibleMonsters).toEqual([ 'Ash Rat', + 'Feral Road Hound', 'Road Bandit', + 'Charred Raider', ]); const firstHunt = await request(app.getHttpServer()) @@ -277,13 +280,22 @@ describe('Visible vertical slice smoke (e2e)', () => { expect(typeof firstHunt.body.id).toBe('string'); expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' }); expect(firstHunt.body.encounters).toHaveLength(3); - for (const encounter of firstHunt.body.encounters as Array<{ + const encounters = firstHunt.body.encounters as Array<{ id: string; - monster: { key: string }; + monster: { key: string; flavorText: string | null }; dangerRating: string; - }>) { + encounterType: string; + }>; + for (const encounter of encounters) { 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([ 'WEAK', 'MATCH', @@ -293,6 +305,37 @@ describe('Visible vertical slice smoke (e2e)', () => { ]).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()) .post('/api/hunts') .expect(201); @@ -321,6 +364,146 @@ describe('Visible vertical slice smoke (e2e)', () => { } }, 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( application: INestApplication, travelDurationSeconds: number, diff --git a/apps/web/public/images/items/bandit-insignia.png b/apps/web/public/images/items/bandit-insignia.png new file mode 100644 index 0000000..fb916d6 Binary files /dev/null and b/apps/web/public/images/items/bandit-insignia.png differ diff --git a/apps/web/public/images/items/charred-raider-insignia.png b/apps/web/public/images/items/charred-raider-insignia.png new file mode 100644 index 0000000..28d9fac Binary files /dev/null and b/apps/web/public/images/items/charred-raider-insignia.png differ diff --git a/apps/web/public/images/items/tough-hide.png b/apps/web/public/images/items/tough-hide.png new file mode 100644 index 0000000..c6e0e58 Binary files /dev/null and b/apps/web/public/images/items/tough-hide.png differ diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 7eb45b1..ff29e04 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -35,6 +35,13 @@ export const routes: Routes = [ (module) => module.CombatPageComponent, ), }, + { + path: 'npc/:npcKey', + loadComponent: () => + import('./features/npc/merchant-page.component').then( + (module) => module.MerchantPageComponent, + ), + }, { path: 'inventory', loadComponent: () => diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index a62b4c5..02066da 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -54,6 +54,8 @@ export interface LocationPointOfInterest { xPercent: number; yPercent: number; enabled: boolean; + /** Set when this hotspot leads to a real NPC rather than authored text. */ + npcKey?: string; } export interface LocationPrimaryAction { @@ -65,6 +67,8 @@ export interface LocationPrimaryAction { enabled: boolean; /** Set when the action reveals the same result as a hotspot on the artwork. */ poiKey?: string; + /** Set when the action leads to a real NPC rather than authored text. */ + npcKey?: string; } export interface EncounterPreview { @@ -132,15 +136,20 @@ export interface MonsterSummary { name: string; level: number; artworkPath: string; + /** Short atmosphere line for the encounter card; null when unauthored. */ + flavorText: string | null; } export type HuntEncounterStatus = 'AVAILABLE' | 'IN_PROGRESS' | 'DEFEATED'; +export type EncounterType = 'NORMAL' | 'RARE' | 'ELITE' | 'BOSS'; + export interface HuntEncounter { id: string; monster: MonsterSummary; dangerRating: DangerRating; status: HuntEncounterStatus; + encounterType: EncounterType; } export interface HuntResult { @@ -150,7 +159,18 @@ export interface HuntResult { } export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST'; -export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST'; +export type CombatEventType = + | 'DAMAGE' + | 'HEAL' + | 'DEFEND' + | 'TELEGRAPH' + | 'INTERRUPT' + | 'STATUS_APPLIED' + | 'STATUS_DAMAGE' + | 'STATUS_EXPIRED' + | 'COMBAT_WON' + | 'COMBAT_LOST'; +export type StatusEffectType = 'BLEED'; export type CombatSide = 'PLAYER' | 'MONSTER'; export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'; export type CombatMonsterIntent = 'HEAVY_ATTACK'; @@ -162,6 +182,13 @@ export interface CombatEvent { source: CombatSide; target: CombatSide; amount?: number; + statusEffect?: StatusEffectType; +} + +export interface CombatStatusEffect { + type: StatusEffectType; + remainingRounds: number; + damagePerRound: number; } export interface CombatPlayer { @@ -170,6 +197,7 @@ export interface CombatPlayer { currentHp: number; potionsRemaining: number; potionsMax: number; + statusEffects: CombatStatusEffect[]; } export interface CombatMonster { @@ -194,22 +222,54 @@ export interface Combat { export type ItemRarity = 'COMMON' | 'RARE' | 'EPIC'; -export interface RewardItemSummary { +export type ItemType = + | 'EQUIPMENT' + | 'TRADE_GOOD' + | 'TROPHY' + | 'QUEST_ITEM' + | 'CONSUMABLE'; + +/** The carrying bucket a trade good counts against (slice 0.7.5 §3). */ +export type LootCategory = 'HIDE' | 'RAIDER_TROPHY'; + +export interface LootCapacityBag { key: string; name: string; - rarity: ItemRarity; iconPath: string; } -export interface CombatRewardItem { - characterItemId: string; - item: RewardItemSummary; - quantity: number; +export interface LootCapacity { + category: LootCategory; + current: number; + capacity: number; + /** The active bag behind `capacity`, or null at the bagless default. */ + bag: LootCapacityBag | null; } +export interface RewardItemSummary { + key: string; + name: string; + type: ItemType; + rarity: ItemRarity; + iconPath: string; + lootCategory: LootCategory | null; +} + +export interface CombatRewardItem { + /** Null when the whole drop was left behind — nothing was stored. */ + characterItemId: string | null; + item: RewardItemSummary; + /** How much reached the character. 0 when the bag was already full. */ + quantity: number; + /** How much the bag refused (slice 0.7.5 §9). */ + quantityLeftBehind: number; +} + +/** Items only: a normal victory grants no Silver or XP (slice 0.7 V2 §7). */ export interface CombatReward { - silver: number; items: CombatRewardItem[]; + /** Carrying state after this reward, so the screen needs no second call. */ + capacities: LootCapacity[]; } export type EquipmentSlot = @@ -276,16 +336,125 @@ export interface ReputationEntry { nextThreshold: number | null; } -export interface TurnInResult { - turnInKey: string; - quantityConsumed: number; - silverGranted: number; - reputationResult: { - factionKey: string; - previousReputation: number; - newReputation: number; - previousRank: string; - newRank: string; - rankChanged: boolean; - }; +/** + * NPC, shop and trade-in transport types (Playable Slice 0.8). + * + * Replaces `TurnInResult` from Slice 0.6.5: trade-in now happens with a + * named merchant and pays reputation and renown alongside Silver. + */ +export type NpcMarker = + | 'MERCHANT' + | 'EXCHANGE' + | 'QUEST_AVAILABLE' + | 'QUEST_TURN_IN'; + +export interface NpcSummary { + id: string; + key: string; + name: string; + title: string | null; + portraitPath: string; + markers: NpcMarker[]; +} + +export type NpcActionType = + | 'TALK' + | 'OPEN_SHOP' + | 'OPEN_EXCHANGE' + | 'VIEW_QUESTS'; + +export interface NpcAction { + type: NpcActionType; + label: string; + key: string | null; +} + +export interface DialogueNodeView { + key: string; + text: string; + responses: Array<{ key: string; text: string; targetNodeKey: string | null }>; +} + +export interface NpcInteraction { + npc: { + id: string; + key: string; + name: string; + title: string | null; + description: string | null; + portraitPath: string; + artworkPath: string | null; + capabilities: string[]; + }; + dialogue: DialogueNodeView | null; + availableActions: NpcAction[]; +} + +export interface ExchangeOffer { + itemKey: string; + itemName: string; + iconPath: string; + quantityCarried: number; + inputQuantity: number; + silverPerStep: number; + reputationPerStep: number; + factionKey: string; + factionName: string; + renownMilestoneKey: string | null; +} + +export interface ExchangeView { + profileKey: string; + profileName: string; + npcKey: string; + offers: ExchangeOffer[]; + capacities: LootCapacity[]; +} + +export interface ExchangeResult { + profileKey: string; + consumed: Array<{ itemKey: string; itemName: string; quantity: number }>; + rewards: { + silver: number; + regionalReputation: number; + worldRenown: number; + }; + balances: { + silver: number; + regionalReputation: number; + worldRenown: number; + }; + reputationRankChanged: boolean; + newReputationRank: string | null; + renownMilestonesCompleted: string[]; + capacities: LootCapacity[]; +} + +export interface ShopOfferView { + itemKey: string; + itemName: string; + itemDescription: string; + iconPath: string; + currencyType: string; + price: number; + quantity: number; + unlocked: boolean; + affordable: boolean; +} + +export interface ShopView { + shopKey: string; + shopName: string; + npcKey: string; + silver: number; + offers: ShopOfferView[]; +} + +export interface ShopPurchaseResult { + shopKey: string; + itemKey: string; + itemName: string; + quantity: number; + silverSpent: number; + silverBalance: number; } diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index fecf570..ea34b82 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -108,24 +108,52 @@ describe('GameApiService', () => { req.flush([]); }); - it('submits a turn-in with only turnInKey and quantity', () => { - service.turnIn('ash-pelt-border-guard', 3).subscribe(); + it('submits a trade-in with only item keys and quantities', () => { + // Prices and rewards are never sent: the server reads them from the + // exchange rules (slice 0.8 §8). + service + .tradeIn('borin-quartermaster', [{ itemKey: 'ash-pelt', quantity: 3 }]) + .subscribe(); - const req = http.expectOne('/api/turn-ins'); + const req = http.expectOne('/api/merchants/borin-quartermaster/trade-in'); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ turnInKey: 'ash-pelt-border-guard', quantity: 3 }); - req.flush({ - turnInKey: 'ash-pelt-border-guard', - quantityConsumed: 3, - silverGranted: 12, - reputationResult: { - factionKey: 'border-guard', - previousReputation: 0, - newReputation: 3, - previousRank: 'STRANGER', - newRank: 'STRANGER', - rankChanged: false, - }, + expect(req.request.body).toEqual({ + items: [{ itemKey: 'ash-pelt', quantity: 3 }], }); + req.flush({}); + }); + + it('fetches the trade-in view for a merchant', () => { + service.getTradeIn('borin-quartermaster').subscribe(); + + const req = http.expectOne('/api/merchants/borin-quartermaster/trade-in'); + expect(req.request.method).toBe('GET'); + req.flush({}); + }); + + it('fetches an NPC interaction by key', () => { + service.getNpcInteraction('borin-quartermaster').subscribe(); + + const req = http.expectOne( + '/api/npcs/borin-quartermaster/interaction', + ); + expect(req.request.method).toBe('GET'); + req.flush({}); + }); + + it('buys from a shop without sending a price', () => { + service + .purchase('borin-quartermaster', 'small-healing-potion', 1) + .subscribe(); + + const req = http.expectOne( + '/api/merchants/borin-quartermaster/shop/purchase', + ); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + itemKey: 'small-healing-potion', + quantity: 1, + }); + req.flush({}); }); }); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 6729403..90e0b79 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -11,8 +11,14 @@ import { HuntResult, InventoryResponse, LocationInteractionResult, + LootCapacity, + ExchangeResult, + ExchangeView, + NpcInteraction, + NpcSummary, ReputationEntry, - TurnInResult, + ShopPurchaseResult, + ShopView, } from './game-api.models'; @Injectable({ providedIn: 'root' }) @@ -74,6 +80,10 @@ export class GameApiService { return this.http.get('/api/inventory'); } + getLootCapacities(): Observable { + return this.http.get('/api/loot-bags/capacities'); + } + getEquipment(): Observable { return this.http.get('/api/equipment'); } @@ -86,7 +96,52 @@ export class GameApiService { return this.http.get('/api/reputation'); } - turnIn(turnInKey: string, quantity: number): Observable { - return this.http.post('/api/turn-ins', { turnInKey, quantity }); + getLocationNpcs(locationId: string): Observable { + return this.http.get( + `/api/locations/${encodeURIComponent(locationId)}/npcs`, + ); + } + + getNpcInteraction(npcKey: string): Observable { + return this.http.get( + `/api/npcs/${encodeURIComponent(npcKey)}/interaction`, + ); + } + + getTradeIn(merchantKey: string): Observable { + return this.http.get( + `/api/merchants/${encodeURIComponent(merchantKey)}/trade-in`, + ); + } + + /** + * Hands goods over. Only keys and quantities travel -- prices and rewards + * are the server's to decide (slice 0.8 §8). + */ + tradeIn( + merchantKey: string, + items: Array<{ itemKey: string; quantity: number }>, + ): Observable { + return this.http.post( + `/api/merchants/${encodeURIComponent(merchantKey)}/trade-in`, + { items }, + ); + } + + getShop(merchantKey: string): Observable { + return this.http.get( + `/api/merchants/${encodeURIComponent(merchantKey)}/shop`, + ); + } + + purchase( + merchantKey: string, + itemKey: string, + quantity: number, + ): Observable { + return this.http.post( + `/api/merchants/${encodeURIComponent(merchantKey)}/shop/purchase`, + { itemKey, quantity }, + ); } } diff --git a/apps/web/src/app/core/resume-combat.spec.ts b/apps/web/src/app/core/resume-combat.spec.ts index d051f5c..7fbc9bc 100644 --- a/apps/web/src/app/core/resume-combat.spec.ts +++ b/apps/web/src/app/core/resume-combat.spec.ts @@ -9,7 +9,14 @@ const runningCombat: Combat = { id: 'combat-running', status: 'ACTIVE', round: 4, - player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 }, + player: { + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 62, + potionsRemaining: 2, + potionsMax: 2, + statusEffects: [], + }, monster: { key: 'road-bandit', name: 'Road Bandit', diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 595ff1c..f7a8c61 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -17,6 +17,23 @@ {{ combat.player.currentHp }} / {{ combat.player.maxHp }} + + @if (combat.player.statusEffects.length) { +
    + @for (effect of combat.player.statusEffects; track effect.type) { +
  • + + + {{ statusEffectLabel(effect.type) }} · {{ effect.remainingRounds }} + +
  • + } +
+ } @@ -137,28 +154,43 @@ @if (combat.rewards; as rewards) {
-

Rewards

- - @if (rewards.silver) { -
-
-
Silver
-
+{{ rewards.silver }}
-
-
+ @if (lootGroups(); as groups) { + @if (groups.length) { + @for (group of groups; track group.key) { +

+ {{ group.title }} +

+
    + @for (reward of group.items; track reward.characterItemId) { +
  • + +
  • + } +
+ } + } @else { +

No notable loot found.

+ } } - @if (rewards.items.length) { -

Loot

-
    - @for (reward of rewards.items; track reward.characterItemId) { -
  • - -
  • - } -
- } @else { -

No notable loot found.

+ @if (leftBehind(); as refused) { + @if (refused.length) { +

Left Behind

+
    + @for (entry of refused; track entry.key) { +
  • + {{ entry.name }} ×{{ entry.quantity }} — no room left +
  • + } +
+ } + } + + @if (rewards.capacities.length) { + }
} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 5ec6122..16e0ac2 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -100,6 +100,45 @@ max-inline-size: 22rem; } +/* ---------- ongoing effects ---------- */ + +/* Sits directly under the health bar it is eating away at, so the cause of + the drain is next to the number that drops (spec §4). */ +.statuses { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.1rem 0.45rem; + border: 1px solid var(--ar-border); + border-radius: 0.15rem; + background: rgb(0 0 0 / 0.35); + font-size: var(--ar-font-sm); + letter-spacing: 0.06em; +} + +/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and + the shape carries the meaning on its own. */ +.status__glyph { + inline-size: 0.55rem; + block-size: 0.7rem; + background: currentcolor; + clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%); +} + +.status--bleed { + border-color: rgb(158 46 42 / 0.75); + color: #d8736c; +} + .fighter--monster .fighter__meter { justify-items: end; text-align: end; @@ -670,7 +709,46 @@ justify-content: flex-start; } - .fighter--monster .fighter__meter { + /* ---------- ongoing effects ---------- */ + +/* Sits directly under the health bar it is eating away at, so the cause of + the drain is next to the number that drops (spec §4). */ +.statuses { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.1rem 0.45rem; + border: 1px solid var(--ar-border); + border-radius: 0.15rem; + background: rgb(0 0 0 / 0.35); + font-size: var(--ar-font-sm); + letter-spacing: 0.06em; +} + +/* Drawn rather than loaded: a 12px bitmap of a blood drop would be mush, and + the shape carries the meaning on its own. */ +.status__glyph { + inline-size: 0.55rem; + block-size: 0.7rem; + background: currentcolor; + clip-path: polygon(50% 0%, 100% 62%, 82% 95%, 18% 95%, 0% 62%); +} + +.status--bleed { + border-color: rgb(158 46 42 / 0.75); + color: #d8736c; +} + +.fighter--monster .fighter__meter { justify-items: start; text-align: start; } @@ -707,30 +785,25 @@ text-transform: uppercase; } -.rewards__currencies { - display: flex; - gap: var(--ar-space-6); - margin: 0; +/* Set apart from the loot headings: this is what the player did NOT get, + and it must not read as another reward row (slice 0.7.5 §9). */ +.rewards__title--refused { + color: var(--ar-danger); } -.rewards__currency { +.rewards__refused { display: grid; gap: var(--ar-space-1); - justify-items: center; -} - -.rewards__currency dt { + margin: 0; + padding: 0; color: var(--ar-text-muted); font-size: var(--ar-font-sm); - letter-spacing: 0.08em; - text-transform: uppercase; + list-style: none; + text-align: center; } -.rewards__currency dd { - margin: 0; - color: var(--ar-gold); - font-family: Georgia, 'Times New Roman', serif; - font-size: clamp(1.25rem, 2.5vw, 1.6rem); +.rewards__capacities { + margin-block-start: var(--ar-space-2); } .rewards__loot { diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 3ba8e77..5fe29fb 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -2,16 +2,33 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; -import type { Combat } from '../../../core/api/game-api.models'; +import type { + Combat, + CombatRewardItem, + LootCapacity, +} from '../../../core/api/game-api.models'; import { CombatStore } from '../combat.store'; import { WorldStore } from '../../world/world.store'; import { CombatPageComponent } from './combat-page.component'; +// Roomy enough that no test trips the "full" styling unless it asks to. +const FULL_CAPACITIES: LootCapacity[] = [ + { category: 'HIDE', current: 1, capacity: 5, bag: null }, + { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, +]; + const activeCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 2, - player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 }, + player: { + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 95, + potionsRemaining: 2, + potionsMax: 2, + statusEffects: [], + }, monster: { key: 'ash-rat', name: 'Ash Rat', @@ -426,7 +443,7 @@ describe('CombatPageComponent', () => { ...activeCombat, status: 'WON', monster: { ...activeCombat.monster, currentHp: 0 }, - rewards: { silver: 6, items: [] }, + rewards: { items: [], capacities: FULL_CAPACITIES }, events: [ ...activeCombat.events, { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 31 }, @@ -522,57 +539,292 @@ describe('CombatPageComponent', () => { expect(combatStore.loadCombat).toHaveBeenCalledTimes(2); }); + describe('Bleeding (spec §4)', () => { + it('shows an active effect with the rounds it still has to run', async () => { + const fixture = await setup({ + ...activeCombat, + player: { + ...activeCombat.player, + statusEffects: [{ type: 'BLEED', remainingRounds: 2, damagePerRound: 5 }], + }, + }); + const element = fixture.nativeElement as HTMLElement; + + const badge = element.querySelector('[data-combat-status="BLEED"]'); + expect(badge).toBeTruthy(); + expect(badge?.textContent).toContain('Bleeding'); + expect(badge?.textContent).toContain('2'); + }); + + it('shows no effect strip while the player is unafflicted', async () => { + const fixture = await setup(activeCombat); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-statuses]')).toBeNull(); + }); + + it('reads the status events in the combat log', async () => { + const fixture = await setup({ + ...activeCombat, + player: { + ...activeCombat.player, + statusEffects: [{ type: 'BLEED', remainingRounds: 1, damagePerRound: 5 }], + }, + events: [ + { + round: 1, + sequence: 1, + type: 'STATUS_APPLIED', + source: 'MONSTER', + target: 'PLAYER', + amount: 2, + statusEffect: 'BLEED', + }, + { + round: 1, + sequence: 2, + type: 'STATUS_DAMAGE', + source: 'MONSTER', + target: 'PLAYER', + amount: 5, + statusEffect: 'BLEED', + }, + { + round: 1, + sequence: 3, + type: 'STATUS_EXPIRED', + source: 'MONSTER', + target: 'PLAYER', + statusEffect: 'BLEED', + }, + ], + }); + const element = fixture.nativeElement as HTMLElement; + const log = element.querySelector('.combat__log-body')?.textContent ?? ''; + + expect(log).toContain('inflicts Bleeding'); + expect(log).toContain('Bleeding costs Aric Duskwalker 5 HP'); + expect(log).toContain('Bleeding fades'); + }); + }); + const wonWithRewards: Combat = { ...activeCombat, status: 'WON', monster: { ...activeCombat.monster, currentHp: 0 }, - rewards: { silver: 6, items: [] }, + rewards: { items: [], capacities: FULL_CAPACITIES }, }; - it('shows the granted silver on the victory screen', async () => { - const fixture = await setup(wonWithRewards); + const ashenPelt: CombatRewardItem = { + characterItemId: 'character-item-pelt', + item: { + key: 'ash-pelt', + name: 'Ashen Pelt', + type: 'TRADE_GOOD', + lootCategory: 'HIDE', + rarity: 'COMMON', + iconPath: '/images/items/ash-pelt.png', + }, + quantity: 1, + quantityLeftBehind: 0, + }; + + const banditBlade: CombatRewardItem = { + characterItemId: 'character-item-blade', + item: { + key: 'bandit-blade', + name: 'Bandit Blade', + type: 'EQUIPMENT', + lootCategory: null, + rarity: 'COMMON', + iconPath: '/images/items/bandit-blade.png', + }, + quantity: 1, + quantityLeftBehind: 0, + }; + + const healingPotion: CombatRewardItem = { + characterItemId: 'character-item-potion', + item: { + key: 'small-healing-potion', + name: 'Small Healing Potion', + type: 'CONSUMABLE', + lootCategory: null, + rarity: 'COMMON', + iconPath: '/images/items/small-healing-potion.png', + }, + quantity: 1, + quantityLeftBehind: 0, + }; + + it('shows the reward panel without any currency row', async () => { + const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } }); const element = fixture.nativeElement as HTMLElement; expect(element.querySelector('[data-combat-rewards]')).toBeTruthy(); - expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('6'); - // R16: the XP block is removed entirely, not hidden -- guard against it - // reappearing in the markup. + // Spec §9: no XP or Silver rows. Both blocks are removed from the markup + // rather than hidden, so guard against either reappearing. + expect(element.querySelector('[data-reward-silver]')).toBeNull(); expect(element.querySelector('[data-reward-experience]')).toBeNull(); }); - it('hides the silver tile entirely when the kill granted none', async () => { - // Every monster seeded in this slice rolls silverMin/silverMax = 0 (design - // R7), so without this guard the victory screen advertises "Silver +0" - // after every single fight in the shipped content. + it('separates trade goods, equipment, and consumables in the loot summary', async () => { const fixture = await setup({ ...wonWithRewards, - rewards: { silver: 0, items: [] }, + rewards: { items: [banditBlade, ashenPelt, healingPotion], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; - expect(element.querySelector('[data-reward-silver]')).toBeNull(); - expect(element.querySelector('[data-combat-rewards]')).toBeTruthy(); + const groups = [...element.querySelectorAll('[data-reward-group]')]; + expect(groups.map((group) => group.getAttribute('data-reward-group'))).toEqual([ + 'trade-goods', + 'equipment', + 'consumables', + ]); + expect(groups.map((group) => group.textContent?.trim())).toEqual([ + 'Trade Goods', + 'Equipment', + 'Consumables', + ]); + }); + + it('omits a category the fight did not drop anything for', async () => { + const fixture = await setup({ ...wonWithRewards, rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES } }); + const element = fixture.nativeElement as HTMLElement; + + const groups = [...element.querySelectorAll('[data-reward-group]')]; + expect(groups).toHaveLength(1); + expect(groups[0].getAttribute('data-reward-group')).toBe('trade-goods'); + }); + + it('files a trophy with the trade goods, because both are merchant fodder', async () => { + const insignia: CombatRewardItem = { + characterItemId: 'character-item-insignia', + item: { + key: 'bandit-insignia', + name: 'Raider Insignia', + type: 'TROPHY', + lootCategory: 'RAIDER_TROPHY', + rarity: 'COMMON', + iconPath: '/images/items/bandit-insignia.png', + }, + quantity: 1, + quantityLeftBehind: 0, + }; + const fixture = await setup({ ...wonWithRewards, rewards: { items: [insignia], capacities: FULL_CAPACITIES } }); + const element = fixture.nativeElement as HTMLElement; + + expect( + element.querySelector('[data-reward-group="trade-goods"]'), + ).toBeTruthy(); + expect(element.querySelector('[data-item-name]')?.textContent).toContain( + 'Raider Insignia', + ); + }); + + describe('full loot bags (slice 0.7.5 §9, §10)', () => { + const refusedPelt: CombatRewardItem = { + ...ashenPelt, + characterItemId: null, + quantity: 0, + quantityLeftBehind: 1, + }; + + const fullHides: LootCapacity[] = [ + { category: 'HIDE', current: 5, capacity: 5, bag: null }, + { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, + ]; + + it('names what the bag refused instead of dropping it silently', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { items: [refusedPelt], capacities: fullHides }, + }); + const element = fixture.nativeElement as HTMLElement; + + const refused = element.querySelector('[data-reward-left-behind]'); + expect(refused).toBeTruthy(); + expect(refused?.textContent).toContain('Ashen Pelt'); + expect(refused?.textContent).toContain('1'); + }); + + it('keeps a fully refused drop out of the loot the player actually got', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { items: [refusedPelt], capacities: fullHides }, + }); + const element = fixture.nativeElement as HTMLElement; + + // Nothing was banked, so no Trade Goods heading and no item card. + expect(element.querySelector('[data-reward-group="trade-goods"]')).toBeNull(); + expect(element.querySelector('[data-item-name]')).toBeNull(); + }); + + it('lists a partial grant under both loot and left behind', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { + items: [{ ...ashenPelt, quantity: 1, quantityLeftBehind: 1 }], + capacities: fullHides, + }, + }); + const element = fixture.nativeElement as HTMLElement; + + // §10: granted 1, left behind 1 -- the player has to see both halves. + expect(element.querySelector('[data-item-name]')?.textContent).toContain( + 'Ashen Pelt', + ); + expect( + element.querySelector('[data-reward-refused="ash-pelt"]')?.textContent, + ).toContain('1'); + }); + + it('still shows equipment that landed while the hide bag was full', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { items: [refusedPelt, banditBlade], capacities: fullHides }, + }); + const element = fixture.nativeElement as HTMLElement; + + // §9: equipment must not be lost to a full trade-good bag. + expect(element.querySelector('[data-reward-group="equipment"]')).toBeTruthy(); + expect(element.querySelector('[data-item-name]')?.textContent).toContain( + 'Bandit Blade', + ); + expect(element.querySelector('[data-reward-left-behind]')).toBeTruthy(); + }); + + it('says nothing about left-behind loot when everything fit', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-reward-left-behind]')).toBeNull(); + }); + + it('shows the carrying state after the fight', async () => { + const fixture = await setup({ + ...wonWithRewards, + rewards: { items: [refusedPelt], capacities: fullHides }, + }); + const element = fixture.nativeElement as HTMLElement; + + // §11/§12: the player learns the trip is over on the victory screen, + // without a second request. + expect(element.querySelector('[data-loot-capacities]')).toBeTruthy(); + expect( + element.querySelector('[data-loot-capacity="HIDE"] [data-loot-capacity-full]'), + ).toBeTruthy(); + }); }); it('renders a dropped item with its icon, name, and rarity', async () => { const fixture = await setup({ ...wonWithRewards, monster: { ...activeCombat.monster, key: 'road-bandit', name: 'Road Bandit', currentHp: 0 }, - rewards: { - silver: 12, - items: [ - { - characterItemId: 'character-item-1', - item: { - key: 'bandit-blade', - name: 'Bandit Blade', - rarity: 'COMMON', - iconPath: '/images/items/bandit-blade.png', - }, - quantity: 1, - }, - ], - }, + rewards: { items: [banditBlade], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; @@ -599,12 +851,12 @@ describe('CombatPageComponent', () => { // exactly what the server persisted, without rerolling anything. const fixture = await setup({ ...wonWithRewards, - rewards: { silver: 12, items: [] }, + rewards: { items: [ashenPelt], capacities: FULL_CAPACITIES }, }); const element = fixture.nativeElement as HTMLElement; expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1'); - expect(element.querySelector('[data-reward-silver]')?.textContent).toContain('12'); + expect(element.querySelector('[data-item-name]')?.textContent).toContain('Ashen Pelt'); }); it('still shows a plain victory when the server reports no reward record', async () => { diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 0f8b118..d554fc2 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -1,6 +1,14 @@ import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models'; +import type { + Combat, + CombatAction, + CombatEvent, + CombatEventType, + CombatRewardItem, + ItemType, + StatusEffectType, +} from '../../../core/api/game-api.models'; import { combatMonsterSpriteScale, monsterCutoutPath, @@ -8,6 +16,7 @@ import { runtimeMonsterArtworkPath, } from '../../../shared/monster-artwork'; import { ItemCardComponent } from '../../../shared/item-card/item-card.component'; +import { LootCapacityStripComponent } from '../../../shared/loot-capacity-strip/loot-capacity-strip.component'; import { WorldStore } from '../../world/world.store'; import { CombatStore } from '../combat.store'; @@ -16,6 +25,41 @@ interface CombatLogRound { events: CombatEvent[]; } +interface LootGroup { + key: string; + title: string; + items: CombatRewardItem[]; +} + +/** A drop the bag refused, rendered apart from what was actually banked. */ +interface LeftBehindEntry { + key: string; + name: string; + quantity: number; +} + +// Reward rows are grouped by what the item *is*, not by which enemy dropped +// it (spec §9). Trophies sit with the trade goods: an insignia is turned in +// at the merchant exactly like a pelt. +const LOOT_GROUPS: ReadonlyArray<{ key: string; title: string; types: ItemType[] }> = [ + { key: 'trade-goods', title: 'Trade Goods', types: ['TRADE_GOOD', 'TROPHY'] }, + { key: 'equipment', title: 'Equipment', types: ['EQUIPMENT'] }, + { key: 'consumables', title: 'Consumables', types: ['CONSUMABLE'] }, + { key: 'quest-items', title: 'Quest Items', types: ['QUEST_ITEM'] }, +]; + +const STATUS_EFFECT_LABELS: Readonly> = { + BLEED: 'Bleeding', +}; + +// Ongoing-effect events belong to the round's aftermath, not to the enemy's +// turn: they still fire when Shield Bash interrupted the monster outright. +const STATUS_EVENT_TYPES: ReadonlySet = new Set([ + 'STATUS_APPLIED', + 'STATUS_DAMAGE', + 'STATUS_EXPIRED', +]); + type CombatPhase = 'idle' | 'attacking' | 'hit'; // The monster is a single cut-out with no sheets, so its beats are pure // CSS transforms and run offset from the player's: it flinches when the @@ -42,7 +86,7 @@ const DAMAGING_ACTIONS: ReadonlySet = new Set(['ATTACK', 'HEAVY_ST selector: 'app-combat-page', templateUrl: './combat-page.component.html', styleUrl: './combat-page.component.scss', - imports: [ItemCardComponent], + imports: [ItemCardComponent, LootCapacityStripComponent], }) export class CombatPageComponent implements OnInit { protected readonly combatStore = inject(CombatStore); @@ -105,9 +149,9 @@ export class CombatPageComponent implements OnInit { } if (after.status === 'WON') { - // The server already granted silver and any item drops; pull the - // authoritative character so the HUD matches (spec §35). Renown is - // not granted here -- it comes from milestones only. + // A victory grants items only -- no Silver, no Renown (slice 0.7 V2 + // §7) -- but the character's HP moved, so pull the authoritative + // character for the HUD (spec §35). void this.worldStore.refreshCharacter(); } @@ -117,10 +161,13 @@ export class CombatPageComponent implements OnInit { ); this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle'); - const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER'); + const monsterEvent = roundEvents.find( + (event) => event.source === 'MONSTER' && !STATUS_EVENT_TYPES.has(event.type), + ); if (!monsterEvent) { // No reply this round: either the fight just ended, or SHIELD_BASH - // interrupted the monster's turn outright. + // interrupted the monster's turn outright. Any bleed tick that still + // landed comes along with this reveal. this.displayed.set(after); return; } @@ -162,7 +209,7 @@ export class CombatPageComponent implements OnInit { } this.phase.set('hit'); - this.monsterPhase.set('lunge'); + this.monsterPhase.set(monsterEvent.type === 'DAMAGE' ? 'lunge' : 'idle'); this.displayed.set(after); await this.wait(RECOIL_MS); if (this.destroyed) { @@ -228,6 +275,46 @@ export class CombatPageComponent implements OnInit { return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; } + /** + * The loot summary, split into the categories the player thinks in (spec + * §9). Empty groups are dropped so a fight that only yielded a pelt shows + * one heading rather than four. + */ + protected lootGroups(): LootGroup[] { + const items = this.displayed()?.rewards?.items ?? []; + + return LOOT_GROUPS.map((group) => ({ + key: group.key, + title: group.title, + // A drop that was entirely refused belongs under "left behind", not in + // the loot the player walked away with (slice 0.7.5 §9). + items: items.filter( + (reward) => + reward.quantity > 0 && group.types.includes(reward.item.type), + ), + })).filter((group) => group.items.length > 0); + } + + protected statusEffectLabel(type: StatusEffectType): string { + return STATUS_EFFECT_LABELS[type]; + } + + /** + * What a full bag refused (slice 0.7.5 §9). + * + * Never silently dropped: the player has to learn that the trip is over + * before they walk into another fight and lose the same good again. + */ + protected leftBehind(): LeftBehindEntry[] { + return (this.displayed()?.rewards?.items ?? []) + .filter((reward) => reward.quantityLeftBehind > 0) + .map((reward) => ({ + key: reward.item.key, + name: reward.item.name, + quantity: reward.quantityLeftBehind, + })); + } + protected monsterIntentLabel(): string | null { const combat = this.displayed(); if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') { @@ -279,6 +366,20 @@ export class CombatPageComponent implements OnInit { return `${playerName} interrupts ${monsterName}'s attack.`; } + const effect = event.statusEffect ? STATUS_EFFECT_LABELS[event.statusEffect] : 'An effect'; + + if (event.type === 'STATUS_APPLIED') { + return `${monsterName} inflicts ${effect} on ${playerName}.`; + } + + if (event.type === 'STATUS_DAMAGE') { + return `${effect} costs ${playerName} ${event.amount} HP.`; + } + + if (event.type === 'STATUS_EXPIRED') { + return `${effect} fades from ${playerName}.`; + } + if (event.type === 'COMBAT_WON') { return `${monsterName} has been defeated.`; } diff --git a/apps/web/src/app/features/combat/combat.store.spec.ts b/apps/web/src/app/features/combat/combat.store.spec.ts index fbde827..ead0e41 100644 --- a/apps/web/src/app/features/combat/combat.store.spec.ts +++ b/apps/web/src/app/features/combat/combat.store.spec.ts @@ -10,7 +10,14 @@ const startedCombat: Combat = { id: 'combat-1', status: 'ACTIVE', round: 1, - player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, + player: { + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 100, + potionsRemaining: 2, + potionsMax: 2, + statusEffects: [], + }, monster: { key: 'ash-rat', name: 'Ash Rat', diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html index 132dbad..e7ee7bc 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html @@ -1,4 +1,8 @@ -
+
@if (iconPath(); as icon) { @@ -14,6 +18,10 @@ decoding="async" /> + @if (rare) { + {{ rareLabel }} + } + @if (defeated) {
+ +@if (encounter.monster.flavorText; as flavor) { +

{{ flavor }}

+} diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss index d947f86..be476ae 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss @@ -1,5 +1,7 @@ :host { - display: block; + display: grid; + gap: 0.5rem; + justify-items: center; } /* @@ -21,6 +23,13 @@ background-size: 100% 100%; } +/* A rare find has to be legible across the row before the player reads a + single word, so the whole frame carries a gilded halo -- not just the + corner tag inside the artwork panel. */ +.encounter-card--rare { + filter: drop-shadow(0 0 0.85rem rgb(214 178 107 / 0.55)); +} + /* A settled encounter is out of play, so the card recedes: the artwork loses its colour and the whole frame dims. */ .encounter-card--settled .encounter-card__artwork { @@ -64,6 +73,24 @@ place-items: end center; } +/* Pinned inside the artwork panel: the painted frame has no free band, and + the crest sits centred on the card's top edge, so the corner is the one + place a tag can live without covering something. */ +.encounter-card__rare { + position: absolute; + inset-block-start: 3%; + inset-inline-start: 2%; + padding: 0.15em 0.6em; + border: 1px solid rgb(214 178 107 / 0.85); + border-radius: 0.15rem; + color: #f2e2bd; + background: rgb(28 20 12 / 0.85); + font-size: clamp(0.55rem, 3.2cqw, 0.75rem); + letter-spacing: 0.12em; + line-height: 1.2; + text-transform: uppercase; +} + /* Sits over the artwork panel rather than the whole card, so the name and the danger badge stay readable under it. */ .encounter-card__defeated-mark { @@ -166,6 +193,23 @@ outline-offset: -3px; } +/* ---------- flavor line ---------- */ + +/* Outside the painted frame on purpose: every band inside it is already + spoken for, and an atmosphere line is the one piece of the card that may + wrap to a second row. */ +.encounter-card__flavor { + max-inline-size: 20rem; + margin: 0; + color: var(--ar-text-muted); + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--ar-font-sm); + font-style: italic; + line-height: 1.35; + text-align: center; + text-wrap: balance; +} + @media (prefers-reduced-motion: no-preference) { .encounter-card { transition: filter var(--ar-motion-base); @@ -175,6 +219,11 @@ filter: drop-shadow(0 0 0.9rem rgb(214 178 107 / 0.3)); } + /* A rare card must not lose its halo the moment the pointer arrives. */ + .encounter-card--rare:not(.encounter-card--settled):hover { + filter: drop-shadow(0 0 1.1rem rgb(214 178 107 / 0.65)); + } + .encounter-card__attack { transition: color var(--ar-motion-fast), diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts index e1b4f98..35e252f 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts @@ -10,9 +10,11 @@ const dawnwolfEncounter: HuntEncounter = { name: 'Dawnwolf', level: 3, artworkPath: '/images/enemies/Dawnwolf.png', + flavorText: null, }, dangerRating: 'MATCH', status: 'AVAILABLE', + encounterType: 'NORMAL', }; const ashRatEncounter: HuntEncounter = { @@ -22,9 +24,11 @@ const ashRatEncounter: HuntEncounter = { name: 'Ash Rat', level: 1, artworkPath: '/images/monsters/ash-rat.png', + flavorText: null, }, dangerRating: 'WEAK', status: 'AVAILABLE', + encounterType: 'NORMAL', }; function render(encounter: HuntEncounter): HTMLElement { @@ -92,6 +96,71 @@ describe('EncounterCardComponent', () => { expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key); }); + describe('rare encounters (spec §9)', () => { + const charredRaider: HuntEncounter = { + ...dawnwolfEncounter, + id: 'encounter-id-rare', + monster: { + key: 'charred-looter', + name: 'Charred Raider', + level: 2, + artworkPath: '/images/monsters/charred-looter.png', + flavorText: null, + }, + encounterType: 'RARE', + }; + + it('marks a rare encounter with a tag and a distinct frame', () => { + const element = render(charredRaider); + + expect(element.querySelector('[data-encounter-rare]')?.textContent?.trim()).toBe( + 'Rare', + ); + expect(element.querySelector('.encounter-card')?.classList).toContain( + 'encounter-card--rare', + ); + }); + + it('leaves an ordinary encounter unmarked', () => { + const element = render(dawnwolfEncounter); + + expect(element.querySelector('[data-encounter-rare]')).toBeNull(); + expect(element.querySelector('.encounter-card')?.classList).not.toContain( + 'encounter-card--rare', + ); + }); + + it('reads the encounter type from the server rather than the monster key', () => { + // The same rare monster, downgraded to NORMAL by the server, must lose + // its marking -- the card may not decide rarity from content it knows. + const element = render({ ...charredRaider, encounterType: 'NORMAL' }); + + expect(element.querySelector('[data-encounter-rare]')).toBeNull(); + }); + }); + + describe('flavor text (spec §9)', () => { + it('shows the flavor line the server sent for the monster', () => { + const element = render({ + ...dawnwolfEncounter, + monster: { + ...dawnwolfEncounter.monster, + flavorText: 'It hunts at the hour the light turns.', + }, + }); + + expect(element.querySelector('[data-encounter-flavor]')?.textContent).toContain( + 'It hunts at the hour the light turns.', + ); + }); + + it('renders nothing at all for a monster without a flavor line', () => { + const element = render(dawnwolfEncounter); + + expect(element.querySelector('[data-encounter-flavor]')).toBeNull(); + }); + }); + it('leaves an available encounter unmarked and interactive', () => { const element = render(dawnwolfEncounter); diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts index 660b646..284f00f 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts @@ -27,6 +27,22 @@ export class EncounterCardComponent { return this.encounter.status !== 'AVAILABLE'; } + /** + * An uncommon find has to be recognisable at a glance (spec §9). The card + * reads the server's encounter type rather than checking for a monster key, + * so marking a future enemy as rare stays a content change. + */ + protected get rare(): boolean { + return this.encounter.encounterType !== 'NORMAL'; + } + + protected get rareLabel(): string { + return this.encounter.encounterType === 'RARE' + ? 'Rare' + : this.encounter.encounterType.charAt(0) + + this.encounter.encounterType.slice(1).toLowerCase(); + } + protected get actionLabel(): string { if (this.defeated) { return 'Defeated'; diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html index b8aa947..0c7787f 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html @@ -10,6 +10,13 @@

{{ location.name }} — Encounters

+ @if (huntingStore.lootCapacities().length) { + + } +
@for (encounter of huntingStore.encounters(); track encounter.id) { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss index 84cc962..9eb4211 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss @@ -43,6 +43,15 @@ font-size: 1.3rem; } +/* Between the heading and the cards: the player sees what they can still + carry before choosing the next fight, not after it (spec §12). */ +.hunt-page__capacities { + display: block; + margin-block-end: var(--ar-space-4); + padding-block-end: var(--ar-space-3); + border-block-end: 1px solid var(--ar-border); +} + .hunt-page__encounters { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index ccdf298..2ae54bc 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -3,7 +3,12 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; -import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; +import type { + Combat, + CurrentLocationResponse, + HuntResult, + LootCapacity, +} from '../../../core/api/game-api.models'; import { CombatStore } from '../../combat/combat.store'; import { burnedRoadFixture, @@ -23,9 +28,16 @@ const threeEncounterHunt: HuntResult = { encounters: [ { id: 'encounter-1', - monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' }, + monster: { + key: 'ash-rat', + name: 'Ash Rat', + level: 1, + artworkPath: '/images/enemies/AshRat.png', + flavorText: null, + }, dangerRating: 'WEAK', status: 'AVAILABLE', + encounterType: 'NORMAL', }, { id: 'encounter-2', @@ -34,15 +46,24 @@ const threeEncounterHunt: HuntResult = { name: 'Road Bandit', level: 3, artworkPath: '/images/enemies/RoadBandit.png', + flavorText: null, }, dangerRating: 'MATCH', status: 'AVAILABLE', + encounterType: 'NORMAL', }, { id: 'encounter-3', - monster: { key: 'ash-rat', name: 'Ash Rat', level: 1, artworkPath: '/images/enemies/AshRat.png' }, - dangerRating: 'WEAK', + monster: { + key: 'charred-looter', + name: 'Charred Raider', + level: 2, + artworkPath: '/images/enemies/CharredRaider.png', + flavorText: null, + }, + dangerRating: 'STRONG', status: 'AVAILABLE', + encounterType: 'RARE', }, ], }; @@ -51,7 +72,14 @@ const startedCombat: Combat = { id: 'combat-2', status: 'ACTIVE', round: 1, - player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, + player: { + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 100, + potionsRemaining: 2, + potionsMax: 2, + statusEffects: [], + }, monster: { key: 'road-bandit', name: 'Road Bandit', @@ -78,6 +106,8 @@ describe('HuntPageComponent', () => { startHunt: ReturnType; refreshHunt: ReturnType; loadActiveHunt: ReturnType; + loadLootCapacities: ReturnType; + lootCapacities: ReturnType>; selectEncounter: ReturnType; }; let combatStore: { @@ -90,7 +120,11 @@ describe('HuntPageComponent', () => { }; let router: Router; - async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) { + async function setup( + location: CurrentLocationResponse | null, + hunt: HuntResult | null = null, + capacities: LootCapacity[] = [], + ) { worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) }; const currentHunt = signal(hunt); huntingStore = { @@ -101,6 +135,8 @@ describe('HuntPageComponent', () => { startHunt: vi.fn(() => Promise.resolve()), refreshHunt: vi.fn(() => Promise.resolve()), loadActiveHunt: vi.fn(() => Promise.resolve()), + loadLootCapacities: vi.fn(() => Promise.resolve()), + lootCapacities: signal(capacities), selectEncounter: vi.fn(), }; combatStore = { @@ -158,27 +194,69 @@ describe('HuntPageComponent', () => { expect(huntingStore.startHunt).toHaveBeenCalledOnce(); }); - it('renders 3 encounter cards, duplicates included, with the correct data', async () => { + it('renders one card per encounter the server rolled, in order', async () => { const fixture = await setup(burnedRoad, threeEncounterHunt); const element = fixture.nativeElement as HTMLElement; const cards = element.querySelectorAll('app-encounter-card'); expect(cards.length).toBe(3); - expect(element.textContent).toMatch(/Ash Rat[\s\S]*Road Bandit[\s\S]*Ash Rat/); - expect( - element.querySelectorAll( - '.encounter-card__artwork[src="/images/combat/sprites/ash-rat-760.png"]', - ).length, - ).toBe(2); - expect( - element.querySelectorAll( - '.encounter-card__artwork[src="/images/combat/sprites/road-bandit-620.png"]', - ).length, - ).toBe(1); + expect(element.textContent).toMatch( + /Ash Rat[\s\S]*Road Bandit[\s\S]*Charred Raider/, + ); + for (const sprite of [ + '/images/combat/sprites/ash-rat-760.png', + '/images/combat/sprites/road-bandit-620.png', + '/images/combat/sprites/charred-looter-620.png', + ]) { + expect( + element.querySelectorAll(`.encounter-card__artwork[src="${sprite}"]`).length, + ).toBe(1); + } expect(element.textContent).toContain('Level 1'); expect(element.textContent).toContain('Level 3'); }); + it('marks only the rare encounter, so the player can spot it at a glance', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt); + const element = fixture.nativeElement as HTMLElement; + + const rareTags = element.querySelectorAll('[data-encounter-rare]'); + expect(rareTags.length).toBe(1); + expect(rareTags[0].textContent?.trim()).toBe('Rare'); + expect(element.querySelectorAll('.encounter-card--rare').length).toBe(1); + }); + + describe('carrying capacity (slice 0.7.5 §12)', () => { + it('shows what the player can still carry beside the encounters', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt, [ + { category: 'HIDE', current: 4, capacity: 5, bag: null }, + { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, + ]); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-loot-capacities]')).toBeTruthy(); + expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain( + '4 / 5', + ); + }); + + it('re-reads capacity on page entry, because the last fight moved it', async () => { + await setup(burnedRoad, threeEncounterHunt); + + expect(huntingStore.loadLootCapacities).toHaveBeenCalledOnce(); + }); + + it('renders no strip at all when capacity could not be loaded', async () => { + const fixture = await setup(burnedRoad, threeEncounterHunt, []); + const element = fixture.nativeElement as HTMLElement; + + // A capacity strip that failed to load is missing decoration, not a + // broken hunt -- it must not become an error banner over the cards. + expect(element.querySelector('[data-loot-capacities]')).toBeNull(); + expect(element.querySelectorAll('app-encounter-card').length).toBe(3); + }); + }); + it('calls refreshHunt when Search Again is clicked', async () => { const fixture = await setup(burnedRoad, threeEncounterHunt); const element = fixture.nativeElement as HTMLElement; diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 5547472..113f2ee 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -2,12 +2,13 @@ import { Component, OnInit, inject } from '@angular/core'; import { Router } from '@angular/router'; import { CombatStore } from '../../combat/combat.store'; import { WorldStore } from '../../world/world.store'; +import { LootCapacityStripComponent } from '../../../shared/loot-capacity-strip/loot-capacity-strip.component'; import { EncounterCardComponent } from '../encounter-card/encounter-card.component'; import { HuntingStore } from '../hunting.store'; @Component({ selector: 'app-hunt-page', - imports: [EncounterCardComponent], + imports: [EncounterCardComponent, LootCapacityStripComponent], templateUrl: './hunt-page.component.html', styleUrl: './hunt-page.component.scss', }) @@ -26,6 +27,9 @@ export class HuntPageComponent implements OnInit { // including on the way back from a fight -- takes its word over whatever // roll is still in memory. void this.huntingStore.loadActiveHunt(); + // Same reason: a fight just banked (or refused) loot, so the carrying + // strip has to be re-read rather than trusted from before the fight. + void this.huntingStore.loadLootCapacities(); } protected startHunt(): void { diff --git a/apps/web/src/app/features/hunting/hunting.store.spec.ts b/apps/web/src/app/features/hunting/hunting.store.spec.ts index f48c030..50ff1a4 100644 --- a/apps/web/src/app/features/hunting/hunting.store.spec.ts +++ b/apps/web/src/app/features/hunting/hunting.store.spec.ts @@ -12,15 +12,29 @@ const huntResult: HuntResult = { encounters: [ { id: 'encounter-1', - monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' }, + monster: { + key: 'wolf', + name: 'Wolf', + level: 1, + artworkPath: '/images/enemies/Wolf.png', + flavorText: null, + }, dangerRating: 'MATCH', status: 'AVAILABLE', + encounterType: 'NORMAL', }, { id: 'encounter-2', - monster: { key: 'bear', name: 'Bear', level: 3, artworkPath: '/images/enemies/Bear.png' }, + monster: { + key: 'bear', + name: 'Bear', + level: 3, + artworkPath: '/images/enemies/Bear.png', + flavorText: null, + }, dangerRating: 'STRONG', status: 'DEFEATED', + encounterType: 'NORMAL', }, ], }; @@ -31,9 +45,16 @@ const refreshedHuntResult: HuntResult = { encounters: [ { id: 'encounter-3', - monster: { key: 'rat', name: 'Rat', level: 1, artworkPath: '/images/enemies/Rat.png' }, + monster: { + key: 'rat', + name: 'Rat', + level: 1, + artworkPath: '/images/enemies/Rat.png', + flavorText: null, + }, dangerRating: 'WEAK', status: 'AVAILABLE', + encounterType: 'NORMAL', }, ], }; diff --git a/apps/web/src/app/features/hunting/hunting.store.ts b/apps/web/src/app/features/hunting/hunting.store.ts index 3b1cdbd..e68d465 100644 --- a/apps/web/src/app/features/hunting/hunting.store.ts +++ b/apps/web/src/app/features/hunting/hunting.store.ts @@ -1,7 +1,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, computed, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { HuntResult } from '../../core/api/game-api.models'; +import { HuntResult, LootCapacity } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; const GENERIC_ERROR_MESSAGE = 'Could not load world state.'; @@ -18,11 +18,13 @@ const HUNT_ERROR_MESSAGES: Readonly> = { @Injectable({ providedIn: 'root' }) export class HuntingStore { private readonly currentHuntState = signal(null); + private readonly lootCapacitiesState = signal([]); private readonly selectedEncounterIdState = signal(null); private readonly loadingState = signal(false); private readonly errorState = signal(null); readonly currentHunt = this.currentHuntState.asReadonly(); + readonly lootCapacities = this.lootCapacitiesState.asReadonly(); readonly selectedEncounterId = this.selectedEncounterIdState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); @@ -46,6 +48,23 @@ export class HuntingStore { } } + /** + * Refreshes what the character can still carry (slice 0.7.5 §12). + * + * Deliberately silent on failure: a capacity strip that cannot load is + * missing decoration, not a broken hunt, and an error banner over the + * encounter cards would be worse than showing nothing. + */ + async loadLootCapacities(): Promise { + try { + this.lootCapacitiesState.set( + await firstValueFrom(this.api.getLootCapacities()), + ); + } catch { + this.lootCapacitiesState.set([]); + } + } + async refreshHunt(): Promise { await this.startHunt(); } diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html new file mode 100644 index 0000000..a024c04 --- /dev/null +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -0,0 +1,265 @@ +@if (store.loading()) { +

Approaching…

+} @else if (store.error()) { + +} @else if (store.interaction(); as interaction) { +
+
+ +
+

{{ interaction.npc.name }}

+ @if (interaction.npc.title) { +

{{ interaction.npc.title }}

+ } + @if (interaction.npc.description) { +

{{ interaction.npc.description }}

+ } +
+
+ + @if (interaction.dialogue) { +
+ {{ interaction.dialogue.text }} +
+ } + + + + @if (store.actionError()) { + + } + + @if (isPanel('EXCHANGE') && store.exchange(); as exchange) { +
+

{{ exchange.profileName }}

+ + + + @if (exchange.offers.length === 0) { +

There is nothing here he will take.

+ } @else { +
    + @for (offer of exchange.offers; track offer.itemKey) { +
  • + +
    + {{ offer.itemName }} + + Carrying {{ offer.quantityCarried }} + +
    + +
    + {{ offer.silverPerStep }} Silver + +{{ offer.reputationPerStep }} + {{ offer.factionName }} +
    + +
    + + + +
    +
  • + } +
+ +
+ + +
+ } + + @if (store.lastTrade(); as trade) { +
+

Trade Complete

+
    + @for (entry of trade.consumed; track entry.itemKey) { +
  • {{ entry.quantity }} × {{ entry.itemName }} handed in
  • + } +
+
    +
  • +{{ trade.rewards.silver }} Silver
  • +
  • +{{ trade.rewards.regionalReputation }} Border Watch Reputation
  • + @if (trade.rewards.worldRenown > 0) { +
  • + +{{ trade.rewards.worldRenown }} World Renown +
  • + } +
+ @if (trade.reputationRankChanged) { +

+ The Border Watch now regards you differently. +

+ } + +
+ } +
+ } + + @if (isPanel('SHOP') && store.shop(); as shop) { +
+

{{ shop.shopName }}

+

{{ shop.silver }} Silver

+ +
    + @for (offer of shop.offers; track offer.itemKey) { +
  • + +
    + {{ offer.itemName }} + {{ + offer.itemDescription + }} +
    + {{ offer.price }} Silver + +
  • + } +
+ + @if (store.lastPurchase(); as purchase) { +

+ Bought {{ purchase.quantity }} × {{ purchase.itemName }} for + {{ purchase.silverSpent }} Silver. + +

+ } +
+ } +
+} diff --git a/apps/web/src/app/features/npc/merchant-page.component.scss b/apps/web/src/app/features/npc/merchant-page.component.scss new file mode 100644 index 0000000..ef36532 --- /dev/null +++ b/apps/web/src/app/features/npc/merchant-page.component.scss @@ -0,0 +1,381 @@ +:host { + display: block; + min-block-size: 0; +} + +.merchant { + display: grid; + gap: var(--ar-space-4); + align-content: start; + padding: var(--ar-space-5); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-md); + background: + linear-gradient(180deg, rgb(201 164 95 / 0.06), transparent 28%), + var(--ar-panel); +} + +.merchant__status { + padding: var(--ar-space-5); + color: var(--ar-text-muted); +} + +.merchant__status--error { + display: grid; + justify-items: start; + gap: var(--ar-space-3); + color: var(--ar-danger); +} + +/* Portrait first, at a size that reads as a person rather than a list row + (NPC spec §36). */ +.merchant__identity { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--ar-space-4); + align-items: start; +} + +.merchant__portrait { + inline-size: 7rem; + block-size: 7rem; + object-fit: cover; + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-md); + background: var(--ar-panel-muted); +} + +.merchant__naming { + display: grid; + gap: var(--ar-space-1); +} + +.merchant__name { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.6rem; + color: var(--ar-text); +} + +.merchant__title { + margin: 0; + color: var(--ar-gold); + font-size: var(--ar-font-sm); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.merchant__description { + margin: var(--ar-space-2) 0 0; + max-inline-size: 60ch; + color: var(--ar-text-muted); + line-height: 1.55; +} + +.merchant__dialogue { + margin: 0; + padding: var(--ar-space-4); + border-inline-start: 2px solid var(--ar-border-highlight); + background: var(--ar-panel-muted); + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.02rem; + line-height: 1.6; +} + +.merchant__actions { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-2); +} + +.merchant__button { + padding: var(--ar-space-2) var(--ar-space-4); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel-muted); + color: var(--ar-text); + font: inherit; + font-size: var(--ar-font-sm); + cursor: pointer; + transition: border-color var(--ar-motion-fast), color var(--ar-motion-fast); +} + +.merchant__button:hover:not(:disabled) { + border-color: var(--ar-border-highlight); + color: var(--ar-gold); +} + +.merchant__button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.merchant__button--active { + border-color: var(--ar-border-highlight); + color: var(--ar-gold); +} + +.merchant__button--primary { + border-color: var(--ar-border-highlight); + color: var(--ar-gold); +} + +.merchant__link { + border: 0; + background: none; + color: var(--ar-blue); + font: inherit; + font-size: var(--ar-font-sm); + cursor: pointer; + text-decoration: underline; +} + +.merchant__error { + margin: 0; + color: var(--ar-danger); + font-size: var(--ar-font-sm); +} + +.merchant__panel { + display: grid; + gap: var(--ar-space-3); + padding-block-start: var(--ar-space-4); + border-block-start: 1px solid var(--ar-border); +} + +.merchant__panel-title { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.15rem; + color: var(--ar-text); +} + +.merchant__purse { + margin: 0; + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} + +.merchant__empty { + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); +} + +.trade-list, +.shop-list { + display: grid; + gap: var(--ar-space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.trade-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: var(--ar-space-3); + align-items: center; + padding: var(--ar-space-3); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel-muted); +} + +/* Still listed, so the player can see what this merchant would take. */ +.trade-row--empty { + opacity: 0.55; +} + +.trade-row__icon, +.shop-row__icon { + inline-size: 2.25rem; + block-size: 2.25rem; + object-fit: contain; +} + +.trade-row__naming, +.shop-row__naming { + display: grid; + gap: 0.1rem; + min-inline-size: 0; +} + +.trade-row__name, +.shop-row__name { + color: var(--ar-text); +} + +.trade-row__carried, +.shop-row__description { + color: var(--ar-text-muted); + font-size: 0.75rem; +} + +.trade-row__value { + display: grid; + gap: 0.1rem; + justify-items: end; + text-align: end; +} + +.trade-row__silver { + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} + +.trade-row__rep { + color: var(--ar-text-muted); + font-size: 0.72rem; +} + +.trade-row__picker { + display: flex; + align-items: center; + gap: var(--ar-space-1); +} + +.trade-row__step { + inline-size: 1.75rem; + block-size: 1.75rem; + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel); + color: var(--ar-text); + font: inherit; + cursor: pointer; +} + +.trade-row__step:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.trade-row__quantity { + inline-size: 3.5rem; + padding: var(--ar-space-1); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel); + color: var(--ar-text); + font: inherit; + text-align: center; +} + +.trade-footer { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-3); + align-items: center; + justify-content: space-between; + padding-block-start: var(--ar-space-2); +} + +.trade-footer__preview { + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); +} + +.trade-footer__preview strong { + color: var(--ar-gold); + font-weight: 600; +} + +.trade-footer__buttons { + display: flex; + gap: var(--ar-space-2); +} + +/* A quiet ledger, not a reward explosion (slice §10). */ +.trade-summary { + display: grid; + gap: var(--ar-space-2); + justify-items: start; + padding: var(--ar-space-4); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel-muted); +} + +.trade-summary__title { + margin: 0; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1rem; + color: var(--ar-gold); +} + +.trade-summary__consumed, +.trade-summary__rewards { + display: grid; + gap: 0.15rem; + margin: 0; + padding: 0; + list-style: none; + font-size: var(--ar-font-sm); +} + +.trade-summary__consumed { + color: var(--ar-text-muted); +} + +.trade-summary__rewards { + color: var(--ar-success); +} + +.trade-summary__renown { + color: var(--ar-gold); + font-weight: 600; +} + +.trade-summary__rank { + margin: 0; + color: var(--ar-blue); + font-size: var(--ar-font-sm); +} + +.shop-row { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: var(--ar-space-3); + align-items: center; + padding: var(--ar-space-3); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel-muted); +} + +.shop-row__price { + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} + +.shop-receipt { + display: flex; + gap: var(--ar-space-2); + align-items: center; + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); +} + +@media (max-width: 40rem) { + .merchant__identity { + grid-template-columns: 1fr; + } + + .trade-row, + .shop-row { + grid-template-columns: auto 1fr; + row-gap: var(--ar-space-2); + } + + .trade-row__value, + .trade-row__picker { + grid-column: 1 / -1; + justify-content: start; + justify-items: start; + text-align: start; + } +} diff --git a/apps/web/src/app/features/npc/merchant-page.component.spec.ts b/apps/web/src/app/features/npc/merchant-page.component.spec.ts new file mode 100644 index 0000000..f3ce881 --- /dev/null +++ b/apps/web/src/app/features/npc/merchant-page.component.spec.ts @@ -0,0 +1,311 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; +import type { + ExchangeResult, + ExchangeView, + NpcInteraction, + ShopView, +} from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { MerchantPageComponent } from './merchant-page.component'; +import { MerchantStore } from './merchant.store'; + +const INTERACTION: NpcInteraction = { + npc: { + id: 'npc-1', + key: 'borin-quartermaster', + name: 'Borin', + title: 'Quartermaster of the Border Watch', + description: 'A broad, grey-bearded man.', + portraitPath: '/images/npcs/borin.png', + artworkPath: null, + capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'], + }, + dialogue: { + key: 'borin-default', + text: 'Pelts, hides, raider trinkets — I take all of it.', + responses: [], + }, + availableActions: [ + { type: 'TALK', label: 'Talk', key: null }, + { type: 'OPEN_SHOP', label: 'Browse Wares', key: 'borin-supplies' }, + { type: 'OPEN_EXCHANGE', label: 'Trade In Goods', key: 'borin-trade-in' }, + ], +}; + +const EXCHANGE: ExchangeView = { + profileKey: 'borin-trade-in', + profileName: 'Border Watch Trade-In', + npcKey: 'borin-quartermaster', + offers: [ + { + itemKey: 'ash-pelt', + itemName: 'Ashen Pelt', + iconPath: '/images/items/ash-pelt.png', + quantityCarried: 8, + inputQuantity: 1, + silverPerStep: 5, + reputationPerStep: 2, + factionKey: 'border-guard', + factionName: 'Border Watch', + renownMilestoneKey: null, + }, + { + itemKey: 'charred-raider-insignia', + itemName: 'Charred Raider Insignia', + iconPath: '/images/items/charred.png', + quantityCarried: 0, + inputQuantity: 1, + silverPerStep: 30, + reputationPerStep: 12, + factionKey: 'border-guard', + factionName: 'Border Watch', + renownMilestoneKey: null, + }, + ], + capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }], +}; + +const SHOP: ShopView = { + shopKey: 'borin-supplies', + shopName: "Quartermaster's Supplies", + npcKey: 'borin-quartermaster', + silver: 100, + offers: [ + { + itemKey: 'small-healing-potion', + itemName: 'Small Healing Potion', + itemDescription: 'A bitter draught.', + iconPath: '/images/items/potion.png', + currencyType: 'SILVER', + price: 12, + quantity: 1, + unlocked: true, + affordable: true, + }, + { + itemKey: 'ash-blade', + itemName: 'Ash Blade', + itemDescription: 'Locked for now.', + iconPath: '/images/items/ash-blade.png', + currencyType: 'SILVER', + price: 400, + quantity: 1, + unlocked: false, + affordable: false, + }, + ], +}; + +const TRADE_RESULT: ExchangeResult = { + profileKey: 'borin-trade-in', + consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }], + rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 }, + balances: { silver: 25, regionalReputation: 10, worldRenown: 2 }, + reputationRankChanged: false, + newReputationRank: null, + renownMilestonesCompleted: ['first-goods-returned'], + capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }], +}; + +async function render(): Promise<{ + fixture: ComponentFixture; + element: HTMLElement; + store: MerchantStore; +}> { + const api = { + getNpcInteraction: vi.fn(() => of(INTERACTION)), + getTradeIn: vi.fn(() => of(EXCHANGE)), + getShop: vi.fn(() => of(SHOP)), + tradeIn: vi.fn(() => of(TRADE_RESULT)), + purchase: vi.fn(() => + of({ + shopKey: 'borin-supplies', + itemKey: 'small-healing-potion', + itemName: 'Small Healing Potion', + quantity: 1, + silverSpent: 12, + silverBalance: 88, + }), + ), + }; + + await TestBed.configureTestingModule({ + imports: [MerchantPageComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: GameApiService, useValue: api }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { paramMap: { get: () => 'borin-quartermaster' } }, + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(MerchantPageComponent); + const store = TestBed.inject(MerchantStore); + + // `ngOnInit` kicks off an async load with several awaits in it. A macrotask + // turn drains that whole microtask chain; `whenStable` alone does not under + // zoneless change detection. + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + return { fixture, element: fixture.nativeElement as HTMLElement, store }; +} + +describe('MerchantPageComponent', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('presents the NPC as a person, not a table (spec §36)', async () => { + const { element } = await render(); + + expect(element.querySelector('.merchant__name')?.textContent).toContain( + 'Borin', + ); + expect(element.querySelector('.merchant__title')?.textContent).toContain( + 'Quartermaster', + ); + expect(element.querySelector('.merchant__portrait')).not.toBeNull(); + expect(element.querySelector('[data-dialogue]')?.textContent).toContain( + 'I take all of it', + ); + }); + + it('renders exactly the actions the server offered', async () => { + const { element } = await render(); + + const actions = Array.from( + element.querySelectorAll('[data-action]'), + ).map((node) => node.getAttribute('data-action')); + + expect(actions).toEqual(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']); + }); + + it('shows carried quantity and value per good (slice §10)', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + fixture.detectChanges(); + + const row = element.querySelector('[data-trade-item="ash-pelt"]'); + expect(row?.querySelector('[data-carried]')?.textContent).toContain('8'); + expect(row?.querySelector('[data-value]')?.textContent).toContain( + '5 Silver', + ); + }); + + it('still lists a good the player is not carrying, dimmed', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + fixture.detectChanges(); + + const row = element.querySelector( + '[data-trade-item="charred-raider-insignia"]', + ); + expect(row).not.toBeNull(); + expect(row?.classList).toContain('trade-row--empty'); + }); + + it('keeps the trade button disabled until something is selected', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + fixture.detectChanges(); + + const button = element.querySelector( + '[data-trade-confirm]', + ); + expect(button?.disabled).toBe(true); + + store.setQuantity('ash-pelt', 5); + fixture.detectChanges(); + expect( + element.querySelector('[data-trade-confirm]')?.disabled, + ).toBe(false); + }); + + it('previews the reward before the trade is made', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + store.setQuantity('ash-pelt', 5); + fixture.detectChanges(); + + const preview = element.querySelector('[data-preview]')?.textContent; + expect(preview).toContain('25 Silver'); + expect(preview).toContain('10 Reputation'); + }); + + it('summarises a completed trade as a ledger, including renown', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + store.setQuantity('ash-pelt', 5); + fixture.detectChanges(); + + await store.tradeSelected(); + fixture.detectChanges(); + + const summary = element.querySelector('[data-trade-summary]'); + expect(summary?.textContent).toContain('Trade Complete'); + expect(summary?.textContent).toContain('5 × Ashen Pelt'); + expect(summary?.textContent).toContain('+25 Silver'); + expect(summary?.querySelector('[data-renown]')?.textContent).toContain( + '+1 World Renown', + ); + }); + + it('closes the trade summary when dismissed', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + store.setQuantity('ash-pelt', 5); + fixture.detectChanges(); + + await store.tradeSelected(); + fixture.detectChanges(); + expect(element.querySelector('[data-trade-summary]')).not.toBeNull(); + + store.dismissTradeSummary(); + fixture.detectChanges(); + expect(element.querySelector('[data-trade-summary]')).toBeNull(); + }); + + it('shows the bag capacity the trade will free (slice §11)', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('EXCHANGE'); + fixture.detectChanges(); + + expect(element.querySelector('app-loot-capacity-strip')).not.toBeNull(); + }); + + it('disables a locked shop offer and says so', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('SHOP'); + fixture.detectChanges(); + + const locked = element.querySelector('[data-shop-item="ash-blade"]'); + const button = locked?.querySelector('button'); + expect(button?.disabled).toBe(true); + expect(button?.textContent).toContain('Locked'); + }); + + it('shows the purse so a price means something', async () => { + const { fixture, element, store } = await render(); + + store.showPanel('SHOP'); + fixture.detectChanges(); + + expect(element.querySelector('[data-purse]')?.textContent).toContain('100'); + }); +}); diff --git a/apps/web/src/app/features/npc/merchant-page.component.ts b/apps/web/src/app/features/npc/merchant-page.component.ts new file mode 100644 index 0000000..2a23a51 --- /dev/null +++ b/apps/web/src/app/features/npc/merchant-page.component.ts @@ -0,0 +1,82 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LootCapacityStripComponent } from '../../shared/loot-capacity-strip/loot-capacity-strip.component'; +import { WorldStore } from '../world/world.store'; +import { MerchantPanel, MerchantStore } from './merchant.store'; + +/** + * One NPC, presented as a person rather than a form (NPC spec §36). + * + * Portrait, name, title and the line they are currently saying come first; + * trading is something that happens inside that frame. The action bar is + * whatever the server said is available, so a future NPC with no shop simply + * renders one fewer button without a change here. + */ +@Component({ + selector: 'app-merchant-page', + imports: [LootCapacityStripComponent], + templateUrl: './merchant-page.component.html', + styleUrl: './merchant-page.component.scss', +}) +export class MerchantPageComponent implements OnInit { + protected readonly store = inject(MerchantStore); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly worldStore = inject(WorldStore); + + ngOnInit(): void { + // Opening this screen directly -- a bookmark, a refresh -- means the world + // state was never loaded, and the HUD would sit on "Loading character + // data" for as long as the player stayed here. + if (this.worldStore.character() === null) { + void this.worldStore.load(); + } + + const npcKey = this.route.snapshot.paramMap.get('npcKey'); + if (npcKey) { + void this.store.load(npcKey); + } + } + + protected activate(actionType: string, _key: string | null): void { + switch (actionType) { + case 'OPEN_EXCHANGE': + this.store.showPanel('EXCHANGE'); + return; + case 'OPEN_SHOP': + this.store.showPanel('SHOP'); + return; + case 'TALK': + this.store.showPanel('DIALOGUE'); + return; + default: + // VIEW_QUESTS has no screen until Slice 0.9. Ignored rather than + // rendered as a button that does nothing. + return; + } + } + + protected isPanel(panel: MerchantPanel): boolean { + return this.store.panel() === panel; + } + + protected quantityFor(itemKey: string): number { + return this.store.selection()[itemKey] ?? 0; + } + + protected onQuantityInput(itemKey: string, value: string): void { + const parsed = Number.parseInt(value, 10); + this.store.setQuantity(itemKey, Number.isNaN(parsed) ? 0 : parsed); + } + + protected step(itemKey: string, direction: 1 | -1, stepSize: number): void { + this.store.setQuantity( + itemKey, + this.quantityFor(itemKey) + direction * stepSize, + ); + } + + protected leave(): void { + void this.router.navigate(['/location']); + } +} diff --git a/apps/web/src/app/features/npc/merchant.store.spec.ts b/apps/web/src/app/features/npc/merchant.store.spec.ts new file mode 100644 index 0000000..f8e4b8e --- /dev/null +++ b/apps/web/src/app/features/npc/merchant.store.spec.ts @@ -0,0 +1,362 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import type { + ExchangeResult, + ExchangeView, + NpcInteraction, + ShopView, +} from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { MerchantStore } from './merchant.store'; + +function interaction( + actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [ + 'TALK', + 'OPEN_SHOP', + 'OPEN_EXCHANGE', + ], +): NpcInteraction { + return { + npc: { + id: 'npc-1', + key: 'borin-quartermaster', + name: 'Borin', + title: 'Quartermaster of the Border Watch', + description: 'A broad, grey-bearded man.', + portraitPath: '/images/npcs/borin.png', + artworkPath: null, + capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'], + }, + dialogue: { key: 'borin-default', text: 'Show me what you have.', responses: [] }, + availableActions: actionTypes.map((type) => ({ + type, + label: type, + key: type === 'TALK' ? null : 'some-key', + })), + }; +} + +function exchangeView(overrides: Partial = {}): ExchangeView { + return { + profileKey: 'borin-trade-in', + profileName: 'Border Watch Trade-In', + npcKey: 'borin-quartermaster', + offers: [ + { + itemKey: 'ash-pelt', + itemName: 'Ashen Pelt', + iconPath: '/images/items/ash-pelt.png', + quantityCarried: 8, + inputQuantity: 1, + silverPerStep: 5, + reputationPerStep: 2, + factionKey: 'border-guard', + factionName: 'Border Watch', + renownMilestoneKey: 'first-goods-returned', + }, + { + itemKey: 'tough-hide', + itemName: 'Tough Hide', + iconPath: '/images/items/tough-hide.png', + quantityCarried: 7, + inputQuantity: 5, + silverPerStep: 40, + reputationPerStep: 10, + factionKey: 'border-guard', + factionName: 'Border Watch', + renownMilestoneKey: null, + }, + ], + capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }], + ...overrides, + }; +} + +function shopView(): ShopView { + return { + shopKey: 'borin-supplies', + shopName: "Quartermaster's Supplies", + npcKey: 'borin-quartermaster', + silver: 100, + offers: [ + { + itemKey: 'small-healing-potion', + itemName: 'Small Healing Potion', + itemDescription: 'A bitter draught.', + iconPath: '/images/items/potion.png', + currencyType: 'SILVER', + price: 12, + quantity: 1, + unlocked: true, + affordable: true, + }, + ], + }; +} + +function tradeResult(): ExchangeResult { + return { + profileKey: 'borin-trade-in', + consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }], + rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 }, + balances: { silver: 25, regionalReputation: 10, worldRenown: 2 }, + reputationRankChanged: false, + newReputationRank: null, + renownMilestonesCompleted: ['first-goods-returned'], + capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }], + }; +} + +function createApi(overrides: Partial> = {}) { + return { + getNpcInteraction: vi.fn(() => of(interaction())), + getTradeIn: vi.fn(() => of(exchangeView())), + getShop: vi.fn(() => of(shopView())), + tradeIn: vi.fn(() => of(tradeResult())), + getCharacter: vi.fn(() => + of({ + id: 'character-1', + name: 'Aric Duskwalker', + renown: 2, + silver: 25, + currentHp: 119, + maxHp: 119, + }), + ), + purchase: vi.fn(() => + of({ + shopKey: 'borin-supplies', + itemKey: 'small-healing-potion', + itemName: 'Small Healing Potion', + quantity: 1, + silverSpent: 12, + silverBalance: 88, + }), + ), + ...overrides, + }; +} + +function createStore(api: ReturnType): MerchantStore { + TestBed.configureTestingModule({ + providers: [{ provide: GameApiService, useValue: api }], + }); + return TestBed.inject(MerchantStore); +} + +describe('MerchantStore', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('loads the NPC and the panels the server offered', async () => { + const api = createApi(); + const store = createStore(api); + + await store.load('borin-quartermaster'); + + expect(store.interaction()?.npc.name).toBe('Borin'); + expect(store.exchange()?.offers).toHaveLength(2); + expect(store.shop()?.offers).toHaveLength(1); + }); + + it('does not probe an endpoint the NPC did not offer', async () => { + // A person with no shop should not have their shop fetched. The server + // decides which interactions exist. + const api = createApi({ + getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'OPEN_EXCHANGE']))), + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + + expect(api.getShop).not.toHaveBeenCalled(); + expect(store.shop()).toBeNull(); + }); + + it('clamps a selection to what is actually carried', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 999); + + expect(store.selection()['ash-pelt']).toBe(8); + }); + + it('rounds a batch rule down to whole steps', async () => { + // Tough Hide trades five at a time and seven are carried, so five is the + // most that can be handed over -- never seven. + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.setQuantity('tough-hide', 7); + + expect(store.selection()['tough-hide']).toBe(5); + }); + + it('never selects a partial batch', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.setQuantity('tough-hide', 4); + + expect(store.selection()['tough-hide']).toBe(0); + }); + + it('refuses a negative quantity', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', -5); + + expect(store.selection()['ash-pelt']).toBe(0); + }); + + it('previews the payout the selection implies', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 4); + store.setQuantity('tough-hide', 5); + + // 4 pelts at 5 silver, plus one hide batch at 40. + expect(store.preview()).toEqual({ silver: 60, reputation: 18 }); + }); + + it('selects everything tradeable, in whole steps only', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + store.selectAll(); + + expect(store.selection()).toEqual({ 'ash-pelt': 8, 'tough-hide': 5 }); + }); + + it('sends only keys and quantities, then re-reads from the server', async () => { + const api = createApi(); + const store = createStore(api); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 5); + await store.tradeSelected(); + + expect(api.tradeIn).toHaveBeenCalledWith('borin-quartermaster', [ + { itemKey: 'ash-pelt', quantity: 5 }, + ]); + // Carried goods, capacity and Silver all moved at once, so the view is + // re-fetched rather than patched locally. + expect(api.getTradeIn).toHaveBeenCalledTimes(2); + expect(store.lastTrade()?.rewards.silver).toBe(25); + expect(store.selection()).toEqual({}); + expect(store.actionError()).toBeNull(); + }); + + it('pushes the new Silver back to the shared character state', async () => { + // The purse in the top bar reads from `WorldStore`. Without this the + // player sells four pelts and watches their Silver stay put. + const api = createApi(); + const store = createStore(api); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 5); + await store.tradeSelected(); + + expect(api.getCharacter).toHaveBeenCalled(); + }); + + it('will not trade with nothing selected', async () => { + const api = createApi(); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.tradeSelected(); + + expect(api.tradeIn).not.toHaveBeenCalled(); + }); + + it('surfaces a rejected trade as a readable message and keeps the selection', async () => { + const api = createApi({ + tradeIn: vi.fn(() => + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { code: 'EXCHANGE_INSUFFICIENT_QUANTITY' }, + }), + ), + ), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 5); + await store.tradeSelected(); + + expect(store.actionError()).toBe('You are not carrying that many.'); + expect(store.lastTrade()).toBeNull(); + expect(store.selection()['ash-pelt']).toBe(5); + }); + + it('falls back to a generic message rather than leaking an unknown code', async () => { + const api = createApi({ + tradeIn: vi.fn(() => + throwError( + () => + new HttpErrorResponse({ status: 500, error: { code: 'WAT' } }), + ), + ), + }); + const store = createStore(api); + await store.load('borin-quartermaster'); + + store.setQuantity('ash-pelt', 1); + await store.tradeSelected(); + + expect(store.actionError()).toBe("That isn't possible right now."); + }); + + it('reports being unable to reach the NPC', async () => { + const api = createApi({ + getNpcInteraction: vi.fn(() => + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { code: 'NPC_UNAVAILABLE' }, + }), + ), + ), + }); + const store = createStore(api); + + await store.load('borin-quartermaster'); + + expect(store.error()).toBe('You are not where this person is.'); + expect(store.interaction()).toBeNull(); + }); + + it('refreshes the shop after buying, so the purse cannot go stale', async () => { + const api = createApi(); + const store = createStore(api); + await store.load('borin-quartermaster'); + + await store.buy('small-healing-potion'); + + expect(api.purchase).toHaveBeenCalledWith( + 'borin-quartermaster', + 'small-healing-potion', + 1, + ); + expect(api.getShop).toHaveBeenCalledTimes(2); + expect(store.lastPurchase()?.silverSpent).toBe(12); + }); + + it('starts on the dialogue panel and switches on request', async () => { + const store = createStore(createApi()); + await store.load('borin-quartermaster'); + + expect(store.panel()).toBe('DIALOGUE'); + store.showPanel('EXCHANGE'); + expect(store.panel()).toBe('EXCHANGE'); + }); +}); diff --git a/apps/web/src/app/features/npc/merchant.store.ts b/apps/web/src/app/features/npc/merchant.store.ts new file mode 100644 index 0000000..ee3c2e3 --- /dev/null +++ b/apps/web/src/app/features/npc/merchant.store.ts @@ -0,0 +1,264 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + ExchangeResult, + ExchangeView, + NpcInteraction, + ShopPurchaseResult, + ShopView, +} from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { WorldStore } from '../world/world.store'; + +const GENERIC_ERROR = "That isn't possible right now."; + +// Mirrors the codes in the API's npc/exchange/shop error files. Anything not +// listed falls back to the generic line rather than leaking a raw code. +const ERROR_MESSAGES: Readonly> = { + NPC_NOT_FOUND: 'This person could not be found.', + NPC_UNAVAILABLE: 'You are not where this person is.', + EXCHANGE_NOT_FOUND: 'This merchant does not trade in goods.', + EXCHANGE_DISABLED: 'This merchant is not trading right now.', + EXCHANGE_ITEM_NOT_ACCEPTED: 'This merchant does not accept that.', + EXCHANGE_INVALID_QUANTITY: 'That quantity cannot be traded.', + EXCHANGE_INSUFFICIENT_QUANTITY: 'You are not carrying that many.', + EXCHANGE_EMPTY_REQUEST: 'Select at least one item to trade.', + SHOP_NOT_FOUND: 'This merchant has nothing to sell.', + SHOP_DISABLED: 'This shop is closed.', + SHOP_OFFER_NOT_FOUND: 'This merchant does not stock that.', + SHOP_OFFER_LOCKED: 'You have not earned the right to buy this yet.', + SHOP_INSUFFICIENT_SILVER: 'You cannot afford that.', + SHOP_INVALID_QUANTITY: 'That quantity cannot be bought.', + CHARACTER_NOT_FOUND: 'Your character could not be found.', +}; + +export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; + +/** + * State for one merchant screen (Playable Slice 0.8). + * + * Holds the trade-in selection, which is the only genuinely local state here: + * everything about what a good is worth, and whether an offer is open, comes + * from the server on every load. The store never computes a reward — it shows + * the preview the offers imply and lets the server decide the real payout. + */ +@Injectable({ providedIn: 'root' }) +export class MerchantStore { + private readonly api = inject(GameApiService); + private readonly worldStore = inject(WorldStore); + + private readonly interactionState = signal(null); + private readonly exchangeState = signal(null); + private readonly shopState = signal(null); + private readonly panelState = signal('DIALOGUE'); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + private readonly actionErrorState = signal(null); + private readonly pendingState = signal(null); + private readonly lastTradeState = signal(null); + private readonly lastPurchaseState = signal(null); + private readonly selectionState = signal>({}); + + readonly interaction = this.interactionState.asReadonly(); + readonly exchange = this.exchangeState.asReadonly(); + readonly shop = this.shopState.asReadonly(); + readonly panel = this.panelState.asReadonly(); + readonly loading = this.loadingState.asReadonly(); + readonly error = this.errorState.asReadonly(); + readonly actionError = this.actionErrorState.asReadonly(); + readonly pending = this.pendingState.asReadonly(); + readonly lastTrade = this.lastTradeState.asReadonly(); + readonly lastPurchase = this.lastPurchaseState.asReadonly(); + readonly selection = this.selectionState.asReadonly(); + + /** True once anything is selected, so the trade button can enable. */ + readonly hasSelection = computed(() => + Object.values(this.selectionState()).some((quantity) => quantity > 0), + ); + + /** + * What the current selection is expected to pay. + * + * A preview, not an authority: the server recomputes it from the same rules + * when the trade is submitted (slice §10 asks for a reward preview). + */ + readonly preview = computed(() => { + const offers = this.exchangeState()?.offers ?? []; + const selection = this.selectionState(); + + return offers.reduce( + (total, offer) => { + const quantity = selection[offer.itemKey] ?? 0; + if (quantity <= 0) { + return total; + } + const steps = Math.floor(quantity / offer.inputQuantity); + return { + silver: total.silver + steps * offer.silverPerStep, + reputation: total.reputation + steps * offer.reputationPerStep, + }; + }, + { silver: 0, reputation: 0 }, + ); + }); + + async load(npcKey: string): Promise { + this.loadingState.set(true); + this.errorState.set(null); + this.actionErrorState.set(null); + this.lastTradeState.set(null); + this.lastPurchaseState.set(null); + this.selectionState.set({}); + this.panelState.set('DIALOGUE'); + + try { + const interaction = await firstValueFrom( + this.api.getNpcInteraction(npcKey), + ); + this.interactionState.set(interaction); + + // Only fetch the panels this NPC actually offers. The server decides + // which actions exist, so the client never probes an endpoint it was + // not offered. + const actions = interaction.availableActions.map((action) => action.type); + this.exchangeState.set( + actions.includes('OPEN_EXCHANGE') + ? await firstValueFrom(this.api.getTradeIn(npcKey)) + : null, + ); + this.shopState.set( + actions.includes('OPEN_SHOP') + ? await firstValueFrom(this.api.getShop(npcKey)) + : null, + ); + } catch (error) { + this.interactionState.set(null); + this.exchangeState.set(null); + this.shopState.set(null); + this.errorState.set(this.toMessage(error)); + } finally { + this.loadingState.set(false); + } + } + + showPanel(panel: MerchantPanel): void { + this.panelState.set(panel); + this.actionErrorState.set(null); + } + + /** Clamps to what is carried, so the UI cannot offer an impossible trade. */ + setQuantity(itemKey: string, quantity: number): void { + const offer = this.exchangeState()?.offers.find( + (candidate) => candidate.itemKey === itemKey, + ); + if (!offer) { + return; + } + + // Rounded down to whole tradeable steps: a batch rule that trades five at + // a time must not let four be selected. + const capped = Math.max(0, Math.min(quantity, offer.quantityCarried)); + const steps = Math.floor(capped / offer.inputQuantity); + + this.selectionState.update((current) => ({ + ...current, + [itemKey]: steps * offer.inputQuantity, + })); + } + + selectAll(): void { + const offers = this.exchangeState()?.offers ?? []; + const selection: Record = {}; + + for (const offer of offers) { + const steps = Math.floor(offer.quantityCarried / offer.inputQuantity); + if (steps > 0) { + selection[offer.itemKey] = steps * offer.inputQuantity; + } + } + + this.selectionState.set(selection); + } + + clearSelection(): void { + this.selectionState.set({}); + } + + async tradeSelected(): Promise { + const npcKey = this.interactionState()?.npc.key; + if (!npcKey || this.pendingState() !== null || !this.hasSelection()) { + return; + } + + const items = Object.entries(this.selectionState()) + .filter(([, quantity]) => quantity > 0) + .map(([itemKey, quantity]) => ({ itemKey, quantity })); + + this.pendingState.set('trade'); + this.actionErrorState.set(null); + + try { + const result = await firstValueFrom(this.api.tradeIn(npcKey, items)); + this.lastTradeState.set(result); + this.selectionState.set({}); + + // Re-read rather than patching locally: the trade changed carried goods, + // capacity, Silver and possibly renown at once, and the server is the + // only place that knows all of it. + this.exchangeState.set(await firstValueFrom(this.api.getTradeIn(npcKey))); + this.shopState.set( + this.shopState() ? await firstValueFrom(this.api.getShop(npcKey)) : null, + ); + + // The purse in the top bar comes from the shared character state, so a + // trade that is not pushed back there leaves the player looking at the + // Silver they had before selling. + await this.worldStore.refreshCharacter(); + } catch (error) { + this.actionErrorState.set(this.toMessage(error)); + } finally { + this.pendingState.set(null); + } + } + + async buy(itemKey: string): Promise { + const npcKey = this.interactionState()?.npc.key; + if (!npcKey || this.pendingState() !== null) { + return; + } + + this.pendingState.set(itemKey); + this.actionErrorState.set(null); + + try { + this.lastPurchaseState.set( + await firstValueFrom(this.api.purchase(npcKey, itemKey, 1)), + ); + this.shopState.set(await firstValueFrom(this.api.getShop(npcKey))); + await this.worldStore.refreshCharacter(); + } catch (error) { + this.actionErrorState.set(this.toMessage(error)); + } finally { + this.pendingState.set(null); + } + } + + dismissTradeSummary(): void { + this.lastTradeState.set(null); + } + + dismissPurchase(): void { + this.lastPurchaseState.set(null); + } + + private toMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + if (code && ERROR_MESSAGES[code]) { + return ERROR_MESSAGES[code]; + } + } + return GENERIC_ERROR; + } +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.ts b/apps/web/src/app/features/world/location-page/location-page.component.ts index 6a0eaa9..0c437ab 100644 --- a/apps/web/src/app/features/world/location-page/location-page.component.ts +++ b/apps/web/src/app/features/world/location-page/location-page.component.ts @@ -50,11 +50,11 @@ export class LocationPageComponent implements OnInit { } protected activatePoi(poi: LocationPointOfInterest): void { - this.dispatch(poi.type, poi.key); + this.dispatch(poi.type, poi.key, poi.npcKey); } protected activateAction(action: LocationPrimaryAction): void { - this.dispatch(action.type, action.poiKey ?? action.key); + this.dispatch(action.type, action.poiKey ?? action.key, action.npcKey); } protected runtimeArtwork(artworkPath: string): string | undefined { @@ -75,7 +75,11 @@ export class LocationPageComponent implements OnInit { * for the screen that owns them; every other type asks the server what * happened. Unimplemented types are ignored rather than faked. */ - private dispatch(type: LocationInteractionType, interactionKey: string): void { + private dispatch( + type: LocationInteractionType, + interactionKey: string, + npcKey?: string, + ): void { switch (type) { case 'HUNT': void this.router.navigate(['/hunt']); @@ -87,8 +91,20 @@ export class LocationPageComponent implements OnInit { case 'INVESTIGATE': case 'SEARCH': case 'NPC': + // A hotspot that names an NPC opens that person's screen; one that + // does not is scenery with a line of authored text behind it, and + // still goes through the interaction endpoint. + if (npcKey) { + void this.router.navigate(['/npc', npcKey]); + return; + } void this.store.runInteraction(interactionKey); return; + case 'SHOP': + if (npcKey) { + void this.router.navigate(['/npc', npcKey]); + } + return; default: return; } diff --git a/apps/web/src/app/shared/item-card/item-card.component.spec.ts b/apps/web/src/app/shared/item-card/item-card.component.spec.ts index b2a367a..2c53c47 100644 --- a/apps/web/src/app/shared/item-card/item-card.component.spec.ts +++ b/apps/web/src/app/shared/item-card/item-card.component.spec.ts @@ -5,6 +5,8 @@ import { ItemCardComponent } from './item-card.component'; const banditBlade: RewardItemSummary = { key: 'bandit-blade', name: 'Bandit Blade', + type: 'EQUIPMENT', + lootCategory: null, rarity: 'COMMON', iconPath: '/images/items/bandit-blade.png', }; diff --git a/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.html b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.html new file mode 100644 index 0000000..f582979 --- /dev/null +++ b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.html @@ -0,0 +1,16 @@ +
+ @for (row of rows(); track row.category) { +

+ {{ row.label }} + {{ row.current }} / {{ row.capacity }} + @if (row.full) { + Full + } +

+ } +
diff --git a/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.scss b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.scss new file mode 100644 index 0000000..c271e4f --- /dev/null +++ b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.scss @@ -0,0 +1,45 @@ +:host { + display: block; +} + +.capacity-strip { + display: flex; + flex-wrap: wrap; + gap: var(--ar-space-4); + align-items: center; +} + +.capacity { + display: inline-flex; + gap: 0.45rem; + align-items: baseline; + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.06em; +} + +.capacity__label { + text-transform: uppercase; +} + +.capacity__count { + color: var(--ar-text); + font-family: Georgia, 'Times New Roman', serif; + font-variant-numeric: tabular-nums; +} + +/* A full category has to stop the player before the next farm cycle + (spec §12), so it reads as a warning rather than another grey number. */ +.capacity--full .capacity__count { + color: var(--ar-danger); +} + +.capacity__flag { + padding: 0.05em 0.4em; + border: 1px solid rgb(158 46 42 / 0.75); + border-radius: 0.15rem; + color: #d8736c; + font-size: 0.85em; + text-transform: uppercase; +} diff --git a/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.spec.ts b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.spec.ts new file mode 100644 index 0000000..d164fea --- /dev/null +++ b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import type { LootCapacity } from '../../core/api/game-api.models'; +import { LootCapacityStripComponent } from './loot-capacity-strip.component'; + +function render(capacities: LootCapacity[]): HTMLElement { + const fixture = TestBed.createComponent(LootCapacityStripComponent); + fixture.componentRef.setInput('capacities', capacities); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; +} + +describe('LootCapacityStripComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LootCapacityStripComponent], + }).compileComponents(); + }); + + it('shows a readable count per category (spec §12)', () => { + const element = render([ + { category: 'HIDE', current: 4, capacity: 5, bag: null }, + { category: 'RAIDER_TROPHY', current: 1, capacity: 1, bag: null }, + ]); + + expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain( + 'Hides', + ); + expect(element.querySelector('[data-loot-capacity="HIDE"]')?.textContent).toContain( + '4 / 5', + ); + expect( + element.querySelector('[data-loot-capacity="RAIDER_TROPHY"]')?.textContent, + ).toContain('Raider Trophies'); + }); + + it('marks a full category, so the player sees it before the next farm cycle', () => { + const element = render([ + { category: 'HIDE', current: 5, capacity: 5, bag: null }, + { category: 'RAIDER_TROPHY', current: 0, capacity: 5, bag: null }, + ]); + + const hides = element.querySelector('[data-loot-capacity="HIDE"]'); + const trophies = element.querySelector('[data-loot-capacity="RAIDER_TROPHY"]'); + + expect(hides?.classList).toContain('capacity--full'); + expect(hides?.querySelector('[data-loot-capacity-full]')?.textContent).toContain( + 'Full', + ); + expect(trophies?.classList).not.toContain('capacity--full'); + expect(trophies?.querySelector('[data-loot-capacity-full]')).toBeNull(); + }); + + it('treats carrying more than capacity as full, not as room to spare', () => { + // A bag unstowed, or a definition retuned downward, leaves the character + // over the line. That has to read as full rather than as "5 / 3 is fine". + const element = render([{ category: 'HIDE', current: 5, capacity: 3, bag: null }]); + + expect( + element.querySelector('[data-loot-capacity="HIDE"]')?.classList, + ).toContain('capacity--full'); + }); + + it('names the active bag behind a raised capacity', () => { + const element = render([ + { + category: 'HIDE', + current: 0, + capacity: 5, + bag: { + key: 'basic-hide-bag', + name: 'Basic Hide Bag', + iconPath: '/images/items/basic-hide-bag.png', + }, + }, + ]); + + expect( + element.querySelector('[data-loot-capacity="HIDE"]')?.getAttribute('title'), + ).toBe('Basic Hide Bag'); + }); + + it('explains the bagless default rather than leaving it unlabelled', () => { + const element = render([{ category: 'HIDE', current: 0, capacity: 1, bag: null }]); + + expect( + element.querySelector('[data-loot-capacity="HIDE"]')?.getAttribute('title'), + ).toBe('No bag — carrying by hand'); + }); +}); diff --git a/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.ts b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.ts new file mode 100644 index 0000000..feebfd8 --- /dev/null +++ b/apps/web/src/app/shared/loot-capacity-strip/loot-capacity-strip.component.ts @@ -0,0 +1,48 @@ +import { Component, computed, input } from '@angular/core'; +import type { LootCapacity, LootCategory } from '../../core/api/game-api.models'; + +/** Plural names, because the strip counts things (spec §12). */ +const CATEGORY_LABELS: Readonly> = { + HIDE: 'Hides', + RAIDER_TROPHY: 'Raider Trophies', +}; + +interface CapacityRow { + category: LootCategory; + label: string; + current: number; + capacity: number; + full: boolean; + bagName: string | null; +} + +/** + * What the character can still carry, per loot category (spec §12). + * + * Deliberately a thin strip rather than a screen: the spec asks for the state + * to be visible while hunting, not for an inventory-management view. Shared by + * the hunt page and the victory summary so both read identically. + */ +@Component({ + selector: 'app-loot-capacity-strip', + templateUrl: './loot-capacity-strip.component.html', + styleUrl: './loot-capacity-strip.component.scss', +}) +export class LootCapacityStripComponent { + readonly capacities = input.required(); + + protected readonly rows = computed(() => + this.capacities().map((entry) => ({ + category: entry.category, + label: CATEGORY_LABELS[entry.category] ?? entry.category, + current: entry.current, + capacity: entry.capacity, + // `>=` rather than `===`: a character carrying more than capacity (a bag + // unstowed, a definition retuned) is full, not merely at the limit. + full: entry.current >= entry.capacity, + bagName: entry.bag?.name ?? null, + })), + ); + + protected readonly anyFull = computed(() => this.rows().some((row) => row.full)); +} diff --git a/docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md b/docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md index 0686678..c7ea8a0 100644 --- a/docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md +++ b/docs/Ashen Realms – Loot Bags & Monster Loot Categories Specification V1.md @@ -151,6 +151,22 @@ Alle Materialien oder Handelswaren, die das Taschensystem verwenden, besitzen ei V1 verwendet zunächst wenige breite Kategorien. +> **IMPLEMENTIERUNGSSTAND (Playable Slice 0.7.5).** +> Implementiert sind bisher nur `HIDE` und `RAIDER_TROPHY`. Die Slice-Spezifikation +> `docs/playable-slices/0.7.5-Monster-Categories-and-Loot-Bags.md` §3/§4 ist hier die +> neuere, verbindliche Quelle und benennt die Kategorie für Plündererabzeichen +> ausdrücklich `RAIDER_TROPHY` — nicht `TROPHY` und nicht `HUMANOID_SPOILS`. +> +> Damit weicht der implementierte Satz bewusst von der Liste unten ab: +> `TROPHY`, `HUMANOID_SPOILS` und `COIN` existieren im Code noch nicht. Sie sind +> nicht verworfen, sondern warten auf Content, der sie tatsächlich braucht +> (§5 dieses Dokuments). Beim Hinzufügen bitte klären, ob `RAIDER_TROPHY` in +> `HUMANOID_SPOILS` aufgeht oder danebensteht — das ist eine offene Design-Frage, +> keine Implementierungslücke. +> +> Gleiches gilt für Monster Categories (§6): implementiert sind `BEAST` und +> `HUMANOID`, nicht `UNDEAD`/`INSECT`/`ABERRATION`. + ## HIDE Tierhäute und Felle. diff --git a/docs/Ashen_Realms_NPC_System_Specification_V1.md b/docs/Ashen_Realms_NPC_System_Specification_V1.md index a39c2b5..a35caae 100644 --- a/docs/Ashen_Realms_NPC_System_Specification_V1.md +++ b/docs/Ashen_Realms_NPC_System_Specification_V1.md @@ -1331,3 +1331,70 @@ Eine zentrale Condition Engine verbindet: Der zentrale Grundsatz lautet: > **NPCs sind datengetriebene Charaktere mit kombinierbaren Interaktionen – keine voneinander getrennten Spezialklassen.** + +--- + +# 40. Implementierungsstand (Slice 0.8) + +Der V1-Scope aus §34 ist umgesetzt, mit drei bewussten Abweichungen. Sie sind +hier festgehalten statt still aufgelöst zu werden. + +## Umgesetzt + +```text +NpcDefinition apps/api/src/npcs/entities/ +DialogueNode priorisiert, mit Conditions (§11) +CharacterNpcState vorbereitet, genutzt für first-met + Flags (§7, §35) +NpcShop / ShopOffer apps/api/src/shops/ +NpcExchangeProfile / ExchangeRule apps/api/src/exchanges/ +GameConditionService apps/api/src/conditions/ +``` + +Erster NPC: **Borin, Quartermaster** an `south-gate` (Graufurt) mit +DIALOGUE + MERCHANT + RESOURCE_EXCHANGE gleichzeitig — der Kompositionsfall +aus §2/§26 in echt. + +## Abweichung 1 — kein `NpcQuestAssignment` + +§34 listet es im V1-Scope, aber es gibt noch kein Questsystem (Slice 0.9). +Eine Tabelle mit Fremdschlüssel auf eine nicht existierende `quests`-Tabelle +ist nicht baubar, und ein `questKey`-String ohne Validierung wäre spekulative +Architektur (AGENTS §1.7). Nachzuholen mit Slice 0.9, zusammen mit den +Dialog-Actions `START_QUEST` / `COMPLETE_QUEST`. + +## Abweichung 2 — kein `NpcDialogueProfile` + +§10 skizziert `NpcDialogueProfile` als Zwischenebene zwischen NPC und +`DialogueNode`. §34 verlangt dagegen nur "priorisierte DialogNodes + +Conditions", und das Profil hätte in V1 keine eigenen Felder. Nodes hängen +deshalb direkt am NPC. Ein Profil lässt sich später einziehen, ohne die Nodes +neu zu schreiben. + +## Abweichung 3 — Conditions, die (noch) nichts beantworten kann + +`GameConditionType` enthält die vollständige V1-Liste aus §19, aber nur +`REGION_REPUTATION`, `WORLD_RENOWN`, `FLAG_SET` und `HAS_ITEM` sind +auswertbar. `QUEST_ACTIVE`, `QUEST_COMPLETED`, `BOSS_DEFEATED` und +`LOCATION_DISCOVERED` haben noch kein System dahinter. + +Sie werten **fail-closed** aus, also immer "nicht erfüllt". Für ein Gate ist +die sichere Richtung eines Fehlers zu, nicht offen — ein Quest-Gate darf +niemals aufgehen, nur weil es keine Quests gibt. + +## Offene Designfrage — World Renown im Tausch + +Slice 0.8 §4/§9 skizziert `worldRenownPerUnit`. Das ist mit dem +implementierten Renown-System nicht verträglich: Renown ist ein Rang 1–15, +der bei jeder Änderung baseHp/baseAttack aus einer festen Kurve neu setzt +(Slice 0.6.5 §4). Renown pro Fell würde einen Spieler in wenigen Trips ans +Statmaximum bringen. + +`ExchangeRule.renownMilestoneKey` verweist deshalb auf einen +`RenownMilestoneDefinition` — die "batch rule"-Variante aus 0.8 §4, und +deckungsgleich mit 0.6.5 §6, das "first meaningful trophy returned" als +Renown-2-Meilenstein nennt. Nicht wiederholbar: der erste Tausch löst ihn +aus, jeder weitere zahlt weiter Silber und Reputation, ohne den Rang +anzufassen. + +Falls Renown später doch als Punktwährung gedacht ist, muss das zuerst in +0.6.5 geändert werden — nicht hier. diff --git a/docs/playable-slices/0.7-Complete-Burned-Road-V2.md b/docs/playable-slices/0.7-Complete-Burned-Road-V2.md new file mode 100644 index 0000000..ac57ac5 --- /dev/null +++ b/docs/playable-slices/0.7-Complete-Burned-Road-V2.md @@ -0,0 +1,285 @@ +# Ashen Realms – Playable Slice 0.7 V2 + +## Complete Burned Road + +**Status:** Implementation Specification +**Depends on:** Slice 0.6.6 +**Purpose:** Turn the Burned Road into the first complete repeatable hunt location using the new no-XP/no-direct-money progression model. + +--- + +## 1. Goal + +The Burned Road becomes the first place where Ashen Realms feels like a repeatable RPG activity rather than a combat test screen. + +The player should be able to: + +**Travel → Hunt → compare encounters → choose an enemy → fight → receive trade goods/equipment → hunt again.** + +This slice replaces the previous reward assumption of direct XP and Silver from kills. + +> Normal monster kills must not directly grant XP, Silver, regional reputation or World Renown. + +--- + +## 2. Player Experience + +When the player starts a hunt on the Burned Road, 2–3 encounter cards appear. + +Possible enemies: + +- Ash Rat +- Feral Road Hound +- Road Bandit +- rare: Charred Raider + +Each enemy must feel mechanically and economically different. + +The player can deliberately choose an easy target, a useful target, or a risky rare target. + +--- + +## 3. Encounter Pool + +Suggested initial weights: + +| Monster | Weight | Role | +|---|---:|---| +| Ash Rat | 50 | basic safe target | +| Feral Road Hound | 30 | first status-effect enemy | +| Road Bandit | 18 | stronger humanoid with telegraph | +| Charred Raider | 2 | rare dangerous encounter | + +Weights are balancing data and must not be hardcoded into the Angular UI. + +The hunt should return 2–3 distinct encounter records where possible. + +--- + +## 4. Monster Mechanics + +### Ash Rat + +Purpose: pure baseline combat. + +- no special combat mechanic +- low danger +- short fight +- guaranteed basic trade good + +### Feral Road Hound + +Purpose: introduce Bleeding as a simple ongoing effect. + +- normal bite +- occasional Bleeding application +- Bleeding has clear icon/status feedback +- Bleeding duration and damage are server-authoritative + +### Road Bandit + +Purpose: reinforce Telegraphing. + +- normal attack +- Heavy Strike is announced before execution +- Shield Bash can interrupt the prepared attack +- Defend is a valid response if the player does not interrupt + +### Charred Raider + +Purpose: first rare encounter and visible future farming target. + +- noticeably stronger than normal Burned Road enemies +- combines higher base stats with one known mechanic +- no entirely new subsystem +- visually marked as Rare / Strong + +--- + +## 5. Trade Goods + +Every normal enemy should provide a thematic trade good instead of money. + +Initial content: + +| Monster | Guaranteed Trade Good | +|---|---| +| Ash Rat | Ashen Pelt ×1 | +| Feral Road Hound | Tough Hide ×1 | +| Road Bandit | Raider Insignia ×1 | +| Charred Raider | Charred Raider Insignia ×1 | + +These items represent loot that can later be exchanged with a merchant. + +The trade goods must be persisted as actual player-owned loot. + +Slice 0.7.5 will add category-specific carrying capacity and loot bags. Until then, existing inventory storage may temporarily hold these goods. + +--- + +## 6. Equipment Drops + +Trade goods are guaranteed; equipment remains an exciting additional roll. + +Suggested initial drops: + +### Ash Rat + +- small chance for simple starter-slot equipment + +### Feral Road Hound + +- chance for Ashen Boots +- chance for a Small Healing Potion if potions are already lootable + +### Road Bandit + +- Bandit Blade +- Bandit Hood +- Plunderer Gloves + +### Charred Raider + +- Reinforced Leather Jacket +- Ashen Boots +- Mark of the Border Watch +- optional prestige drop later + +Exact drop rates should remain data-driven and can initially follow existing Tier-1 balancing values where already implemented. + +--- + +## 7. Reward Rules + +After a normal victory, the server may grant: + +- trade goods +- equipment +- consumables +- hunt/combat state progression + +The server must **not** grant: + +- XP +- Silver +- regional reputation +- World Renown + +These economy/progression rewards are deliberately deferred to merchant exchange and milestone systems. + +--- + +## 8. Backend Requirements + +The existing content model must support: + +- multiple monsters for one location +- weighted encounter generation +- rare encounter weighting +- guaranteed loot entries +- probabilistic equipment entries +- status-effect combat events +- telegraphed actions + +Recommended behavior: + +```text +POST /api/hunts +→ server validates current location +→ server selects encounter definitions from Burned Road pool +→ persisted HuntEncounter records are returned +``` + +Combat must still start only from a valid generated encounter. + +--- + +## 9. Frontend Requirements + +The Hunt screen must show for each encounter: + +- monster artwork +- English name +- danger rating +- optional short flavor text +- clear Attack action + +The player should immediately recognize the rare Charred Raider. + +After victory, the loot summary should clearly separate: + +- Trade Goods +- Equipment +- Consumables + +Do not show old XP or Silver reward rows. + +--- + +## 10. Hunt Refresh + +The player can choose **Search Again**. + +Rules: + +- generates a new persisted hunt result +- old encounter IDs cannot be attacked indefinitely after being invalidated +- avoid client-side randomization +- no resource cost in this slice + +--- + +## 11. Tests + +At minimum: + +### Hunting + +- Burned Road produces only configured monsters +- rare encounter is selectable through deterministic injected random values +- encounter generation is server-authoritative +- invalid/stale encounter cannot start combat + +### Combat + +- Feral Road Hound can apply Bleeding +- Bleeding ticks correctly +- Road Bandit Heavy Strike is telegraphed +- Shield Bash can interrupt the telegraphed action + +### Loot + +- Ash Rat grants Ashen Pelt +- Road Bandit grants Raider Insignia +- normal kill grants no XP +- normal kill grants no Silver +- normal kill grants no reputation +- equipment drop roll remains independent from guaranteed trade good + +--- + +## 12. Acceptance Criteria + +- [ ] Burned Road offers 2–3 encounter choices per hunt. +- [ ] Four enemy definitions are available, including rare Charred Raider. +- [ ] Feral Road Hound demonstrates Bleeding. +- [ ] Road Bandit demonstrates Telegraphing / interrupt interaction. +- [ ] Every enemy grants a thematic trade good. +- [ ] Equipment can drop in addition to trade goods. +- [ ] Normal kills grant no XP, Silver, regional reputation or World Renown. +- [ ] Loot is persisted server-side. +- [ ] The player can repeatedly hunt without manual database changes. +- [ ] Existing travel and combat flows continue to work. + +--- + +## 13. Out of Scope + +Do not add yet: + +- loot bag capacity +- merchant exchange +- reputation-gated shop offers +- first quest tutorial +- Abandoned Watchpost content +- area boss diff --git a/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md b/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md index 2a79a4b..1e63ad1 100644 --- a/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md +++ b/docs/playable-slices/Ashen Realms – Playable Slice 0.6.5_ Renown & Reputation Foundation.md @@ -700,6 +700,23 @@ Not through special-case controller logic. # 19. Turn-in foundation +> **IMPLEMENTIERUNGSSTAND (Slice 0.8):** `TurnInDefinition` und `TurnInService` +> wurden durch `ExchangeRule` / `ExchangeService` ersetzt (NPC-Spec §17). +> §20 sah das bereits vor ("Implement the domain service now even if the full +> NPC merchant UI comes later") — die NPC-Merchant-UI ist jetzt da, und Borin +> ist der NPC, der den Tausch besitzt. +> +> Übernommen wurde: item → faction → Silber → Reputation, datengetrieben. +> Ergänzt wurden: NPC-/Profil-Zuordnung, Conditions und `renownMilestoneKey` — +> letzteres ist genau das unten als optional gelistete `firstTurnInMilestoneKey`, +> das laut §19 erst hinzugefügt werden sollte, wenn es gebraucht wird. In 0.8 +> wird es gebraucht: der erste Tausch ist der Renown-2-Meilenstein aus §6 +> ("first meaningful trophy returned"). +> +> Die alte Tabelle wurde gelöscht, nicht deaktiviert: zwei parallele Wege, ein +> Fell in Silber zu verwandeln, wären genau das zweite Progressionsmodell, das +> Slice 0.8 §6 ausschließt. + Create a reusable content definition for future trophy/material turn-ins. Conceptually: diff --git a/docs/references/character-screenshot.png b/docs/references/character-screenshot.png new file mode 100644 index 0000000..53caf40 Binary files /dev/null and b/docs/references/character-screenshot.png differ diff --git a/docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md b/docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md index 1eaa045..c4cadb4 100644 --- a/docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md +++ b/docs/superpowers/specs/2026-08-21-renown-reputation-foundation-design.md @@ -56,8 +56,19 @@ Directly implements spec §13's suggested "old Level 1 → Renown 1" mapping, cl Spec §15 frames a direct currency drop as a legitimate *exception* mechanism ("unless the monster explicitly has a lore-valid direct currency drop... an exception rather than the default system"), not something to delete. The cleanest way to keep the mechanism available for a future lore-valid monster while making today's content compliant is: keep the `silverMin`/`silverMax` roll in code (a monster with both set to 0 always rolls 0 — harmless), but delete `experienceReward` and the `character.experience +=` line entirely, since XP has no "legitimate exception" carve-out anywhere in the spec — it is fully abolished (§1, §33). This is the more data-driven choice per §42 (no special-case code to gate the exception; content data alone decides), and required zero seed-monster stat redesign beyond zeroing two columns. *Cost if wrong:* if the "exception" framing turns out unwanted, deleting the roll mechanism later is a small, contained change (one method body, one DTO field). +> **SUPERSEDED by Playable Slice 0.7 V2 (`docs/playable-slices/0.7-Complete-Burned-Road-V2.md` §7).** +> The "exception" framing did turn out unwanted. Slice 0.7 V2 states flatly that a normal +> victory must grant no Silver, and routes all currency through merchant exchange instead +> (slice 0.8). `MonsterDefinition.silverMin`/`silverMax`, `CombatReward.silverGranted`, and +> the silver roll in `CombatRewardService` are all gone as of migration +> `1793000000000-CompleteBurnedRoad`. A future lore-valid direct currency drop would be +> reintroduced deliberately rather than left standing as an unused code path. + **R8 — `CombatReward.experienceGranted` column and `CombatRewardDto.experience` field are both deleted.** Follows directly from R7 — once XP is gone as a concept, persisting a granted-XP audit trail is dead weight. `CombatRewardDto` becomes `{ silver: number; items: CombatRewardItemDto[] }`. + +> **Amended by Playable Slice 0.7 V2:** `CombatRewardDto` is now `{ items: CombatRewardItemDto[] }` — +> the `silver` field went the same way as `experience`, for the same reason. *Cost if wrong:* trivial to re-add a column; no external consumer beyond this same slice's own new code. **R9 — API surface: extend the existing `GET /api/characters/me` response with `renown: number` (replacing `level`/`experience`); add a new `GET /api/reputation` endpoint returning all enabled factions with the character's reputation (defaulting unrepresented factions to 0/Stranger); do not add a separate `GET /api/renown` endpoint.**