diff --git a/apps/api/src/characters/character-stats.service.spec.ts b/apps/api/src/characters/character-stats.service.spec.ts index 45f8417..7528c24 100644 --- a/apps/api/src/characters/character-stats.service.spec.ts +++ b/apps/api/src/characters/character-stats.service.spec.ts @@ -10,7 +10,9 @@ type EquippedFixture = { item: Partial; }; -function fakeScope(equipped: EquippedFixture[]): Pick { +function fakeScope( + equipped: EquippedFixture[], +): Pick { const rows = equipped.map((entry) => ({ slot: entry.slot, characterItem: { @@ -25,7 +27,7 @@ function fakeScope(equipped: EquippedFixture[]): Pick ({ find: async () => rows }) as never, - } as unknown as Pick; + }; } function character(overrides: Partial = {}): Character { @@ -42,7 +44,9 @@ describe('CharacterStatsService', () => { const service = new CharacterStatsService({} as DataSource); it('derives stats from the starting weapon alone', async () => { - const scope = fakeScope([{ slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }]); + const scope = fakeScope([ + { slot: EquipmentSlot.WEAPON, item: { weaponDamage: 8 } }, + ]); const stats = await service.calculate(character(), scope); @@ -52,9 +56,12 @@ describe('CharacterStatsService', () => { expect(stats.armor).toBe(0); }); - it('applies Räuberklinge\'s weapon damage and bonus attack', async () => { + it("applies Räuberklinge's weapon damage and bonus attack", async () => { const scope = fakeScope([ - { slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, + { + slot: EquipmentSlot.WEAPON, + item: { weaponDamage: 11, bonusAttack: 1 }, + }, ]); const stats = await service.calculate(character(), scope); @@ -86,7 +93,9 @@ describe('CharacterStatsService', () => { }); it('reports weaponDamage as 0 when no weapon is equipped', async () => { - const scope = fakeScope([{ slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }]); + const scope = fakeScope([ + { slot: EquipmentSlot.HEAD, item: { bonusArmor: 3 } }, + ]); const stats = await service.calculate(character(), scope); @@ -95,7 +104,10 @@ describe('CharacterStatsService', () => { it('calculates Combat Power as HP/10 + attack*2 + weaponDamage*2 + armor*1.5', async () => { const scope = fakeScope([ - { slot: EquipmentSlot.WEAPON, item: { weaponDamage: 11, bonusAttack: 1 } }, + { + slot: EquipmentSlot.WEAPON, + item: { weaponDamage: 11, bonusAttack: 1 }, + }, { slot: EquipmentSlot.HEAD, item: { bonusArmor: 3, bonusHp: 5 } }, ]); diff --git a/apps/api/src/combat/combat-damage.spec.ts b/apps/api/src/combat/combat-damage.spec.ts index 122f493..311da28 100644 --- a/apps/api/src/combat/combat-damage.spec.ts +++ b/apps/api/src/combat/combat-damage.spec.ts @@ -21,6 +21,8 @@ describe('calculateDamage', () => { }); it('still floors at 1 damage even with a small multiplier', () => { - expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1); + expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe( + 1, + ); }); }); diff --git a/apps/api/src/combat/combat-damage.ts b/apps/api/src/combat/combat-damage.ts index 0f6a6f6..16a6fad 100644 --- a/apps/api/src/combat/combat-damage.ts +++ b/apps/api/src/combat/combat-damage.ts @@ -12,6 +12,7 @@ export function calculateDamage( ): number { const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0); const mitigatedDamage = - (rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor); + (rawDamage * ARMOR_MITIGATION_CONSTANT) / + (ARMOR_MITIGATION_CONSTANT + targetArmor); return Math.max(1, Math.round(mitigatedDamage * multiplier)); } diff --git a/apps/api/src/combat/combat-engine.service.spec.ts b/apps/api/src/combat/combat-engine.service.spec.ts index a33fcc0..b23e08e 100644 --- a/apps/api/src/combat/combat-engine.service.spec.ts +++ b/apps/api/src/combat/combat-engine.service.spec.ts @@ -1,11 +1,16 @@ import { CombatAction } from './combat-action.enum'; -import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service'; +import { + CombatEngineService, + UnsupportedCombatActionError, +} from './combat-engine.service'; import { CombatEngineState } from './combat-engine.types'; import { CombatEventType } from './combat-event-type.enum'; import { CombatStatus } from './combat-status.enum'; import { Combatant } from './combatant.enum'; -function baseState(overrides: Partial = {}): CombatEngineState { +function baseState( + overrides: Partial = {}, +): CombatEngineState { return { status: CombatStatus.ACTIVE, round: 1, @@ -31,7 +36,9 @@ describe('CombatEngineService', () => { }); it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => { - const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); + const result = engine.resolveAction(baseState(), { + action: CombatAction.ATTACK, + }); // raw = 6 + 8 = 14; armor 0 -> 14 mitigated expect(result.state.monster.currentHp).toBe(45 - 14); @@ -44,7 +51,9 @@ describe('CombatEngineService', () => { }); it('lets the monster retaliate when it survives the player attack, and advances the round', () => { - const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK }); + const result = engine.resolveAction(baseState(), { + action: CombatAction.ATTACK, + }); // raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5 expect(result.state.player.currentHp).toBe(100 - 5); @@ -69,14 +78,27 @@ describe('CombatEngineService', () => { expect(result.state.status).toBe(CombatStatus.WON); expect(result.state.round).toBe(1); expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 14, + }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.COMBAT_WON, + }, ]); }); it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => { const state = baseState({ - player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } }, + player: { + currentHp: 3, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6 }, + }, }); const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); @@ -84,9 +106,23 @@ describe('CombatEngineService', () => { expect(result.state.player.currentHp).toBe(0); expect(result.state.status).toBe(CombatStatus.LOST); expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, - { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 }, - { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 14, + }, + { + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 5, + }, + { + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.COMBAT_LOST, + }, ]); }); @@ -106,7 +142,9 @@ describe('CombatEngineService', () => { }); it('HEAVY_STRIKE deals 160% damage to the monster', () => { - const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE }); + const result = engine.resolveAction(baseState(), { + action: CombatAction.HEAVY_STRIKE, + }); // raw = 14; 14 * 1.6 = 22.4 -> rounds to 22 expect(result.events[0]).toEqual({ @@ -119,7 +157,9 @@ describe('CombatEngineService', () => { }); it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => { - const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH }); + const result = engine.resolveAction(baseState(), { + action: CombatAction.SHIELD_BASH, + }); // raw = 14; 14 * 0.7 = 9.8 -> rounds to 10 expect(result.events[0]).toEqual({ @@ -128,7 +168,9 @@ describe('CombatEngineService', () => { type: CombatEventType.DAMAGE, amount: 10, }); - expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false); + expect( + result.events.some((event) => event.type === CombatEventType.INTERRUPT), + ).toBe(false); }); it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => { @@ -140,11 +182,22 @@ describe('CombatEngineService', () => { }, }); - const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH }); + const result = engine.resolveAction(state, { + action: CombatAction.SHIELD_BASH, + }); expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 }, - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 10, + }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }, ]); expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.player.currentHp).toBe(100); @@ -152,13 +205,24 @@ describe('CombatEngineService', () => { }); it('DEFEND deals no damage and halves the monster normal attack this round', () => { - const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND }); + const result = engine.resolveAction(baseState(), { + action: CombatAction.DEFEND, + }); expect(result.state.monster.currentHp).toBe(45); // raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2 expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, - { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 }, + { + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.DEFEND, + }, + { + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 2, + }, ]); expect(result.state.player.currentHp).toBe(98); }); @@ -215,10 +279,14 @@ 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 telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK }); + const telegraphResult = engine.resolveAction(round3State, { + action: CombatAction.ATTACK, + }); expect(telegraphResult.state.player.currentHp).toBe(100); - expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); + expect(telegraphResult.state.monster.stats.pendingAction).toBe( + 'HEAVY_ATTACK', + ); expect(telegraphResult.events[1]).toEqual({ source: Combatant.MONSTER, target: Combatant.PLAYER, @@ -226,7 +294,9 @@ describe('CombatEngineService', () => { }); expect(telegraphResult.state.round).toBe(4); - const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK }); + const resolveResult = engine.resolveAction(telegraphResult.state, { + action: CombatAction.ATTACK, + }); // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7 expect(resolveResult.events[1]).toEqual({ @@ -254,16 +324,29 @@ describe('CombatEngineService', () => { expect(result.state.player.currentHp).toBe(100); expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, - { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 14, + }, + { + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.COMBAT_WON, + }, ]); }); it('is deterministic for HEAVY_STRIKE as well', () => { const state = baseState(); - const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); - const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); + const first = engine.resolveAction(state, { + action: CombatAction.HEAVY_STRIKE, + }); + const second = engine.resolveAction(state, { + action: CombatAction.HEAVY_STRIKE, + }); expect(first).toEqual(second); }); @@ -281,8 +364,17 @@ describe('CombatEngineService', () => { // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27; * defend 0.5 = 3.636 -> rounds to 4 expect(result.events).toEqual([ - { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, - { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 4 }, + { + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.DEFEND, + }, + { + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 4, + }, ]); expect(result.state.monster.stats.pendingAction).toBeUndefined(); expect(result.state.player.currentHp).toBe(100 - 4); diff --git a/apps/api/src/combat/combat-engine.service.ts b/apps/api/src/combat/combat-engine.service.ts index ced7621..76a1117 100644 --- a/apps/api/src/combat/combat-engine.service.ts +++ b/apps/api/src/combat/combat-engine.service.ts @@ -30,7 +30,10 @@ const POTION_HEAL_FRACTION = 0.35; @Injectable() export class CombatEngineService { - resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult { + resolveAction( + state: CombatEngineState, + input: CombatActionInput, + ): CombatEngineResult { switch (input.action) { case CombatAction.ATTACK: return this.resolvePlayerStrike(state, 1); @@ -47,12 +50,19 @@ export class CombatEngineService { } } - private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult { + private resolvePlayerStrike( + state: CombatEngineState, + multiplier: number, + ): CombatEngineResult { const player = this.cloneCombatant(state.player); const monster = this.cloneCombatant(state.monster); const events: CombatEngineEvent[] = []; - const damage = calculateDamage(player.stats, monster.stats.armor, multiplier); + const damage = calculateDamage( + player.stats, + monster.stats.armor, + multiplier, + ); monster.currentHp = Math.max(0, monster.currentHp - damage); events.push({ source: Combatant.PLAYER, @@ -69,7 +79,11 @@ export class CombatEngineService { const monster = this.cloneCombatant(state.monster); const events: CombatEngineEvent[] = []; - const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER); + const damage = calculateDamage( + player.stats, + monster.stats.armor, + SHIELD_BASH_MULTIPLIER, + ); monster.currentHp = Math.max(0, monster.currentHp - damage); events.push({ source: Combatant.PLAYER, @@ -82,7 +96,11 @@ export class CombatEngineService { if (monster.stats.pendingAction) { monster.stats.pendingAction = undefined; interrupted = true; - events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }); + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.INTERRUPT, + }); } return this.finishRound(state, player, monster, events, false, interrupted); @@ -92,7 +110,11 @@ export class CombatEngineService { const player = this.cloneCombatant(state.player); const monster = this.cloneCombatant(state.monster); const events: CombatEngineEvent[] = [ - { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, + { + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.DEFEND, + }, ]; return this.finishRound(state, player, monster, events, true); @@ -108,7 +130,12 @@ export class CombatEngineService { player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1; const events: CombatEngineEvent[] = [ - { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed }, + { + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.HEAL, + amount: healed, + }, ]; return this.finishRound(state, player, monster, events, false); @@ -123,22 +150,50 @@ export class CombatEngineService { interrupted = false, ): CombatEngineResult { if (monster.currentHp <= 0) { - events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }); - const defeatedMonster = { ...monster, stats: { ...monster.stats, pendingAction: undefined } }; - return { state: { ...state, player, monster: defeatedMonster, status: CombatStatus.WON }, events }; + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.COMBAT_WON, + }); + const defeatedMonster = { + ...monster, + stats: { ...monster.stats, pendingAction: undefined }, + }; + return { + state: { + ...state, + player, + monster: defeatedMonster, + status: CombatStatus.WON, + }, + events, + }; } 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 }; + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.COMBAT_LOST, + }); + return { + state: { ...state, player, monster, status: CombatStatus.LOST }, + events, + }; } } return { - state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 }, + state: { + ...state, + player, + monster, + status: CombatStatus.ACTIVE, + round: state.round + 1, + }, events, }; } @@ -154,7 +209,11 @@ export class CombatEngineService { if (monster.stats.pendingAction === 'HEAVY_ATTACK') { monster.stats.pendingAction = undefined; - const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier); + const damage = calculateDamage( + monster.stats, + player.stats.armor, + HEAVY_ATTACK_MULTIPLIER * defendMultiplier, + ); player.currentHp = Math.max(0, player.currentHp - damage); events.push({ source: Combatant.MONSTER, @@ -167,11 +226,19 @@ export class CombatEngineService { if (round % TELEGRAPH_ROUND_INTERVAL === 0) { monster.stats.pendingAction = 'HEAVY_ATTACK'; - events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH }); + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.TELEGRAPH, + }); return; } - const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier); + const damage = calculateDamage( + monster.stats, + player.stats.armor, + defendMultiplier, + ); player.currentHp = Math.max(0, player.currentHp - damage); events.push({ source: Combatant.MONSTER, @@ -181,7 +248,9 @@ export class CombatEngineService { }); } - private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant { + private cloneCombatant( + combatant: CombatEngineCombatant, + ): CombatEngineCombatant { return { ...combatant, stats: { ...combatant.stats } }; } } diff --git a/apps/api/src/combat/combat-equipment-integration.spec.ts b/apps/api/src/combat/combat-equipment-integration.spec.ts index 7e6710d..a007947 100644 --- a/apps/api/src/combat/combat-equipment-integration.spec.ts +++ b/apps/api/src/combat/combat-equipment-integration.spec.ts @@ -53,12 +53,18 @@ class FakeRepository { relations?: Record; lock?: { mode: string }; }): Promise { - const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; - return Promise.resolve(row ? this.withRelations(row, options.relations) : null); + const row = + this.rows().find((candidate) => this.matches(candidate, options.where)) ?? + null; + return Promise.resolve( + row ? this.withRelations(row, options.relations) : null, + ); } findOneBy(where: Partial): Promise { - return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + return Promise.resolve( + this.rows().find((row) => this.matches(row, where)) ?? null, + ); } find(options: { @@ -66,12 +72,18 @@ class FakeRepository { relations?: Record; order?: Partial>; }): Promise { - const matched = this.rows().filter((row) => this.matches(row, options.where)); - return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); + const matched = this.rows().filter((row) => + this.matches(row, options.where), + ); + return Promise.resolve( + matched.map((row) => this.withRelations(row, options.relations)), + ); } count(options: { where: Partial }): Promise { - return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length); + return Promise.resolve( + this.rows().filter((row) => this.matches(row, options.where)).length, + ); } create(values: Partial): T { @@ -98,12 +110,18 @@ class FakeRepository { } const copy = { ...row } as T & Record; if (this.target === CharacterItem && relations['itemDefinition']) { - const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; - copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); + const itemDefinitionId = (row as unknown as CharacterItem) + .itemDefinitionId; + copy['itemDefinition'] = this.state.itemDefinitions.find( + (d) => d.id === itemDefinitionId, + ); } if (this.target === CharacterEquipment && relations['characterItem']) { - const characterItemId = (row as unknown as CharacterEquipment).characterItemId; - const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); + const characterItemId = (row as unknown as CharacterEquipment) + .characterItemId; + const characterItem = this.state.characterItems.find( + (ci) => ci.id === characterItemId, + ); copy['characterItem'] = characterItem ? { ...characterItem, @@ -113,7 +131,7 @@ class FakeRepository { } : undefined; } - return copy as T; + return copy; } private rows(): T[] { @@ -123,18 +141,24 @@ class FakeRepository { if (this.target === MonsterDefinition) return this.state.monsters as T[]; if (this.target === Combat) return this.state.combats as T[]; if (this.target === CombatEvent) return this.state.combatEvents as T[]; - if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; + if (this.target === ItemDefinition) + return this.state.itemDefinitions as T[]; if (this.target === CharacterItem) return this.state.characterItems as T[]; - if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; + if (this.target === CharacterEquipment) + return this.state.characterEquipment as T[]; throw new Error(`Unsupported repository ${this.targetName()}`); } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } private targetName(): string { - return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; + return typeof this.target === 'function' + ? this.target.name + : 'EntitySchema'; } } @@ -146,7 +170,9 @@ class FakeDataSource { return new FakeRepository(this.state, target, this); } - async transaction(work: (manager: EntityManager) => Promise): Promise { + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { return work({ getRepository: (target: EntityTarget) => this.getRepository(target), @@ -177,7 +203,9 @@ function character(overrides: Partial = {}): Character { } as Character; } -function monster(overrides: Partial = {}): MonsterDefinition { +function monster( + overrides: Partial = {}, +): MonsterDefinition { return { id: MONSTER_ID, key: 'road-bandit', @@ -186,7 +214,6 @@ function monster(overrides: Partial = {}): MonsterDefinition maxHp: 75, attack: 9, armor: 5, - experienceReward: 16, silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', @@ -207,7 +234,10 @@ function hunt(overrides: Partial = {}): Hunt { } as Hunt; } -function encounter(id: string, overrides: Partial = {}): HuntEncounter { +function encounter( + id: string, + overrides: Partial = {}, +): HuntEncounter { return { id, huntId: HUNT_ID, @@ -219,7 +249,9 @@ function encounter(id: string, overrides: Partial = {}): HuntEnco } as HuntEncounter; } -function itemDefinition(overrides: Partial = {}): ItemDefinition { +function itemDefinition( + overrides: Partial = {}, +): ItemDefinition { return { id: WORN_SWORD_DEFINITION_ID, key: 'worn-short-sword', @@ -243,12 +275,16 @@ function itemDefinition(overrides: Partial = {}): ItemDefinition } function fakeTravelService(): TravelService { - return { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }) } as unknown as TravelService; + return { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService; } function fakeRewardService(): CombatRewardService { return { - grantVictoryRewards: jest.fn().mockResolvedValue({ experience: 0, silver: 0, items: [] }), + grantVictoryRewards: jest + .fn() + .mockResolvedValue({ experience: 0, silver: 0, items: [] }), loadRewards: jest.fn().mockResolvedValue(null), } as unknown as CombatRewardService; } @@ -302,8 +338,13 @@ function createHarness() { ], }; const dataSource = new FakeDataSource(state); - const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); - const equipmentService = new EquipmentService(dataSource as unknown as DataSource, characterStats); + const characterStats = new CharacterStatsService( + dataSource as unknown as DataSource, + ); + const equipmentService = new EquipmentService( + dataSource as unknown as DataSource, + characterStats, + ); const combatService = new CombatService( dataSource as unknown as DataSource, fakeTravelService(), @@ -322,8 +363,16 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () const before = await combatService.startCombat(CHARACTER_ID, 'encounter-1'); let beforeDamage = 0; let beforeResult = before; - for (let round = 0; round < 10 && beforeResult.status === 'ACTIVE'; round += 1) { - beforeResult = await combatService.performAction(CHARACTER_ID, before.id, CombatAction.ATTACK); + for ( + let round = 0; + round < 10 && beforeResult.status === 'ACTIVE'; + round += 1 + ) { + beforeResult = await combatService.performAction( + CHARACTER_ID, + before.id, + CombatAction.ATTACK, + ); if (round === 0) { beforeDamage = before.monster.maxHp - beforeResult.monster.currentHp; } @@ -336,7 +385,11 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () state.huntEncounters.push(encounter('encounter-2')); const after = await combatService.startCombat(CHARACTER_ID, 'encounter-2'); - const afterResult = await combatService.performAction(CHARACTER_ID, after.id, CombatAction.ATTACK); + const afterResult = await combatService.performAction( + CHARACTER_ID, + after.id, + CombatAction.ATTACK, + ); const afterDamage = after.monster.maxHp - afterResult.monster.currentHp; // (6+8) vs 5 armor -> round(14 * 60/65) = 13 @@ -346,7 +399,7 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () expect(afterDamage).toBeGreaterThan(beforeDamage); }); - it('rejects equipping during an active combat, and never retroactively rewrites a finished combat\'s snapshot', async () => { + it("rejects equipping during an active combat, and never retroactively rewrites a finished combat's snapshot", async () => { const { state, combatService, equipmentService } = createHarness(); state.huntEncounters.push(encounter('encounter-1')); @@ -361,7 +414,11 @@ describe('equipping Räuberklinge increases combat damage (spec §45, §60)', () // (status/round) must stay exactly what it was when the fight ended. let result = combat; for (let round = 0; round < 10 && result.status === 'ACTIVE'; round += 1) { - result = await combatService.performAction(CHARACTER_ID, combat.id, CombatAction.ATTACK); + result = await combatService.performAction( + CHARACTER_ID, + combat.id, + CombatAction.ATTACK, + ); } expect(result.status).not.toBe('ACTIVE'); diff --git a/apps/api/src/combat/combat.controller.spec.ts b/apps/api/src/combat/combat.controller.spec.ts index 90d6899..5f45ff1 100644 --- a/apps/api/src/combat/combat.controller.spec.ts +++ b/apps/api/src/combat/combat.controller.spec.ts @@ -20,7 +20,10 @@ describe('CombatController', () => { const module = await Test.createTestingModule({ controllers: [CombatController], providers: [ - { provide: CombatService, useValue: { getCombat, getActiveCombat, performAction } }, + { + provide: CombatService, + useValue: { getCombat, getActiveCombat, performAction }, + }, ], }).compile(); @@ -34,20 +37,40 @@ describe('CombatController', () => { }); it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; + const combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 1, + player: {}, + monster: {}, + events: [], + rewards: null, + }; getCombat.mockResolvedValue(combat); - const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200); + const response = await request(app.getHttpServer()) + .get('/api/combats/combat-1') + .expect(200); expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1'); expect(response.body).toEqual(combat); }); it('delegates GET /api/combats/active to combatService.getActiveCombat', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 3, player: {}, monster: {}, events: [], rewards: null }; + const combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 3, + player: {}, + monster: {}, + events: [], + rewards: null, + }; getActiveCombat.mockResolvedValue(combat); - const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); + const response = await request(app.getHttpServer()) + .get('/api/combats/active') + .expect(200); expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(getCombat).not.toHaveBeenCalled(); @@ -57,7 +80,9 @@ describe('CombatController', () => { it('returns an empty body from GET /api/combats/active when no combat is running', async () => { getActiveCombat.mockResolvedValue(null); - const response = await request(app.getHttpServer()).get('/api/combats/active').expect(200); + const response = await request(app.getHttpServer()) + .get('/api/combats/active') + .expect(200); expect(getActiveCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(response.body).toEqual({}); @@ -65,7 +90,15 @@ describe('CombatController', () => { }); it('delegates POST /api/combats/:combatId/actions with only the action field', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [], rewards: null }; + const combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 2, + player: {}, + monster: {}, + events: [], + rewards: null, + }; performAction.mockResolvedValue(combat); const response = await request(app.getHttpServer()) @@ -73,7 +106,11 @@ describe('CombatController', () => { .send({ action: 'ATTACK' }) .expect(201); - expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK'); + expect(performAction).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'combat-1', + 'ATTACK', + ); expect(response.body).toEqual(combat); }); @@ -89,7 +126,13 @@ describe('CombatController', () => { it('rejects server-owned combat fields the client must never send', async () => { await request(app.getHttpServer()) .post('/api/combats/combat-1/actions') - .send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 }) + .send({ + action: 'ATTACK', + damage: 999, + playerHp: 1, + monsterHp: 1, + round: 99, + }) .expect(400); expect(performAction).not.toHaveBeenCalled(); diff --git a/apps/api/src/combat/combat.controller.ts b/apps/api/src/combat/combat.controller.ts index 2b2a6e8..5a45981 100644 --- a/apps/api/src/combat/combat.controller.ts +++ b/apps/api/src/combat/combat.controller.ts @@ -19,7 +19,14 @@ export class CombatController { } @Post(':combatId/actions') - performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) { - return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action); + performAction( + @Param('combatId') combatId: string, + @Body() dto: CombatActionDto, + ) { + return this.combatService.performAction( + DEMO_CHARACTER_ID, + combatId, + dto.action, + ); } } diff --git a/apps/api/src/combat/combat.module.ts b/apps/api/src/combat/combat.module.ts index 432e59e..5bd8300 100644 --- a/apps/api/src/combat/combat.module.ts +++ b/apps/api/src/combat/combat.module.ts @@ -16,7 +16,14 @@ import { HuntEncounterAttackController } from './hunt-encounter-attack.controlle @Module({ imports: [ - TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]), + TypeOrmModule.forFeature([ + Character, + Hunt, + HuntEncounter, + MonsterDefinition, + Combat, + CombatEvent, + ]), TravelModule, CharactersModule, RewardsModule, diff --git a/apps/api/src/combat/combat.service.spec.ts b/apps/api/src/combat/combat.service.spec.ts index 74b254a..0750b88 100644 --- a/apps/api/src/combat/combat.service.spec.ts +++ b/apps/api/src/combat/combat.service.spec.ts @@ -197,7 +197,6 @@ function monster( maxHp: 45, attack: 5, armor: 0, - experienceReward: 8, silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', @@ -661,7 +660,11 @@ describe('CombatService', () => { const { service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); - const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION); + const result = await service.performAction( + CHARACTER_ID, + combatId, + CombatAction.POTION, + ); expect(result.player.potionsRemaining).toBe(1); expect(result.player.currentHp).toBe(95); @@ -686,7 +689,11 @@ describe('CombatService', () => { const { service, combatId } = await startedCombat(); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); - const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + const telegraphed = await service.performAction( + CHARACTER_ID, + combatId, + CombatAction.ATTACK, + ); expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK'); @@ -842,7 +849,11 @@ describe('CombatService', () => { rewards, ); - const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); + const result = await service.performAction( + CHARACTER_ID, + 'combat-1', + CombatAction.ATTACK, + ); expect(result.status).toBe(CombatStatus.WON); expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] }); @@ -890,7 +901,11 @@ describe('CombatService', () => { rewards, ); - const result = await service.performAction(CHARACTER_ID, 'combat-1', CombatAction.ATTACK); + const result = await service.performAction( + CHARACTER_ID, + 'combat-1', + CombatAction.ATTACK, + ); expect(result.status).toBe(CombatStatus.LOST); expect(result.rewards).toBeNull(); @@ -986,18 +1001,20 @@ describe('CombatService', () => { // before failing, so the assertions below prove the rollback // discards those writes rather than passing vacuously because // nothing was ever written. - grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => { - const characters = manager.getRepository(Character); - const combatCharacter = await characters.findOneBy({ - id: CHARACTER_ID, - }); - if (combatCharacter) { - combatCharacter.experience += 8; - combatCharacter.silver += 6; - await characters.save(combatCharacter); - } - throw new Error('reward persistence failed'); - }), + grantVictoryRewards: jest + .fn() + .mockImplementation(async (manager: EntityManager) => { + const characters = manager.getRepository(Character); + const combatCharacter = await characters.findOneBy({ + id: CHARACTER_ID, + }); + if (combatCharacter) { + combatCharacter.experience += 8; + combatCharacter.silver += 6; + await characters.save(combatCharacter); + } + throw new Error('reward persistence failed'); + }), }), ); diff --git a/apps/api/src/combat/combat.service.ts b/apps/api/src/combat/combat.service.ts index 224e714..59ba8f7 100644 --- a/apps/api/src/combat/combat.service.ts +++ b/apps/api/src/combat/combat.service.ts @@ -134,7 +134,10 @@ export class CombatService { throw invalidHuntEncounter(); } - const playerStats = await this.characterStats.calculate(character, manager); + const playerStats = await this.characterStats.calculate( + character, + manager, + ); const combat = combats.create({ characterId, @@ -229,7 +232,10 @@ export class CombatService { if (combat.status !== CombatStatus.ACTIVE) { throw combatAlreadyFinished(); } - if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) { + if ( + action === CombatAction.POTION && + (combat.playerState.potionsRemaining ?? 0) <= 0 + ) { throw combatNoPotionsRemaining(); } diff --git a/apps/api/src/combat/entities/combat-event.entity.ts b/apps/api/src/combat/entities/combat-event.entity.ts index 93bdf5a..1d5c2f9 100644 --- a/apps/api/src/combat/entities/combat-event.entity.ts +++ b/apps/api/src/combat/entities/combat-event.entity.ts @@ -12,7 +12,9 @@ import { CombatEventType } from '../combat-event-type.enum'; import { Combat } from './combat.entity'; @Entity({ name: 'combat_events' }) -@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true }) +@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { + unique: true, +}) export class CombatEvent { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts index 3d94e0e..152c023 100644 --- a/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts +++ b/apps/api/src/combat/hunt-encounter-attack.controller.spec.ts @@ -28,7 +28,15 @@ describe('HuntEncounterAttackController', () => { }); it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => { - const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [], rewards: null }; + const combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 1, + player: {}, + monster: {}, + events: [], + rewards: null, + }; startCombat.mockResolvedValue(combat); const response = await request(app.getHttpServer()) diff --git a/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts index 87db448..193db34 100644 --- a/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts +++ b/apps/api/src/database/migrations/1788600000000-CreateLootAndRewards.ts @@ -160,7 +160,9 @@ export class CreateLootAndRewards1788600000000 implements MigrationInterface { 'ALTER TABLE "monster_definitions" DROP COLUMN "loot_table_id"', ); await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_item"'); - await queryRunner.query('DROP INDEX "IDX_loot_table_entries_table_position"'); + await queryRunner.query( + 'DROP INDEX "IDX_loot_table_entries_table_position"', + ); await queryRunner.query('DROP TABLE "loot_table_entries"'); await queryRunner.query('DROP INDEX "IDX_loot_tables_key"'); await queryRunner.query('DROP TABLE "loot_tables"'); diff --git a/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts index 4d234b5..3276ba6 100644 --- a/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts +++ b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts @@ -7,9 +7,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; * alone — the map and hunt screens still render them. Backfill defaults keep * existing rows valid; the seed replaces them with authored content. */ -export class CreateLocalLocationView1788700000000 - implements MigrationInterface -{ +export class CreateLocalLocationView1788700000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'', diff --git a/apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts b/apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts index 3724c8f..fa325a4 100644 --- a/apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts +++ b/apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts @@ -2,10 +2,18 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class ExtendCombatEventTypes1790000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`); - await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`); - await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`); - await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`, + ); + await queryRunner.query( + `ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`, + ); } public async down(queryRunner: QueryRunner): Promise { diff --git a/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts b/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts index bb49c42..a515c14 100644 --- a/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts +++ b/apps/api/src/database/migrations/1791000000000-CreateRenownAndReputation.ts @@ -13,7 +13,9 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac 'ALTER TABLE "characters" ADD CONSTRAINT "CHK_characters_renown" CHECK ("renown" >= 1 AND "renown" <= 15)', ); await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "level"'); - await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "experience"'); + await queryRunner.query( + 'ALTER TABLE "characters" DROP COLUMN "experience"', + ); // --- ItemDefinition: drop requiredLevel (spec §14, design R4) --- await queryRunner.query( @@ -48,6 +50,14 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac 'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"', ); + // --- MonsterDefinition: XP is abolished as a concept (spec §1, design R7). + // `silver_min`/`silver_max` deliberately stay -- spec §15 keeps a direct + // currency drop available as a lore-valid exception -- but XP has no such + // carve-out, so the column goes with the concept. --- + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"', + ); + // --- ReputationFaction (spec §9) --- await queryRunner.query(`CREATE TABLE "reputation_factions" ( "id" uuid NOT NULL DEFAULT gen_random_uuid(), @@ -141,14 +151,22 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac 'DROP INDEX "IDX_character_renown_milestones_character_milestone"', ); await queryRunner.query('DROP TABLE "character_renown_milestones"'); - await queryRunner.query('DROP INDEX "IDX_renown_milestone_definitions_key"'); + await queryRunner.query( + 'DROP INDEX "IDX_renown_milestone_definitions_key"', + ); await queryRunner.query('DROP TABLE "renown_milestone_definitions"'); - await queryRunner.query('DROP INDEX "IDX_character_reputation_character_faction"'); + await queryRunner.query( + 'DROP INDEX "IDX_character_reputation_character_faction"', + ); await queryRunner.query('DROP TABLE "character_reputation"'); await queryRunner.query('DROP INDEX "IDX_reputation_factions_key"'); await queryRunner.query('DROP TABLE "reputation_factions"'); + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward" integer NOT NULL DEFAULT 0', + ); + await queryRunner.query( 'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted" integer NOT NULL DEFAULT 0', ); @@ -175,12 +193,16 @@ export class CreateRenownAndReputation1791000000000 implements MigrationInterfac 'ALTER TABLE "item_definitions" ADD COLUMN "required_level" integer NOT NULL DEFAULT 1', ); - await queryRunner.query('ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1'); + await queryRunner.query( + 'ALTER TABLE "characters" ADD COLUMN "level" integer NOT NULL DEFAULT 1', + ); await queryRunner.query( 'ALTER TABLE "characters" ADD COLUMN "experience" integer NOT NULL DEFAULT 0', ); await queryRunner.query('UPDATE "characters" SET "level" = "renown"'); - await queryRunner.query('ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"'); + await queryRunner.query( + 'ALTER TABLE "characters" DROP CONSTRAINT "CHK_characters_renown"', + ); await queryRunner.query('ALTER TABLE "characters" DROP COLUMN "renown"'); } } diff --git a/apps/api/src/database/migrations/combat-system.migration.spec.ts b/apps/api/src/database/migrations/combat-system.migration.spec.ts index 160e27e..ea2daab 100644 --- a/apps/api/src/database/migrations/combat-system.migration.spec.ts +++ b/apps/api/src/database/migrations/combat-system.migration.spec.ts @@ -8,7 +8,8 @@ describe('combat system schema', () => { const metadata = getMetadataArgsStorage(); const relations = metadata.relations.filter( - (relation) => relation.target === Combat || relation.target === CombatEvent, + (relation) => + relation.target === Combat || relation.target === CombatEvent, ); expect( @@ -19,10 +20,26 @@ describe('combat system schema', () => { })), ).toEqual( expect.arrayContaining([ - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }), - expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'character', + target: Combat, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'huntEncounter', + target: Combat, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'monster', + target: Combat, + }), + expect.objectContaining({ + onDelete: 'CASCADE', + propertyName: 'combat', + target: CombatEvent, + }), ]), ); }); @@ -37,7 +54,10 @@ describe('combat system schema', () => { ); expect(index).toBeDefined(); - const indexMetadata = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; + const indexMetadata = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; expect(indexMetadata?.options?.unique ?? indexMetadata?.unique).toBe(true); }); }); diff --git a/apps/api/src/database/migrations/equipment.migration.spec.ts b/apps/api/src/database/migrations/equipment.migration.spec.ts index fdd59c7..29a38d6 100644 --- a/apps/api/src/database/migrations/equipment.migration.spec.ts +++ b/apps/api/src/database/migrations/equipment.migration.spec.ts @@ -7,7 +7,8 @@ describe('character_equipment schema', () => { const metadata = getMetadataArgsStorage(); const column = metadata.columns.find( (candidate) => - candidate.target === CharacterEquipment && candidate.propertyName === 'slot', + candidate.target === CharacterEquipment && + candidate.propertyName === 'slot', ); expect(column).toBeDefined(); diff --git a/apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts b/apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts index d50ffff..bf17978 100644 --- a/apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts +++ b/apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts @@ -7,7 +7,8 @@ describe('combat_events.type enum', () => { it('includes the Playable Slice 0.6 event types', () => { const metadata = getMetadataArgsStorage(); const column = metadata.columns.find( - (candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type', + (candidate) => + candidate.target === CombatEvent && candidate.propertyName === 'type', ); expect(column).toBeDefined(); 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 35b369e..56f3c40 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 @@ -29,21 +29,31 @@ describe('loot and rewards schema', () => { }); it('keeps one stack per character per item definition', () => { - expect(uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId'])).toBe(true); + expect( + uniqueIndexFor(CharacterItem, ['characterId', 'itemDefinitionId']), + ).toBe(true); }); it('keeps loot-table content keys and entry positions unique', () => { expect(uniqueIndexFor(ItemDefinition, ['key'])).toBe(true); expect(uniqueIndexFor(LootTable, ['key'])).toBe(true); - expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe(true); - expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId'])).toBe(true); + expect(uniqueIndexFor(LootTableEntry, ['lootTableId', 'position'])).toBe( + true, + ); + expect( + uniqueIndexFor(LootTableEntry, ['lootTableId', 'itemDefinitionId']), + ).toBe(true); }); it('maps reward and loot relations with the documented onDelete behavior', () => { const relations = getMetadataArgsStorage().relations.filter((relation) => - [CombatReward, CombatRewardItem, CharacterItem, LootTableEntry, MonsterDefinition].includes( - relation.target as never, - ), + [ + CombatReward, + CombatRewardItem, + CharacterItem, + LootTableEntry, + MonsterDefinition, + ].includes(relation.target as never), ); expect( @@ -54,16 +64,56 @@ describe('loot and rewards schema', () => { })), ).toEqual( expect.arrayContaining([ - expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatReward }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: CombatReward }), - expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combatReward', target: CombatRewardItem }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'characterItem', target: CombatRewardItem }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CombatRewardItem }), - expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'character', target: CharacterItem }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: CharacterItem }), - expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'lootTable', target: LootTableEntry }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'itemDefinition', target: LootTableEntry }), - expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'lootTable', target: MonsterDefinition }), + expect.objectContaining({ + onDelete: 'CASCADE', + propertyName: 'combat', + target: CombatReward, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'character', + target: CombatReward, + }), + expect.objectContaining({ + onDelete: 'CASCADE', + propertyName: 'combatReward', + target: CombatRewardItem, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'characterItem', + target: CombatRewardItem, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'itemDefinition', + target: CombatRewardItem, + }), + expect.objectContaining({ + onDelete: 'CASCADE', + propertyName: 'character', + target: CharacterItem, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'itemDefinition', + target: CharacterItem, + }), + expect.objectContaining({ + onDelete: 'CASCADE', + propertyName: 'lootTable', + target: LootTableEntry, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'itemDefinition', + target: LootTableEntry, + }), + expect.objectContaining({ + onDelete: 'RESTRICT', + propertyName: 'lootTable', + target: MonsterDefinition, + }), ]), ); }); @@ -72,14 +122,16 @@ describe('loot and rewards schema', () => { const columns = getMetadataArgsStorage().columns; const silver = columns.find( - (candidate) => candidate.target === Character && candidate.propertyName === 'silver', + (candidate) => + candidate.target === Character && candidate.propertyName === 'silver', ); expect(silver).toBeDefined(); expect(silver?.options.type).toBe('integer'); const lootTableId = columns.find( (candidate) => - candidate.target === MonsterDefinition && candidate.propertyName === 'lootTableId', + candidate.target === MonsterDefinition && + candidate.propertyName === 'lootTableId', ); expect(lootTableId).toBeDefined(); expect(lootTableId?.options.nullable).toBe(true); @@ -88,7 +140,8 @@ describe('loot and rewards schema', () => { it('stores drop chance as a numeric column so probabilities stay data-driven', () => { const dropChance = getMetadataArgsStorage().columns.find( (candidate) => - candidate.target === LootTableEntry && candidate.propertyName === 'dropChance', + candidate.target === LootTableEntry && + candidate.propertyName === 'dropChance', ); expect(dropChance?.options.type).toBe('numeric'); @@ -111,11 +164,19 @@ describe('loot and rewards schema', () => { expect(upQueries).toEqual( expect.arrayContaining([ // The database half of the "one reward per combat" invariant (spec §7, §37). - expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_rewards_combat"'), - expect.stringContaining('CREATE UNIQUE INDEX "IDX_character_items_character_item"'), - expect.stringContaining('CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_combat_rewards_combat"', + ), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_character_items_character_item"', + ), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_combat_reward_items_reward_item"', + ), expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "silver"'), - expect.stringContaining('ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"'), + expect.stringContaining( + 'ALTER TABLE "monster_definitions" ADD COLUMN "loot_table_id"', + ), ]), ); 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 bf67ad0..81cb956 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 @@ -19,9 +19,13 @@ function columnNames(target: unknown): string[] { function uniqueIndexFor(target: unknown, columns: string[]): boolean { const index = getMetadataArgsStorage().indices.find( (candidate) => - candidate.target === target && columns.every((column) => candidate.columns?.includes(column)), + candidate.target === target && + columns.every((column) => candidate.columns?.includes(column)), ); - const meta = index as typeof index & { options?: { unique?: boolean }; unique?: boolean }; + const meta = index as typeof index & { + options?: { unique?: boolean }; + unique?: boolean; + }; return (meta?.options?.unique ?? meta?.unique) === true; } @@ -52,7 +56,9 @@ describe('Slice 0.6.5 entity metadata', () => { }); it('CharacterReputation enforces one row per character per faction', () => { - expect(uniqueIndexFor(CharacterReputation, ['characterId', 'factionId'])).toBe(true); + expect( + uniqueIndexFor(CharacterReputation, ['characterId', 'factionId']), + ).toBe(true); }); it('RenownMilestoneDefinition has a unique key', () => { diff --git a/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts b/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts index f873446..3bf0ec0 100644 --- a/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts +++ b/apps/api/src/database/migrations/renown-and-reputation.migration.spec.ts @@ -35,8 +35,12 @@ describe('CreateRenownAndReputation1791000000000', () => { it('backfills renown from level BEFORE dropping the level column', async () => { const up = await runUp(); - const backfillIndex = up.findIndex((sql) => sql.includes('LEAST(GREATEST("level", 1), 15)')); - const dropLevelIndex = up.findIndex((sql) => sql.includes('DROP COLUMN "level"')); + const backfillIndex = up.findIndex((sql) => + sql.includes('LEAST(GREATEST("level", 1), 15)'), + ); + const dropLevelIndex = up.findIndex((sql) => + sql.includes('DROP COLUMN "level"'), + ); expect(backfillIndex).toBeGreaterThanOrEqual(0); expect(dropLevelIndex).toBeGreaterThanOrEqual(0); @@ -47,8 +51,12 @@ describe('CreateRenownAndReputation1791000000000', () => { it('adds the renown range constraint only AFTER the backfill has populated valid values', async () => { const up = await runUp(); - const backfillIndex = up.findIndex((sql) => sql.includes('LEAST(GREATEST("level", 1), 15)')); - const constraintIndex = up.findIndex((sql) => sql.includes('CHK_characters_renown')); + const backfillIndex = up.findIndex((sql) => + sql.includes('LEAST(GREATEST("level", 1), 15)'), + ); + const constraintIndex = up.findIndex((sql) => + sql.includes('CHK_characters_renown'), + ); expect(backfillIndex).toBeGreaterThanOrEqual(0); expect(constraintIndex).toBeGreaterThanOrEqual(0); @@ -62,7 +70,9 @@ describe('CreateRenownAndReputation1791000000000', () => { expect(up).toEqual( expect.arrayContaining([ - expect.stringContaining('ALTER TABLE "item_definitions" DROP COLUMN "required_level"'), + expect.stringContaining( + 'ALTER TABLE "item_definitions" DROP COLUMN "required_level"', + ), ]), ); }); @@ -72,8 +82,12 @@ describe('CreateRenownAndReputation1791000000000', () => { expect(up).toEqual( expect.arrayContaining([ - expect.stringContaining(`SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`), - expect.stringContaining(`SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`), + expect.stringContaining( + `SET "type" = 'EQUIPMENT' WHERE "type" IN ('WEAPON', 'ARMOR')`, + ), + expect.stringContaining( + `SET "type" = 'TRADE_GOOD' WHERE "type" = 'MATERIAL'`, + ), expect.stringContaining('DROP TYPE "item_type_enum"'), expect.stringContaining( `CREATE TYPE "item_type_enum" AS ENUM ('EQUIPMENT', 'TRADE_GOOD', 'TROPHY', 'QUEST_ITEM', 'CONSUMABLE')`, @@ -87,30 +101,63 @@ describe('CreateRenownAndReputation1791000000000', () => { expect(up).toEqual( expect.arrayContaining([ - expect.stringContaining('ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"'), + expect.stringContaining( + 'ALTER TABLE "combat_rewards" DROP COLUMN "experience_granted"', + ), ]), ); }); + /** + * XP is abolished outright (design R7), so the column goes with the concept. + * It is NOT NULL with no default, so leaving it behind while the seed stops + * supplying a value would fail every monster insert against a real database + * -- a break no suite here can catch, since none of them connect to Postgres. + */ + it('drops experience_reward from monster_definitions', async () => { + const up = await runUp(); + + expect(up).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'ALTER TABLE "monster_definitions" DROP COLUMN "experience_reward"', + ), + ]), + ); + }); + + it('keeps silver_min and silver_max on monster_definitions', async () => { + const up = await runUp(); + + expect(up.join(' ')).not.toContain('"silver_min"'); + expect(up.join(' ')).not.toContain('"silver_max"'); + }); + it('creates all five new tables with their unique constraints', async () => { const up = await runUp(); expect(up).toEqual( expect.arrayContaining([ expect.stringContaining('CREATE TABLE "reputation_factions"'), - expect.stringContaining('CREATE UNIQUE INDEX "IDX_reputation_factions_key"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_reputation_factions_key"', + ), expect.stringContaining('CREATE TABLE "character_reputation"'), expect.stringContaining( 'CREATE UNIQUE INDEX "IDX_character_reputation_character_faction" ON "character_reputation" ("character_id", "faction_id")', ), expect.stringContaining('CREATE TABLE "renown_milestone_definitions"'), - expect.stringContaining('CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_renown_milestone_definitions_key"', + ), expect.stringContaining('CREATE TABLE "character_renown_milestones"'), expect.stringContaining( 'CREATE UNIQUE INDEX "IDX_character_renown_milestones_character_milestone" ON "character_renown_milestones" ("character_id", "milestone_id")', ), expect.stringContaining('CREATE TABLE "turn_in_definitions"'), - expect.stringContaining('CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"'), + expect.stringContaining( + 'CREATE UNIQUE INDEX "IDX_turn_in_definitions_key"', + ), ]), ); }); @@ -125,14 +172,27 @@ describe('CreateRenownAndReputation1791000000000', () => { expect.stringContaining('DROP TABLE "renown_milestone_definitions"'), expect.stringContaining('DROP TABLE "character_reputation"'), expect.stringContaining('DROP TABLE "reputation_factions"'), - expect.stringContaining('ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"'), - expect.stringContaining('ALTER TABLE "item_definitions" ADD COLUMN "required_level"'), + expect.stringContaining( + 'ALTER TABLE "monster_definitions" ADD COLUMN "experience_reward"', + ), + expect.stringContaining( + 'ALTER TABLE "combat_rewards" ADD COLUMN "experience_granted"', + ), + expect.stringContaining( + 'ALTER TABLE "item_definitions" ADD COLUMN "required_level"', + ), expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "level"'), - expect.stringContaining('ALTER TABLE "characters" ADD COLUMN "experience"'), - expect.stringContaining('ALTER TABLE "characters" DROP COLUMN "renown"'), + expect.stringContaining( + 'ALTER TABLE "characters" ADD COLUMN "experience"', + ), + expect.stringContaining( + 'ALTER TABLE "characters" DROP COLUMN "renown"', + ), ]), ); - const turnInDropIndex = down.findIndex((sql) => sql.includes('DROP TABLE "turn_in_definitions"')); + const turnInDropIndex = down.findIndex((sql) => + sql.includes('DROP TABLE "turn_in_definitions"'), + ); const reputationFactionsDropIndex = down.findIndex((sql) => sql.includes('DROP TABLE "reputation_factions"'), ); 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 4c97581..84f9a6a 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -129,7 +129,10 @@ describe('seedVisibleVerticalSlice', () => { Object.assign(characterRepository.rows[0], { currentLocationId: BURNED_ROAD_ID, currentHp: 57, - renown: 1, + // Deliberately NOT the seed default of 1: if a re-seed clobbered player + // state back to defaults, an assertion on the default value could not + // tell the difference. + renown: 5, }); await seedVisibleVerticalSlice(dataSource); @@ -168,7 +171,7 @@ describe('seedVisibleVerticalSlice', () => { expect.objectContaining({ currentLocationId: BURNED_ROAD_ID, currentHp: 57, - renown: 1, + renown: 5, }), ); diff --git a/apps/api/src/equipment/equipment.controller.ts b/apps/api/src/equipment/equipment.controller.ts index 1a42a90..25f50c0 100644 --- a/apps/api/src/equipment/equipment.controller.ts +++ b/apps/api/src/equipment/equipment.controller.ts @@ -14,6 +14,9 @@ export class EquipmentController { @Post() equip(@Body() request: EquipItemDto): Promise { - return this.equipmentService.equip(DEMO_CHARACTER_ID, request.characterItemId); + return this.equipmentService.equip( + DEMO_CHARACTER_ID, + request.characterItemId, + ); } } diff --git a/apps/api/src/equipment/equipment.module.ts b/apps/api/src/equipment/equipment.module.ts index 48aafe5..5fa3a43 100644 --- a/apps/api/src/equipment/equipment.module.ts +++ b/apps/api/src/equipment/equipment.module.ts @@ -11,7 +11,13 @@ import { EquipmentService } from './equipment.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Character, Combat, CharacterItem, ItemDefinition, CharacterEquipment]), + TypeOrmModule.forFeature([ + Character, + Combat, + CharacterItem, + ItemDefinition, + CharacterEquipment, + ]), CharactersModule, ], controllers: [EquipmentController], diff --git a/apps/api/src/equipment/equipment.service.spec.ts b/apps/api/src/equipment/equipment.service.spec.ts index b722016..d75987b 100644 --- a/apps/api/src/equipment/equipment.service.spec.ts +++ b/apps/api/src/equipment/equipment.service.spec.ts @@ -38,17 +38,30 @@ class FakeRepository { relations?: Record; lock?: { mode: string }; }): Promise { - const row = this.rows().find((candidate) => this.matches(candidate, options.where)) ?? null; - return Promise.resolve(row ? this.withRelations(row, options.relations) : null); + const row = + this.rows().find((candidate) => this.matches(candidate, options.where)) ?? + null; + return Promise.resolve( + row ? this.withRelations(row, options.relations) : null, + ); } findOneBy(where: Partial): Promise { - return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + return Promise.resolve( + this.rows().find((row) => this.matches(row, where)) ?? null, + ); } - find(options: { where: Partial; relations?: Record }): Promise { - const matched = this.rows().filter((row) => this.matches(row, options.where)); - return Promise.resolve(matched.map((row) => this.withRelations(row, options.relations))); + find(options: { + where: Partial; + relations?: Record; + }): Promise { + const matched = this.rows().filter((row) => + this.matches(row, options.where), + ); + return Promise.resolve( + matched.map((row) => this.withRelations(row, options.relations)), + ); } create(values: Partial): T { @@ -75,12 +88,18 @@ class FakeRepository { } const copy = { ...row } as T & Record; if (this.target === CharacterItem && relations['itemDefinition']) { - const itemDefinitionId = (row as unknown as CharacterItem).itemDefinitionId; - copy['itemDefinition'] = this.state.itemDefinitions.find((d) => d.id === itemDefinitionId); + const itemDefinitionId = (row as unknown as CharacterItem) + .itemDefinitionId; + copy['itemDefinition'] = this.state.itemDefinitions.find( + (d) => d.id === itemDefinitionId, + ); } if (this.target === CharacterEquipment && relations['characterItem']) { - const characterItemId = (row as unknown as CharacterEquipment).characterItemId; - const characterItem = this.state.characterItems.find((ci) => ci.id === characterItemId); + const characterItemId = (row as unknown as CharacterEquipment) + .characterItemId; + const characterItem = this.state.characterItems.find( + (ci) => ci.id === characterItemId, + ); copy['characterItem'] = characterItem ? { ...characterItem, @@ -90,24 +109,30 @@ class FakeRepository { } : undefined; } - return copy as T; + return copy; } private rows(): T[] { if (this.target === Character) return this.state.characters as T[]; - if (this.target === ItemDefinition) return this.state.itemDefinitions as T[]; + if (this.target === ItemDefinition) + return this.state.itemDefinitions as T[]; if (this.target === CharacterItem) return this.state.characterItems as T[]; - if (this.target === CharacterEquipment) return this.state.characterEquipment as T[]; + if (this.target === CharacterEquipment) + return this.state.characterEquipment as T[]; if (this.target === Combat) return this.state.combats as T[]; throw new Error(`Unsupported repository ${this.targetName()}`); } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } private targetName(): string { - return typeof this.target === 'function' ? this.target.name : 'EntitySchema'; + return typeof this.target === 'function' + ? this.target.name + : 'EntitySchema'; } } @@ -119,7 +144,9 @@ class FakeDataSource { return new FakeRepository(this.state, target, this); } - async transaction(work: (manager: EntityManager) => Promise): Promise { + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { return work({ getRepository: (target: EntityTarget) => this.getRepository(target), @@ -133,7 +160,9 @@ class FakeDataSource { } } -function itemDefinition(overrides: Partial = {}): ItemDefinition { +function itemDefinition( + overrides: Partial = {}, +): ItemDefinition { return { id: 'def-worn-sword', key: 'worn-short-sword', @@ -152,7 +181,7 @@ function itemDefinition(overrides: Partial = {}): ItemDefinition createdAt: new Date(), updatedAt: new Date(), ...overrides, - } as ItemDefinition; + }; } function character(overrides: Partial = {}): Character { @@ -176,12 +205,20 @@ function createHarness(state: Partial = {}) { ...state, }; const dataSource = new FakeDataSource(fullState); - const characterStats = new CharacterStatsService(dataSource as unknown as DataSource); - const service = new EquipmentService(dataSource as unknown as DataSource, characterStats); + const characterStats = new CharacterStatsService( + dataSource as unknown as DataSource, + ); + const service = new EquipmentService( + dataSource as unknown as DataSource, + characterStats, + ); return { state: fullState, service }; } -async function expectEquipmentDomainError(promise: Promise, code: string): Promise { +async function expectEquipmentDomainError( + promise: Promise, + code: string, +): Promise { let error: unknown; try { await promise; @@ -265,7 +302,9 @@ describe('EquipmentService', () => { expect(result.slots.WEAPON?.characterItemId).toBe(BANDIT_BLADE_ITEM_ID); expect(state.characterEquipment).toHaveLength(1); - expect(state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID)).toBeDefined(); + expect( + state.characterItems.find((i) => i.id === WORN_SWORD_ITEM_ID), + ).toBeDefined(); }); it('rejects equipping an item owned by a different character', async () => { @@ -382,7 +421,9 @@ describe('EquipmentService', () => { await service.equip(CHARACTER_ID, BANDIT_BLADE_ITEM_ID); expect(state.characterEquipment).toHaveLength(1); - expect(state.characterEquipment[0].characterItemId).toBe(BANDIT_BLADE_ITEM_ID); + expect(state.characterEquipment[0].characterItemId).toBe( + BANDIT_BLADE_ITEM_ID, + ); }); it('rejects equipping while the character has an active combat', async () => { @@ -397,7 +438,13 @@ describe('EquipmentService', () => { quantity: 1, } as CharacterItem, ], - combats: [{ id: 'combat-1', characterId: CHARACTER_ID, status: CombatStatus.ACTIVE } as Combat], + combats: [ + { + id: 'combat-1', + characterId: CHARACTER_ID, + status: CombatStatus.ACTIVE, + } as Combat, + ], }); await expectEquipmentDomainError( @@ -422,7 +469,12 @@ describe('EquipmentService', () => { FEET: null, AMULET: null, }); - expect(result.stats).toEqual({ maxHp: 100, attack: 6, weaponDamage: 0, armor: 0 }); + expect(result.stats).toEqual({ + maxHp: 100, + attack: 6, + weaponDamage: 0, + armor: 0, + }); }); }); }); diff --git a/apps/api/src/equipment/equipment.service.ts b/apps/api/src/equipment/equipment.service.ts index 5f7da30..c60543f 100644 --- a/apps/api/src/equipment/equipment.service.ts +++ b/apps/api/src/equipment/equipment.service.ts @@ -26,7 +26,10 @@ export interface EquipmentSlotItemDto { }; } -export type EquipmentSlotsDto = Record; +export type EquipmentSlotsDto = Record< + EquipmentSlot, + EquipmentSlotItemDto | null +>; export interface EquipmentStatsDto { maxHp: number; @@ -64,7 +67,10 @@ export class EquipmentService { * (spec §14, §28). Runs in one transaction: the old item is unequipped by * being overwritten, never deleted (spec §15). */ - async equip(characterId: string, characterItemId: string): Promise { + async equip( + characterId: string, + characterItemId: string, + ): Promise { return this.dataSource.transaction(async (manager) => { const characters = manager.getRepository(Character); const combats = manager.getRepository(Combat); diff --git a/apps/api/src/hunting/hunting.service.spec.ts b/apps/api/src/hunting/hunting.service.spec.ts index 437a6aa..d4bac69 100644 --- a/apps/api/src/hunting/hunting.service.spec.ts +++ b/apps/api/src/hunting/hunting.service.spec.ts @@ -36,7 +36,7 @@ function withMonster( encounter: HuntEncounter, monster: MonsterDefinition, ): HuntEncounter { - return { ...encounter, monster } as HuntEncounter; + return { ...encounter, monster }; } class FakeRepository { @@ -260,7 +260,6 @@ function monsterDefinition( maxHp: 20, attack: 3, armor: 0, - experienceReward: 10, silverMin: 1, silverMax: 3, artworkPath: `/assets/monsters/${key}.webp`, diff --git a/apps/api/src/inventory/inventory.service.spec.ts b/apps/api/src/inventory/inventory.service.spec.ts index 2d39c00..dd67bec 100644 --- a/apps/api/src/inventory/inventory.service.spec.ts +++ b/apps/api/src/inventory/inventory.service.spec.ts @@ -34,7 +34,7 @@ function characterItem(overrides: Partial = {}): CharacterItem { } describe('InventoryService', () => { - it('returns only the current character\'s items with definition data, quantity, and equipped state', async () => { + it("returns only the current character's items with definition data, quantity, and equipped state", async () => { const items = [ characterItem({ id: 'item-1', quantity: 1 }), characterItem({ id: 'item-2', quantity: 3, itemDefinitionId: 'def-2' }), @@ -44,7 +44,10 @@ describe('InventoryService', () => { } as unknown as Repository; const equipment = { find: jest.fn().mockResolvedValue([ - { characterItemId: 'item-1', slot: EquipmentSlot.WEAPON } as CharacterEquipment, + { + characterItemId: 'item-1', + slot: EquipmentSlot.WEAPON, + } as CharacterEquipment, ]), } as unknown as Repository; const service = new InventoryService(characterItems, equipment); @@ -92,6 +95,8 @@ describe('InventoryService', () => { } as unknown as Repository; const service = new InventoryService(characterItems, equipment); - await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ items: [] }); + await expect(service.getInventory(CHARACTER_ID)).resolves.toEqual({ + items: [], + }); }); }); diff --git a/apps/api/src/items/entities/character-item.entity.ts b/apps/api/src/items/entities/character-item.entity.ts index 41a536a..0351ba0 100644 --- a/apps/api/src/items/entities/character-item.entity.ts +++ b/apps/api/src/items/entities/character-item.entity.ts @@ -19,9 +19,13 @@ import { ItemDefinition } from './item-definition.entity'; * `ItemDefinition.id`. */ @Entity({ name: 'character_items' }) -@Index('IDX_character_items_character_item', ['characterId', 'itemDefinitionId'], { - unique: true, -}) +@Index( + 'IDX_character_items_character_item', + ['characterId', 'itemDefinitionId'], + { + unique: true, + }, +) export class CharacterItem { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/items/entities/item-definition.entity.ts b/apps/api/src/items/entities/item-definition.entity.ts index 69281a1..616be6e 100644 --- a/apps/api/src/items/entities/item-definition.entity.ts +++ b/apps/api/src/items/entities/item-definition.entity.ts @@ -25,7 +25,12 @@ export class ItemDefinition { @Column({ name: 'description', type: 'text' }) description!: string; - @Column({ name: 'type', type: 'enum', enum: ItemType, enumName: 'item_type_enum' }) + @Column({ + name: 'type', + type: 'enum', + enum: ItemType, + enumName: 'item_type_enum', + }) type!: ItemType; @Column({ @@ -37,7 +42,12 @@ export class ItemDefinition { }) equipmentSlot!: EquipmentSlot | null; - @Column({ name: 'rarity', type: 'enum', enum: ItemRarity, enumName: 'item_rarity_enum' }) + @Column({ + name: 'rarity', + type: 'enum', + enum: ItemRarity, + enumName: 'item_rarity_enum', + }) rarity!: ItemRarity; @Column({ name: 'tier', type: 'integer' }) diff --git a/apps/api/src/loot/entities/loot-table-entry.entity.ts b/apps/api/src/loot/entities/loot-table-entry.entity.ts index 91b3c5f..00b3732 100644 --- a/apps/api/src/loot/entities/loot-table-entry.entity.ts +++ b/apps/api/src/loot/entities/loot-table-entry.entity.ts @@ -19,8 +19,14 @@ import { LootTable } from './loot-table.entity'; * `dropChance = 1.0000` — no extra mechanism needed (spec §14). */ @Entity({ name: 'loot_table_entries' }) -@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { unique: true }) -@Index('IDX_loot_table_entries_table_item', ['lootTableId', 'itemDefinitionId'], { unique: true }) +@Index('IDX_loot_table_entries_table_position', ['lootTableId', 'position'], { + unique: true, +}) +@Index( + 'IDX_loot_table_entries_table_item', + ['lootTableId', 'itemDefinitionId'], + { unique: true }, +) export class LootTableEntry { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/loot/loot.module.ts b/apps/api/src/loot/loot.module.ts index c2973c8..bb24c93 100644 --- a/apps/api/src/loot/loot.module.ts +++ b/apps/api/src/loot/loot.module.ts @@ -7,7 +7,10 @@ import { LootService } from './loot.service'; @Module({ imports: [TypeOrmModule.forFeature([LootTable, LootTableEntry])], - providers: [LootService, { provide: RANDOM_SOURCE, useValue: systemRandomSource }], + providers: [ + LootService, + { provide: RANDOM_SOURCE, useValue: systemRandomSource }, + ], exports: [LootService], }) export class LootModule {} diff --git a/apps/api/src/loot/loot.service.spec.ts b/apps/api/src/loot/loot.service.spec.ts index e6b315e..6366fca 100644 --- a/apps/api/src/loot/loot.service.spec.ts +++ b/apps/api/src/loot/loot.service.spec.ts @@ -30,7 +30,9 @@ function queuedRandom(...values: number[]): RandomSource { return { next: () => { if (index >= values.length) { - throw new Error('LootService consumed more random values than the test queued'); + throw new Error( + 'LootService consumed more random values than the test queued', + ); } return values[index++]; }, @@ -40,14 +42,15 @@ function queuedRandom(...values: number[]): RandomSource { function dataSourceWith(entries: LootTableEntry[]): DataSource { return { getRepository: jest.fn(() => ({ - find: jest.fn(async (options: { where: { lootTableId: string; enabled: boolean } }) => - entries - .filter( - (candidate) => - candidate.lootTableId === options.where.lootTableId && - candidate.enabled === options.where.enabled, - ) - .sort((a, b) => a.position - b.position), + find: jest.fn( + async (options: { where: { lootTableId: string; enabled: boolean } }) => + entries + .filter( + (candidate) => + candidate.lootTableId === options.where.lootTableId && + candidate.enabled === options.where.enabled, + ) + .sort((a, b) => a.position - b.position), ), })), } as unknown as DataSource; @@ -55,7 +58,12 @@ function dataSourceWith(entries: LootTableEntry[]): DataSource { describe('LootService', () => { const ashRatEntries = [ - entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.6000' }), + entry({ + id: 'entry-pelt', + itemDefinitionId: ASH_PELT, + position: 1, + dropChance: '0.6000', + }), entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, @@ -65,7 +73,10 @@ describe('LootService', () => { ]; it('drops an entry when the roll falls under its chance', async () => { - const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.59, 0.07)); + const service = new LootService( + dataSourceWith(ashRatEntries), + queuedRandom(0.59, 0.07), + ); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [ @@ -76,13 +87,21 @@ describe('LootService', () => { }); it('skips an entry when the roll lands on or above its chance', async () => { - const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.6, 0.08)); + const service = new LootService( + dataSourceWith(ashRatEntries), + queuedRandom(0.6, 0.08), + ); - await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [], + }); }); it('rolls each entry independently, so one combat can drop only the second item', async () => { - const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom(0.9, 0.01)); + const service = new LootService( + dataSourceWith(ashRatEntries), + queuedRandom(0.9, 0.01), + ); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], @@ -91,10 +110,23 @@ describe('LootService', () => { it('rolls entries in position order so injected values stay predictable', async () => { const outOfOrder = [ - entry({ id: 'entry-sword', itemDefinitionId: WORN_SHORT_SWORD, position: 2, dropChance: '1.0000' }), - entry({ id: 'entry-pelt', itemDefinitionId: ASH_PELT, position: 1, dropChance: '0.0000' }), + entry({ + id: 'entry-sword', + itemDefinitionId: WORN_SHORT_SWORD, + position: 2, + dropChance: '1.0000', + }), + entry({ + id: 'entry-pelt', + itemDefinitionId: ASH_PELT, + position: 1, + dropChance: '0.0000', + }), ]; - const service = new LootService(dataSourceWith(outOfOrder), queuedRandom(0.5, 0.5)); + const service = new LootService( + dataSourceWith(outOfOrder), + queuedRandom(0.5, 0.5), + ); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [{ itemDefinitionId: WORN_SHORT_SWORD, quantity: 1 }], @@ -107,13 +139,20 @@ describe('LootService', () => { queuedRandom(), ); - await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [] }); + await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ + items: [], + }); }); it('consumes no quantity roll when min and max match, and one when they differ', async () => { - const stackable = [entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 })]; + const stackable = [ + entry({ dropChance: '1.0000', minQuantity: 2, maxQuantity: 4 }), + ]; // First value drops the entry, second picks the quantity (0.5 -> 3). - const service = new LootService(dataSourceWith(stackable), queuedRandom(0.1, 0.5)); + const service = new LootService( + dataSourceWith(stackable), + queuedRandom(0.1, 0.5), + ); await expect(service.rollLoot(ASH_RAT_TABLE)).resolves.toEqual({ items: [{ itemDefinitionId: ASH_PELT, quantity: 3 }], @@ -121,7 +160,10 @@ describe('LootService', () => { }); it('returns nothing for a monster without a loot table', async () => { - const service = new LootService(dataSourceWith(ashRatEntries), queuedRandom()); + const service = new LootService( + dataSourceWith(ashRatEntries), + queuedRandom(), + ); await expect(service.rollLoot(null)).resolves.toEqual({ items: [] }); }); diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts index efc79aa..4a94aa6 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -34,9 +34,6 @@ export class MonsterDefinition { @Column({ name: 'armor', type: 'integer' }) armor!: number; - @Column({ name: 'experience_reward', type: 'integer' }) - experienceReward!: number; - @Column({ name: 'silver_min', type: 'integer' }) silverMin!: number; diff --git a/apps/api/src/renown/entities/character-renown-milestone.entity.ts b/apps/api/src/renown/entities/character-renown-milestone.entity.ts index 4bd0ae0..cc9a8a6 100644 --- a/apps/api/src/renown/entities/character-renown-milestone.entity.ts +++ b/apps/api/src/renown/entities/character-renown-milestone.entity.ts @@ -10,9 +10,13 @@ import { Character } from '../../characters/entities/character.entity'; import { RenownMilestoneDefinition } from './renown-milestone-definition.entity'; @Entity({ name: 'character_renown_milestones' }) -@Index('IDX_character_renown_milestones_character_milestone', ['characterId', 'milestoneId'], { - unique: true, -}) +@Index( + 'IDX_character_renown_milestones_character_milestone', + ['characterId', 'milestoneId'], + { + unique: true, + }, +) export class CharacterRenownMilestone { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/renown/renown-base-stats.spec.ts b/apps/api/src/renown/renown-base-stats.spec.ts index 2cc1b7a..7c5f557 100644 --- a/apps/api/src/renown/renown-base-stats.spec.ts +++ b/apps/api/src/renown/renown-base-stats.spec.ts @@ -4,9 +4,11 @@ describe('RENOWN_BASE_STATS', () => { it('spans exactly Renown 1 through 15', () => { expect(RENOWN_MIN).toBe(1); expect(RENOWN_MAX).toBe(15); - expect(Object.keys(RENOWN_BASE_STATS).map(Number).sort((a, b) => a - b)).toEqual( - Array.from({ length: 15 }, (_, i) => i + 1), - ); + expect( + Object.keys(RENOWN_BASE_STATS) + .map(Number) + .sort((a, b) => a - b), + ).toEqual(Array.from({ length: 15 }, (_, i) => i + 1)); }); it('matches the exact V1 reference table from spec §4', () => { diff --git a/apps/api/src/renown/renown-base-stats.ts b/apps/api/src/renown/renown-base-stats.ts index 1de0b59..6274521 100644 --- a/apps/api/src/renown/renown-base-stats.ts +++ b/apps/api/src/renown/renown-base-stats.ts @@ -5,7 +5,10 @@ export const RENOWN_MAX = 15; // total base-stat progression (100 HP / 6 Attack -> 148 HP / 12 Attack) // across Renown 1-15. RenownService looks this up on every milestone // completion; it is never incremented, only reassigned by rank (design R2). -export const RENOWN_BASE_STATS: Record = { +export const RENOWN_BASE_STATS: Record< + number, + { baseHp: number; baseAttack: number } +> = { 1: { baseHp: 100, baseAttack: 6 }, 2: { baseHp: 104, baseAttack: 6 }, 3: { baseHp: 107, baseAttack: 7 }, diff --git a/apps/api/src/renown/renown.module.ts b/apps/api/src/renown/renown.module.ts index 8c741b3..b2ad118 100644 --- a/apps/api/src/renown/renown.module.ts +++ b/apps/api/src/renown/renown.module.ts @@ -7,7 +7,11 @@ import { RenownService } from './renown.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Character, RenownMilestoneDefinition, CharacterRenownMilestone]), + TypeOrmModule.forFeature([ + Character, + RenownMilestoneDefinition, + CharacterRenownMilestone, + ]), ], providers: [RenownService], exports: [RenownService], diff --git a/apps/api/src/renown/renown.service.spec.ts b/apps/api/src/renown/renown.service.spec.ts index 86cafcb..972ccc9 100644 --- a/apps/api/src/renown/renown.service.spec.ts +++ b/apps/api/src/renown/renown.service.spec.ts @@ -21,15 +21,22 @@ class FakeRepository { private readonly inTransaction: boolean, ) {} - findOne(options: { where: Partial; lock?: { mode: string } }): Promise { + 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); + 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); + return Promise.resolve( + this.rows.find((row) => this.matches(row, where)) ?? null, + ); } create(values: Partial): T { @@ -50,7 +57,9 @@ class FakeRepository { } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } } @@ -61,27 +70,54 @@ class FakeDataSource { return this.repoFor(target, false); } - async transaction(work: (manager: EntityManager) => Promise): Promise { + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { return work({ - getRepository: (target: EntityTarget) => this.repoFor(target, true), + 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; + private repoFor( + target: EntityTarget, + inTransaction: boolean, + ) { + if (target === Character) + return new FakeRepository( + this.state.characters, + 'character', + inTransaction, + ) as never; if (target === RenownMilestoneDefinition) - return new FakeRepository(this.state.milestones, 'milestone', inTransaction) as never; + return new FakeRepository( + this.state.milestones, + 'milestone', + inTransaction, + ) as never; if (target === CharacterRenownMilestone) - return new FakeRepository(this.state.characterMilestones, 'char-milestone', inTransaction) as never; + return new FakeRepository( + this.state.characterMilestones, + 'char-milestone', + inTransaction, + ) as never; throw new Error('Unsupported repository'); } } function character(overrides: Partial = {}): Character { - return { id: CHARACTER_ID, renown: 1, baseHp: 100, baseAttack: 6, ...overrides } as Character; + return { + id: CHARACTER_ID, + renown: 1, + baseHp: 100, + baseAttack: 6, + ...overrides, + } as Character; } -function milestone(overrides: Partial = {}): RenownMilestoneDefinition { +function milestone( + overrides: Partial = {}, +): RenownMilestoneDefinition { return { id: MILESTONE_ID, key: 'first-hunt', @@ -105,10 +141,16 @@ function createState(overrides: Partial = {}): State { function createService(state: State) { const dataSource = new FakeDataSource(state); - return { service: new RenownService(dataSource as unknown as DataSource), state }; + return { + service: new RenownService(dataSource as unknown as DataSource), + state, + }; } -async function expectRenownDomainError(promise: Promise, code: string): Promise { +async function expectRenownDomainError( + promise: Promise, + code: string, +): Promise { let error: unknown; try { await promise; @@ -127,7 +169,10 @@ describe('RenownService', () => { it('grants renown and recomputes base stats from the power-curve table', async () => { const { service, state } = createService(createState()); - const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + const result = await service.completeMilestone( + CHARACTER_ID, + 'first-hunt', + ); expect(result).toEqual({ milestoneKey: 'first-hunt', @@ -167,11 +212,16 @@ describe('RenownService', () => { }); it('allows a repeatable milestone to grant renown again, incrementing timesCompleted', async () => { - const state = createState({ milestones: [milestone({ repeatable: true })] }); + const state = createState({ + milestones: [milestone({ repeatable: true })], + }); const { service } = createService(state); await service.completeMilestone(CHARACTER_ID, 'first-hunt'); - const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + const result = await service.completeMilestone( + CHARACTER_ID, + 'first-hunt', + ); expect(result.renownGranted).toBe(true); expect(result.newRenown).toBe(3); @@ -186,7 +236,10 @@ describe('RenownService', () => { }); const { service } = createService(state); - const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + const result = await service.completeMilestone( + CHARACTER_ID, + 'first-hunt', + ); expect(result).toEqual({ milestoneKey: 'first-hunt', @@ -207,7 +260,9 @@ describe('RenownService', () => { }); it('rejects a disabled milestone', async () => { - const state = createState({ milestones: [milestone({ enabled: false })] }); + const state = createState({ + milestones: [milestone({ enabled: false })], + }); const { service } = createService(state); await expectRenownDomainError( diff --git a/apps/api/src/renown/renown.service.ts b/apps/api/src/renown/renown.service.ts index 7f11aed..9850087 100644 --- a/apps/api/src/renown/renown.service.ts +++ b/apps/api/src/renown/renown.service.ts @@ -33,7 +33,9 @@ export class RenownService { milestoneKey: string, manager?: EntityManager, ): Promise { - const run = async (txManager: EntityManager): Promise => { + const run = async ( + txManager: EntityManager, + ): Promise => { const characters = txManager.getRepository(Character); const milestones = txManager.getRepository(RenownMilestoneDefinition); const completions = txManager.getRepository(CharacterRenownMilestone); @@ -63,7 +65,10 @@ export class RenownService { } const previousRenown = character.renown; - const newRenown = Math.min(RENOWN_MAX, previousRenown + milestone.renownReward); + const newRenown = Math.min( + RENOWN_MAX, + previousRenown + milestone.renownReward, + ); const renownGranted = newRenown !== previousRenown; if (renownGranted) { diff --git a/apps/api/src/reputation/entities/character-reputation.entity.ts b/apps/api/src/reputation/entities/character-reputation.entity.ts index 6f3cb5f..fe8870a 100644 --- a/apps/api/src/reputation/entities/character-reputation.entity.ts +++ b/apps/api/src/reputation/entities/character-reputation.entity.ts @@ -12,9 +12,13 @@ import { Character } from '../../characters/entities/character.entity'; import { ReputationFaction } from './reputation-faction.entity'; @Entity({ name: 'character_reputation' }) -@Index('IDX_character_reputation_character_faction', ['characterId', 'factionId'], { - unique: true, -}) +@Index( + 'IDX_character_reputation_character_faction', + ['characterId', 'factionId'], + { + unique: true, + }, +) export class CharacterReputation { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/reputation/reputation-rank.ts b/apps/api/src/reputation/reputation-rank.ts index f688506..f7fe130 100644 --- a/apps/api/src/reputation/reputation-rank.ts +++ b/apps/api/src/reputation/reputation-rank.ts @@ -7,7 +7,11 @@ export interface ReputationRankInfo { // Verbatim thresholds from spec §10, German labels for the German-language UI. // Sorted descending: the first entry whose threshold <= reputation wins. -const REPUTATION_RANKS: ReadonlyArray<{ threshold: number; key: string; label: string }> = [ +const REPUTATION_RANKS: ReadonlyArray<{ + threshold: number; + key: string; + label: string; +}> = [ { threshold: 1200, key: 'ESTEEMED', label: 'Geachtet' }, { threshold: 800, key: 'TRUSTED', label: 'Vertraut' }, { threshold: 500, key: 'RECOGNIZED', label: 'Anerkannt' }, @@ -17,7 +21,9 @@ const REPUTATION_RANKS: ReadonlyArray<{ threshold: number; key: string; label: s ]; export function resolveReputationRank(reputation: number): ReputationRankInfo { - const index = REPUTATION_RANKS.findIndex((rank) => reputation >= rank.threshold); + const index = REPUTATION_RANKS.findIndex( + (rank) => reputation >= rank.threshold, + ); const rank = REPUTATION_RANKS[index]; const nextRank = index > 0 ? REPUTATION_RANKS[index - 1] : null; diff --git a/apps/api/src/reputation/reputation.controller.spec.ts b/apps/api/src/reputation/reputation.controller.spec.ts index d002381..17fd3e8 100644 --- a/apps/api/src/reputation/reputation.controller.spec.ts +++ b/apps/api/src/reputation/reputation.controller.spec.ts @@ -15,7 +15,9 @@ describe('ReputationController', () => { getCharacterReputation.mockReset(); const module = await Test.createTestingModule({ controllers: [ReputationController], - providers: [{ provide: ReputationService, useValue: { getCharacterReputation } }], + providers: [ + { provide: ReputationService, useValue: { getCharacterReputation } }, + ], }).compile(); app = module.createNestApplication(); @@ -40,7 +42,9 @@ describe('ReputationController', () => { ]; getCharacterReputation.mockResolvedValue(entries); - const response = await request(app.getHttpServer()).get('/api/reputation').expect(200); + const response = await request(app.getHttpServer()) + .get('/api/reputation') + .expect(200); expect(getCharacterReputation).toHaveBeenCalledWith(DEMO_CHARACTER_ID); expect(response.body).toEqual(entries); diff --git a/apps/api/src/reputation/reputation.controller.ts b/apps/api/src/reputation/reputation.controller.ts index 0e77293..d71d3f7 100644 --- a/apps/api/src/reputation/reputation.controller.ts +++ b/apps/api/src/reputation/reputation.controller.ts @@ -1,6 +1,9 @@ import { Controller, Get } from '@nestjs/common'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; -import { CharacterReputationDto, ReputationService } from './reputation.service'; +import { + CharacterReputationDto, + ReputationService, +} from './reputation.service'; @Controller('reputation') export class ReputationController { diff --git a/apps/api/src/reputation/reputation.service.spec.ts b/apps/api/src/reputation/reputation.service.spec.ts index cbb691e..b87a7bc 100644 --- a/apps/api/src/reputation/reputation.service.spec.ts +++ b/apps/api/src/reputation/reputation.service.spec.ts @@ -22,20 +22,29 @@ class FakeRepository { private readonly inTransaction: boolean, ) {} - findOne(options: { where: Partial; lock?: { mode: string } }): Promise { + 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); + 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); + 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], + options.where + ? this.rows.filter((row) => this.matches(row, options.where!)) + : [...this.rows], ); } @@ -57,7 +66,9 @@ class FakeRepository { } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } } @@ -68,19 +79,37 @@ class FakeDataSource { return this.repoFor(target, false); } - async transaction(work: (manager: EntityManager) => Promise): Promise { + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { return work({ - getRepository: (target: EntityTarget) => this.repoFor(target, true), + getRepository: (target: EntityTarget) => + this.repoFor(target, true), } as unknown as EntityManager); } - private repoFor(target: EntityTarget, inTransaction: boolean) { + private repoFor( + target: EntityTarget, + inTransaction: boolean, + ) { if (target === Character) - return new FakeRepository(this.state.characters, 'character', inTransaction) as never; + return new FakeRepository( + this.state.characters, + 'character', + inTransaction, + ) as never; if (target === ReputationFaction) - return new FakeRepository(this.state.factions, 'faction', inTransaction) as never; + return new FakeRepository( + this.state.factions, + 'faction', + inTransaction, + ) as never; if (target === CharacterReputation) - return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never; + return new FakeRepository( + this.state.characterReputation, + 'char-rep', + inTransaction, + ) as never; throw new Error('Unsupported repository'); } } @@ -89,7 +118,9 @@ function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, ...overrides } as Character; } -function faction(overrides: Partial = {}): ReputationFaction { +function faction( + overrides: Partial = {}, +): ReputationFaction { return { id: FACTION_ID, key: 'border-guard', @@ -102,15 +133,26 @@ function faction(overrides: Partial = {}): ReputationFaction } function createState(overrides: Partial = {}): State { - return { characters: [character()], factions: [faction()], characterReputation: [], ...overrides }; + return { + characters: [character()], + factions: [faction()], + characterReputation: [], + ...overrides, + }; } function createService(state: State) { const dataSource = new FakeDataSource(state); - return { service: new ReputationService(dataSource as unknown as DataSource), state }; + return { + service: new ReputationService(dataSource as unknown as DataSource), + state, + }; } -async function expectReputationDomainError(promise: Promise, code: string): Promise { +async function expectReputationDomainError( + promise: Promise, + code: string, +): Promise { let error: unknown; try { await promise; @@ -154,7 +196,11 @@ describe('ReputationService', () => { it('creates a reputation row starting from 0 on the first grant', async () => { const { service, state } = createService(createState()); - const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 40); + const result = await service.grantReputation( + CHARACTER_ID, + 'border-guard', + 40, + ); expect(result).toEqual({ factionKey: 'border-guard', @@ -171,12 +217,21 @@ describe('ReputationService', () => { it('accumulates onto an existing reputation row', async () => { const state = createState({ characterReputation: [ - { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation, + { + id: 'existing', + characterId: CHARACTER_ID, + factionId: FACTION_ID, + reputation: 90, + } as CharacterReputation, ], }); const { service } = createService(state); - const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20); + const result = await service.grantReputation( + CHARACTER_ID, + 'border-guard', + 20, + ); expect(result.previousReputation).toBe(90); expect(result.newReputation).toBe(110); @@ -185,12 +240,21 @@ describe('ReputationService', () => { it('reports rankChanged when a grant crosses a threshold', async () => { const state = createState({ characterReputation: [ - { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90 } as CharacterReputation, + { + id: 'existing', + characterId: CHARACTER_ID, + factionId: FACTION_ID, + reputation: 90, + } as CharacterReputation, ], }); const { service } = createService(state); - const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 20); + const result = await service.grantReputation( + CHARACTER_ID, + 'border-guard', + 20, + ); expect(result.previousRank).toBe('STRANGER'); expect(result.newRank).toBe('TOLERATED'); @@ -200,12 +264,21 @@ describe('ReputationService', () => { it('reports rankChanged: false when a grant stays within the same rank', async () => { const state = createState({ characterReputation: [ - { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 120 } as CharacterReputation, + { + id: 'existing', + characterId: CHARACTER_ID, + factionId: FACTION_ID, + reputation: 120, + } as CharacterReputation, ], }); const { service } = createService(state); - const result = await service.grantReputation(CHARACTER_ID, 'border-guard', 10); + const result = await service.grantReputation( + CHARACTER_ID, + 'border-guard', + 10, + ); expect(result.rankChanged).toBe(false); }); diff --git a/apps/api/src/reputation/reputation.service.ts b/apps/api/src/reputation/reputation.service.ts index 0edbc58..0b41b45 100644 --- a/apps/api/src/reputation/reputation.service.ts +++ b/apps/api/src/reputation/reputation.service.ts @@ -3,7 +3,10 @@ import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { CharacterReputation } from './entities/character-reputation.entity'; import { ReputationFaction } from './entities/reputation-faction.entity'; -import { characterNotFound, reputationFactionNotFound } from './reputation.errors'; +import { + characterNotFound, + reputationFactionNotFound, +} from './reputation.errors'; import { resolveReputationRank } from './reputation-rank'; export interface ReputationGrantResult { @@ -37,7 +40,9 @@ export class ReputationService { amount: number, manager?: EntityManager, ): Promise { - const run = async (txManager: EntityManager): Promise => { + const run = async ( + txManager: EntityManager, + ): Promise => { const characters = txManager.getRepository(Character); const factions = txManager.getRepository(ReputationFaction); const reputations = txManager.getRepository(CharacterReputation); @@ -74,7 +79,11 @@ export class ReputationService { await reputations.save(existing); } else { await reputations.save( - reputations.create({ characterId, factionId: faction.id, reputation: newReputation }), + reputations.create({ + characterId, + factionId: faction.id, + reputation: newReputation, + }), ); } @@ -99,10 +108,16 @@ export class ReputationService { * never interacted with reads as 0 Reputation / Stranger (spec §36, * design R10), not as an absent entry. */ - async getCharacterReputation(characterId: string): Promise { + async getCharacterReputation( + characterId: string, + ): Promise { const scope: RepositoryScope = this.dataSource; - const factions = await scope.getRepository(ReputationFaction).find({ where: { enabled: true } }); - const reputations = await scope.getRepository(CharacterReputation).find({ where: { characterId } }); + const factions = await scope + .getRepository(ReputationFaction) + .find({ where: { enabled: true } }); + const reputations = await scope + .getRepository(CharacterReputation) + .find({ where: { characterId } }); return factions.map((faction) => { const existing = reputations.find((row) => row.factionId === faction.id); diff --git a/apps/api/src/rewards/combat-reward.service.spec.ts b/apps/api/src/rewards/combat-reward.service.spec.ts index 92967a8..5962e7f 100644 --- a/apps/api/src/rewards/combat-reward.service.spec.ts +++ b/apps/api/src/rewards/combat-reward.service.spec.ts @@ -39,11 +39,15 @@ class FakeRepository { ) {} findOne(options: { where: Partial }): Promise { - return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); + 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); + return Promise.resolve( + this.rows.find((row) => this.matches(row, where)) ?? null, + ); } find(options: { @@ -57,7 +61,10 @@ class FakeRepository { // Mirrors TypeORM's `order` clause so tests can prove ordering comes from // the query, not from insertion order happening to line up. - const [key, direction] = Object.entries(options.order)[0] as [keyof T, 'ASC' | 'DESC']; + const [key, direction] = Object.entries(options.order)[0] as [ + keyof T, + 'ASC' | 'DESC', + ]; const sorted = [...matched].sort((a, b) => { const left = a[key]; const right = b[key]; @@ -85,23 +92,33 @@ class FakeRepository { } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } } function fakeManager(state: State): EntityManager { return { getRepository: (target: EntityTarget) => { - if (target === Character) return new FakeRepository(state.characters, 'character') as never; - if (target === MonsterDefinition) return new FakeRepository(state.monsters, 'monster') as never; + if (target === Character) + return new FakeRepository(state.characters, 'character') as never; + if (target === MonsterDefinition) + return new FakeRepository(state.monsters, 'monster') as never; if (target === ItemDefinition) return new FakeRepository(state.itemDefinitions, 'definition') as never; if (target === CharacterItem) - return new FakeRepository(state.characterItems, 'character-item') as never; + return new FakeRepository( + state.characterItems, + 'character-item', + ) as never; if (target === CombatReward) return new FakeRepository(state.combatRewards, 'reward') as never; if (target === CombatRewardItem) - return new FakeRepository(state.combatRewardItems, 'reward-item') as never; + return new FakeRepository( + state.combatRewardItems, + 'reward-item', + ) as never; throw new Error('Unsupported repository'); }, } as unknown as EntityManager; @@ -162,8 +179,12 @@ function fakeDataSource(state: State): EntityManager { return fakeManager(state); } -function fakeLoot(...items: Array<{ itemDefinitionId: string; quantity: number }>): LootService { - return { rollLoot: jest.fn().mockResolvedValue({ items }) } as unknown as LootService; +function fakeLoot( + ...items: Array<{ itemDefinitionId: string; quantity: number }> +): LootService { + return { + rollLoot: jest.fn().mockResolvedValue({ items }), + } as unknown as LootService; } function fixedRandom(value: number): RandomSource { @@ -184,7 +205,10 @@ describe('CombatRewardService', () => { const state = createState(); await expect( - service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.ACTIVE })), + service(state).grantVictoryRewards( + fakeManager(state), + combat({ status: CombatStatus.ACTIVE }), + ), ).rejects.toMatchObject({ code: 'COMBAT_NOT_WON' }); expect(state.combatRewards).toHaveLength(0); expect(state.characters[0].silver).toBe(3); @@ -194,7 +218,10 @@ describe('CombatRewardService', () => { const state = createState(); await expect( - service(state).grantVictoryRewards(fakeManager(state), combat({ status: CombatStatus.LOST })), + service(state).grantVictoryRewards( + fakeManager(state), + combat({ status: CombatStatus.LOST }), + ), ).rejects.toBeInstanceOf(RewardDomainError); expect(state.combatRewards).toHaveLength(0); }); @@ -202,7 +229,10 @@ describe('CombatRewardService', () => { it('grants rewards for a WON combat', async () => { const state = createState(); - const reward = await service(state).grantVictoryRewards(fakeManager(state), combat()); + const reward = await service(state).grantVictoryRewards( + fakeManager(state), + combat(), + ); expect(reward).toEqual({ silver: 6, items: [] }); expect(state.combatRewards).toHaveLength(1); @@ -213,10 +243,11 @@ describe('CombatRewardService', () => { it('grants a silver roll inside 4-7, persisted on the character', async () => { const state = createState(); - const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( - fakeManager(state), - combat(), - ); + const reward = await service( + state, + fakeLoot(), + fixedRandom(0), + ).grantVictoryRewards(fakeManager(state), combat()); expect(reward.silver).toBe(4); expect(state.characters[0].silver).toBe(7); @@ -225,10 +256,11 @@ describe('CombatRewardService', () => { it('rolls the top of the silver range from the top of the random range', async () => { const state = createState(); - const reward = await service(state, fakeLoot(), fixedRandom(0.99)).grantVictoryRewards( - fakeManager(state), - combat(), - ); + const reward = await service( + state, + fakeLoot(), + fixedRandom(0.99), + ).grantVictoryRewards(fakeManager(state), combat()); expect(reward.silver).toBe(7); }); @@ -240,10 +272,11 @@ describe('CombatRewardService', () => { it('grants a silver roll inside 9-15', async () => { const state = createState(); - const reward = await service(state, fakeLoot(), fixedRandom(0)).grantVictoryRewards( - fakeManager(state), - banditCombat, - ); + const reward = await service( + state, + fakeLoot(), + fixedRandom(0), + ).grantVictoryRewards(fakeManager(state), banditCombat); expect(reward.silver).toBe(9); }); @@ -361,7 +394,11 @@ describe('CombatRewardService', () => { const state = createState(); const loot = fakeLoot({ itemDefinitionId: BANDIT_BLADE, quantity: 1 }); const dataSource = fakeDataSource(state); - const subject = new CombatRewardService(dataSource as never, loot, fixedRandom(0)); + const subject = new CombatRewardService( + dataSource as never, + loot, + fixedRandom(0), + ); const manager = fakeManager(state); const granted = await subject.grantVictoryRewards(manager, combat()); @@ -379,10 +416,10 @@ describe('CombatRewardService', () => { const state = createState(); await expect( - service(state, fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 })).grantVictoryRewards( - fakeManager(state), - combat(), - ), + service( + state, + fakeLoot({ itemDefinitionId: 'missing-item', quantity: 1 }), + ).grantVictoryRewards(fakeManager(state), combat()), ).rejects.toMatchObject({ code: 'REWARD_STATE_INVALID' }); expect(state.combatRewardItems).toHaveLength(0); // Every rolled item definition is resolved before any mutation, so a @@ -430,7 +467,9 @@ describe('CombatRewardService', () => { const expectedKeys = ['bandit-hood', 'bandit-blade']; expect(granted.items.map((item) => item.item.key)).toEqual(expectedKeys); - expect(replayed?.items.map((item) => item.item.key)).toEqual(expectedKeys); + expect(replayed?.items.map((item) => item.item.key)).toEqual( + expectedKeys, + ); expect(replayed).toEqual(granted); }); }); diff --git a/apps/api/src/rewards/combat-reward.service.ts b/apps/api/src/rewards/combat-reward.service.ts index fe030d6..349047e 100644 --- a/apps/api/src/rewards/combat-reward.service.ts +++ b/apps/api/src/rewards/combat-reward.service.ts @@ -122,7 +122,10 @@ export class CombatRewardService { const characterItems = manager.getRepository(CharacterItem); const rewardItems = manager.getRepository(CombatRewardItem); - const granted: Array<{ itemDefinitionId: string; dto: CombatRewardItemDto }> = []; + const granted: Array<{ + itemDefinitionId: string; + dto: CombatRewardItemDto; + }> = []; for (const rolled of roll.items) { const definition = resolvedDefinitions.get(rolled.itemDefinitionId)!; @@ -164,7 +167,9 @@ export class CombatRewardService { // The immediate response and a later `loadRewards` replay must agree on // item order; both sort on the same stable key (itemDefinitionId, which // `toDto`'s query also orders by) rather than roll order. - granted.sort((a, b) => a.itemDefinitionId.localeCompare(b.itemDefinitionId)); + granted.sort((a, b) => + a.itemDefinitionId.localeCompare(b.itemDefinitionId), + ); const items = granted.map((entry) => entry.dto); return { silver, items }; @@ -190,12 +195,10 @@ export class CombatRewardService { // Ordered by itemDefinitionId to agree with the sort `grantVictoryRewards` // applies to its own response — the immediate grant and a later replay // must list items identically. - const rewardItems = await scope - .getRepository(CombatRewardItem) - .find({ - where: { combatRewardId: reward.id }, - order: { itemDefinitionId: 'ASC' }, - }); + const rewardItems = await scope.getRepository(CombatRewardItem).find({ + where: { combatRewardId: reward.id }, + order: { itemDefinitionId: 'ASC' }, + }); const definitions = scope.getRepository(ItemDefinition); const items: CombatRewardItemDto[] = []; @@ -208,7 +211,11 @@ export class CombatRewardService { throw rewardStateInvalid(); } items.push( - this.toItemDto(rewardItem.characterItemId, definition, rewardItem.quantity), + this.toItemDto( + rewardItem.characterItemId, + definition, + rewardItem.quantity, + ), ); } 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 ec862c8..13804e5 100644 --- a/apps/api/src/rewards/entities/combat-reward-item.entity.ts +++ b/apps/api/src/rewards/entities/combat-reward-item.entity.ts @@ -20,9 +20,13 @@ import { CombatReward } from './combat-reward.entity'; */ @Entity({ name: 'combat_reward_items' }) @Index('IDX_combat_reward_items_reward', ['combatRewardId']) -@Index('IDX_combat_reward_items_reward_item', ['combatRewardId', 'itemDefinitionId'], { - unique: true, -}) +@Index( + 'IDX_combat_reward_items_reward_item', + ['combatRewardId', 'itemDefinitionId'], + { + unique: true, + }, +) export class CombatRewardItem { @PrimaryGeneratedColumn('uuid', { name: 'id' }) id!: string; diff --git a/apps/api/src/turn-in/turn-in.controller.spec.ts b/apps/api/src/turn-in/turn-in.controller.spec.ts index 9cb5129..426f4d2 100644 --- a/apps/api/src/turn-in/turn-in.controller.spec.ts +++ b/apps/api/src/turn-in/turn-in.controller.spec.ts @@ -48,14 +48,23 @@ describe('TurnInController', () => { .send({ turnInKey: 'ash-pelt-border-guard', quantity: 3 }) .expect(201); - expect(turnIn).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'ash-pelt-border-guard', 3); + 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 }) + .send({ + turnInKey: 'ash-pelt-border-guard', + quantity: 3, + silverGranted: 999, + reputationReward: 999, + }) .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 index c5effa2..1e3aabe 100644 --- a/apps/api/src/turn-in/turn-in.controller.ts +++ b/apps/api/src/turn-in/turn-in.controller.ts @@ -9,6 +9,10 @@ export class TurnInController { @Post() turnIn(@Body() request: TurnInDto): Promise { - return this.turnInService.turnIn(DEMO_CHARACTER_ID, request.turnInKey, request.quantity); + return this.turnInService.turnIn( + DEMO_CHARACTER_ID, + request.turnInKey, + request.quantity, + ); } } diff --git a/apps/api/src/turn-in/turn-in.module.ts b/apps/api/src/turn-in/turn-in.module.ts index e05dd62..23f9c4e 100644 --- a/apps/api/src/turn-in/turn-in.module.ts +++ b/apps/api/src/turn-in/turn-in.module.ts @@ -10,7 +10,12 @@ import { TurnInService } from './turn-in.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Character, CharacterItem, TurnInDefinition, ReputationFaction]), + TypeOrmModule.forFeature([ + Character, + CharacterItem, + TurnInDefinition, + ReputationFaction, + ]), ReputationModule, ], controllers: [TurnInController], diff --git a/apps/api/src/turn-in/turn-in.service.spec.ts b/apps/api/src/turn-in/turn-in.service.spec.ts index 8bf9405..6ce1b87 100644 --- a/apps/api/src/turn-in/turn-in.service.spec.ts +++ b/apps/api/src/turn-in/turn-in.service.spec.ts @@ -29,20 +29,29 @@ class FakeRepository { private readonly inTransaction: boolean, ) {} - findOne(options: { where: Partial; lock?: { mode: string } }): Promise { + 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); + 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); + 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], + options.where + ? this.rows.filter((row) => this.matches(row, options.where!)) + : [...this.rows], ); } @@ -72,7 +81,9 @@ class FakeRepository { } private matches(row: T, where: Partial): boolean { - return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + return Object.entries(where).every( + ([key, value]) => row[key as keyof T] === value, + ); } } @@ -83,22 +94,49 @@ class FakeDataSource { return this.repoFor(target, false); } - async transaction(work: (manager: EntityManager) => Promise): Promise { + async transaction( + work: (manager: EntityManager) => Promise, + ): Promise { return work({ - getRepository: (target: EntityTarget) => this.repoFor(target, true), + 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; + 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; + 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; + return new FakeRepository( + this.state.turnIns, + 'turn-in', + inTransaction, + ) as never; if (target === ReputationFaction) - return new FakeRepository(this.state.factions, 'faction', inTransaction) as never; + return new FakeRepository( + this.state.factions, + 'faction', + inTransaction, + ) as never; if (target === CharacterReputation) - return new FakeRepository(this.state.characterReputation, 'char-rep', inTransaction) as never; + return new FakeRepository( + this.state.characterReputation, + 'char-rep', + inTransaction, + ) as never; throw new Error('Unsupported repository'); } } @@ -117,7 +155,9 @@ function characterItem(overrides: Partial = {}): CharacterItem { } as CharacterItem; } -function turnInDefinition(overrides: Partial = {}): TurnInDefinition { +function turnInDefinition( + overrides: Partial = {}, +): TurnInDefinition { return { id: 'turn-in-1', key: 'ash-pelt-border-guard', @@ -131,8 +171,16 @@ function turnInDefinition(overrides: Partial = {}): TurnInDefi } as TurnInDefinition; } -function faction(overrides: Partial = {}): ReputationFaction { - return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', enabled: true, ...overrides } as ReputationFaction; +function faction( + overrides: Partial = {}, +): ReputationFaction { + return { + id: FACTION_ID, + key: 'border-guard', + name: 'Grenzwacht', + enabled: true, + ...overrides, + } as ReputationFaction; } function createState(overrides: Partial = {}): State { @@ -148,14 +196,22 @@ function createState(overrides: Partial = {}): State { function createService(state: State) { const dataSource = new FakeDataSource(state); - const reputationService = new ReputationService(dataSource as unknown as DataSource); + const reputationService = new ReputationService( + dataSource as unknown as DataSource, + ); return { - service: new TurnInService(dataSource as unknown as DataSource, reputationService), + service: new TurnInService( + dataSource as unknown as DataSource, + reputationService, + ), state, }; } -async function expectTurnInDomainError(promise: Promise, code: string): Promise { +async function expectTurnInDomainError( + promise: Promise, + code: string, +): Promise { let error: unknown; try { await promise; @@ -198,7 +254,11 @@ describe('TurnInService', () => { 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); + const result = await service.turnIn( + CHARACTER_ID, + 'ash-pelt-border-guard', + 3, + ); expect(result).toEqual({ turnInKey: 'ash-pelt-border-guard', @@ -219,7 +279,9 @@ describe('TurnInService', () => { }); it('deletes the CharacterItem row once its quantity reaches 0', async () => { - const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 3 })] })); + const { service, state } = createService( + createState({ characterItems: [characterItem({ quantity: 3 })] }), + ); await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3); @@ -227,7 +289,9 @@ describe('TurnInService', () => { }); it('rejects turning in more than the character owns, mutating nothing', async () => { - const { service, state } = createService(createState({ characterItems: [characterItem({ quantity: 2 })] })); + const { service, state } = createService( + createState({ characterItems: [characterItem({ quantity: 2 })] }), + ); await expectTurnInDomainError( service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 3), @@ -271,7 +335,9 @@ describe('TurnInService', () => { }); it('rejects a disabled turn-in', async () => { - const state = createState({ turnIns: [turnInDefinition({ enabled: false })] }); + const state = createState({ + turnIns: [turnInDefinition({ enabled: false })], + }); const { service } = createService(state); await expectTurnInDomainError( @@ -282,11 +348,20 @@ describe('TurnInService', () => { it('computes multi-item rewards server-side from the definition, not from client input', async () => { const state = createState({ - turnIns: [turnInDefinition({ silverRewardPerItem: 12, reputationRewardPerItem: 4 })], + turnIns: [ + turnInDefinition({ + silverRewardPerItem: 12, + reputationRewardPerItem: 4, + }), + ], }); const { service, state: resultState } = createService(state); - const result = await service.turnIn(CHARACTER_ID, 'ash-pelt-border-guard', 5); + const result = await service.turnIn( + CHARACTER_ID, + 'ash-pelt-border-guard', + 5, + ); expect(result.silverGranted).toBe(60); expect(result.reputationResult.newReputation).toBe(20); diff --git a/apps/api/src/turn-in/turn-in.service.ts b/apps/api/src/turn-in/turn-in.service.ts index a39aef2..d788102 100644 --- a/apps/api/src/turn-in/turn-in.service.ts +++ b/apps/api/src/turn-in/turn-in.service.ts @@ -3,7 +3,10 @@ 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 { + ReputationGrantResult, + ReputationService, +} from '../reputation/reputation.service'; import { TurnInDefinition } from './entities/turn-in-definition.entity'; import { characterNotFound, @@ -33,7 +36,11 @@ export class TurnInService { * 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 { + async turnIn( + characterId: string, + turnInKey: string, + quantity: number, + ): Promise { if (!Number.isInteger(quantity) || quantity <= 0) { throw turnInInvalidQuantity(); } @@ -90,7 +97,12 @@ export class TurnInService { manager, ); - return { turnInKey, quantityConsumed: quantity, silverGranted, reputationResult }; + return { + turnInKey, + quantityConsumed: quantity, + silverGranted, + reputationResult, + }; }); } } diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts index cd54a2a..e89e4d2 100644 --- a/apps/api/src/world/local-location-interaction.spec.ts +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -115,9 +115,7 @@ describe('WorldService.runLocalInteraction', () => { }); it('settles travel before resolving which location the character stands at', async () => { - const completeTravelIfDue = jest - .fn() - .mockResolvedValue({ status: 'IDLE' }); + const completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }); const service = createService( location(BURNED_ROAD_ID, BURNED_ROAD_POIS), completeTravelIfDue, @@ -155,6 +153,8 @@ describe('WorldService.runLocalInteraction', () => { it('rejects a navigation hotspot that has no result to reveal', async () => { const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); - await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area')); + await expectRejected( + service.runLocalInteraction(CHARACTER_ID, 'hunt-area'), + ); }); }); diff --git a/apps/api/src/world/world.controller.spec.ts b/apps/api/src/world/world.controller.spec.ts index 9ecea5d..b657634 100644 --- a/apps/api/src/world/world.controller.spec.ts +++ b/apps/api/src/world/world.controller.spec.ts @@ -4,7 +4,9 @@ import { WorldService } from './world.service'; describe('WorldController', () => { it('resolves the current location for the acting character', () => { - const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' }); + const getCurrentLocation = jest + .fn() + .mockResolvedValue({ key: 'burned-road' }); const controller = new WorldController({ getCurrentLocation, } as unknown as WorldService); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 7e61b00..904201e 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -2,10 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; -import { - calculateDangerRating, - DangerRating, -} from '../hunting/danger-rating'; +import { calculateDangerRating, DangerRating } from '../hunting/danger-rating'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; import { LocationConnection } from './entities/location-connection.entity'; @@ -107,9 +104,8 @@ export class WorldService { localArtworkPath: location.localArtworkPath, dangerRating: this.toLocalDangerRating(character, pool), recommendationLabel: this.toRecommendationLabel(location), - pointsOfInterest: location.localPointsOfInterest.map( - toPointOfInterestDto, - ), + pointsOfInterest: + location.localPointsOfInterest.map(toPointOfInterestDto), primaryActions: location.localPrimaryActions, encounterPreview: pool.map((entry) => ({ key: entry.monster.key,