From 4c9397336f8aef840df68dcbdc9746f99d42afcf Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 09:14:36 +0200 Subject: [PATCH] feat(renown): add RenownService with milestone completion and base-stat recomputation --- apps/api/src/app.module.ts | 2 + apps/api/src/renown/renown.errors.ts | 54 +++++ apps/api/src/renown/renown.module.ts | 15 ++ apps/api/src/renown/renown.service.spec.ts | 228 +++++++++++++++++++++ apps/api/src/renown/renown.service.ts | 97 +++++++++ 5 files changed, 396 insertions(+) create mode 100644 apps/api/src/renown/renown.errors.ts create mode 100644 apps/api/src/renown/renown.module.ts create mode 100644 apps/api/src/renown/renown.service.spec.ts create mode 100644 apps/api/src/renown/renown.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 79d5f80..22c6047 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { EquipmentModule } from './equipment/equipment.module'; import { HealthModule } from './health/health.module'; import { HuntingModule } from './hunting/hunting.module'; import { InventoryModule } from './inventory/inventory.module'; +import { RenownModule } from './renown/renown.module'; import { TravelModule } from './travel/travel.module'; import { WorldModule } from './world/world.module'; @@ -20,6 +21,7 @@ import { WorldModule } from './world/world.module'; CombatModule, EquipmentModule, InventoryModule, + RenownModule, ], }) export class AppModule {} diff --git a/apps/api/src/renown/renown.errors.ts b/apps/api/src/renown/renown.errors.ts new file mode 100644 index 0000000..13aac8a --- /dev/null +++ b/apps/api/src/renown/renown.errors.ts @@ -0,0 +1,54 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type RenownErrorCode = + | 'RENOWN_MILESTONE_NOT_FOUND' + | 'RENOWN_MILESTONE_DISABLED' + | 'RENOWN_MILESTONE_ALREADY_COMPLETED' + | 'CHARACTER_NOT_FOUND'; + +export class RenownDomainError extends HttpException { + constructor( + public readonly code: RenownErrorCode, + status: HttpStatus, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +export function renownMilestoneNotFound(): RenownDomainError { + return new RenownDomainError( + 'RENOWN_MILESTONE_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'This Renown milestone could not be found.', + ); +} + +export function renownMilestoneDisabled(): RenownDomainError { + return new RenownDomainError( + 'RENOWN_MILESTONE_DISABLED', + HttpStatus.CONFLICT, + 'This Renown milestone is not currently active.', + ); +} + +export function renownMilestoneAlreadyCompleted(): RenownDomainError { + return new RenownDomainError( + 'RENOWN_MILESTONE_ALREADY_COMPLETED', + HttpStatus.CONFLICT, + 'This Renown milestone has already been completed.', + ); +} + +// NOT re-exported from ../travel/travel.errors: that module's characterNotFound() +// throws a TravelDomainError, which is a distinct class from RenownDomainError. +// RenownService callers (and this domain's own error-handling code) rely on +// `instanceof RenownDomainError` to catch all Renown-domain failures uniformly, +// so CHARACTER_NOT_FOUND is redeclared locally as a RenownDomainError here. +export function characterNotFound(): RenownDomainError { + return new RenownDomainError( + 'CHARACTER_NOT_FOUND', + HttpStatus.NOT_FOUND, + 'The character does not exist.', + ); +} diff --git a/apps/api/src/renown/renown.module.ts b/apps/api/src/renown/renown.module.ts new file mode 100644 index 0000000..8c741b3 --- /dev/null +++ b/apps/api/src/renown/renown.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity'; +import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity'; +import { RenownService } from './renown.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Character, RenownMilestoneDefinition, CharacterRenownMilestone]), + ], + providers: [RenownService], + exports: [RenownService], +}) +export class RenownModule {} diff --git a/apps/api/src/renown/renown.service.spec.ts b/apps/api/src/renown/renown.service.spec.ts new file mode 100644 index 0000000..86cafcb --- /dev/null +++ b/apps/api/src/renown/renown.service.spec.ts @@ -0,0 +1,228 @@ +import { DataSource, EntityManager, EntityTarget } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity'; +import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity'; +import { RenownDomainError } from './renown.errors'; +import { RenownService } from './renown.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const MILESTONE_ID = '90000000-0000-4000-8000-000000000001'; + +interface State { + characters: Character[]; + milestones: RenownMilestoneDefinition[]; + characterMilestones: CharacterRenownMilestone[]; +} + +class FakeRepository { + constructor( + private readonly rows: T[], + private readonly prefix: string, + private readonly inTransaction: boolean, + ) {} + + findOne(options: { where: Partial; lock?: { mode: string } }): Promise { + if (options.lock && !this.inTransaction) { + throw new Error('Pessimistic locks require a transaction'); + } + return Promise.resolve(this.rows.find((row) => this.matches(row, options.where)) ?? null); + } + + findOneBy(where: Partial): Promise { + return Promise.resolve(this.rows.find((row) => this.matches(row, where)) ?? null); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + if (!entity.id) { + entity.id = `${this.prefix}-${this.rows.length + 1}`; + } + const index = this.rows.findIndex((row) => row.id === entity.id); + if (index === -1) { + this.rows.push(entity); + } else { + this.rows[index] = entity; + } + return Promise.resolve(entity); + } + + private matches(row: T, where: Partial): boolean { + return Object.entries(where).every(([key, value]) => row[key as keyof T] === value); + } +} + +class FakeDataSource { + constructor(public state: State) {} + + getRepository(target: EntityTarget) { + return this.repoFor(target, false); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + return work({ + getRepository: (target: EntityTarget) => this.repoFor(target, true), + } as unknown as EntityManager); + } + + private repoFor(target: EntityTarget, inTransaction: boolean) { + if (target === Character) return new FakeRepository(this.state.characters, 'character', inTransaction) as never; + if (target === RenownMilestoneDefinition) + return new FakeRepository(this.state.milestones, 'milestone', inTransaction) as never; + if (target === CharacterRenownMilestone) + 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; +} + +function milestone(overrides: Partial = {}): RenownMilestoneDefinition { + return { + id: MILESTONE_ID, + key: 'first-hunt', + name: 'Erste erfolgreiche Jagd', + description: '', + renownReward: 1, + repeatable: false, + enabled: true, + ...overrides, + } as RenownMilestoneDefinition; +} + +function createState(overrides: Partial = {}): State { + return { + characters: [character()], + milestones: [milestone()], + characterMilestones: [], + ...overrides, + }; +} + +function createService(state: State) { + const dataSource = new FakeDataSource(state); + return { service: new RenownService(dataSource as unknown as DataSource), state }; +} + +async function expectRenownDomainError(promise: Promise, code: string): Promise { + let error: unknown; + try { + await promise; + } catch (cause) { + error = cause; + } + expect(error).toBeInstanceOf(RenownDomainError); + if (!(error instanceof RenownDomainError)) { + throw new Error('Expected RenownDomainError'); + } + expect(error.code).toBe(code); +} + +describe('RenownService', () => { + describe('completeMilestone', () => { + 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'); + + expect(result).toEqual({ + milestoneKey: 'first-hunt', + previousRenown: 1, + newRenown: 2, + renownGranted: true, + }); + expect(state.characters[0].renown).toBe(2); + expect(state.characters[0].baseHp).toBe(104); + expect(state.characters[0].baseAttack).toBe(6); + }); + + it('records the completion with a timestamp and timesCompleted = 1', async () => { + const { service, state } = createService(createState()); + + await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + + expect(state.characterMilestones).toHaveLength(1); + expect(state.characterMilestones[0]).toMatchObject({ + characterId: CHARACTER_ID, + milestoneId: MILESTONE_ID, + timesCompleted: 1, + }); + expect(state.characterMilestones[0].completedAt).toBeInstanceOf(Date); + }); + + it('rejects completing a non-repeatable milestone twice, granting renown only once', async () => { + const { service, state } = createService(createState()); + await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + + await expectRenownDomainError( + service.completeMilestone(CHARACTER_ID, 'first-hunt'), + 'RENOWN_MILESTONE_ALREADY_COMPLETED', + ); + expect(state.characters[0].renown).toBe(2); + expect(state.characterMilestones).toHaveLength(1); + }); + + it('allows a repeatable milestone to grant renown again, incrementing timesCompleted', async () => { + 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'); + + expect(result.renownGranted).toBe(true); + expect(result.newRenown).toBe(3); + expect(state.characterMilestones).toHaveLength(1); + expect(state.characterMilestones[0].timesCompleted).toBe(2); + }); + + it('clamps renown at 15 and does not grant beyond the cap', async () => { + const state = createState({ + characters: [character({ renown: 15, baseHp: 148, baseAttack: 12 })], + milestones: [milestone({ repeatable: true })], + }); + const { service } = createService(state); + + const result = await service.completeMilestone(CHARACTER_ID, 'first-hunt'); + + expect(result).toEqual({ + milestoneKey: 'first-hunt', + previousRenown: 15, + newRenown: 15, + renownGranted: false, + }); + expect(state.characters[0].baseHp).toBe(148); + }); + + it('rejects an unknown milestone key', async () => { + const { service } = createService(createState()); + + await expectRenownDomainError( + service.completeMilestone(CHARACTER_ID, 'unknown-milestone'), + 'RENOWN_MILESTONE_NOT_FOUND', + ); + }); + + it('rejects a disabled milestone', async () => { + const state = createState({ milestones: [milestone({ enabled: false })] }); + const { service } = createService(state); + + await expectRenownDomainError( + service.completeMilestone(CHARACTER_ID, 'first-hunt'), + 'RENOWN_MILESTONE_DISABLED', + ); + }); + + it('rejects an unknown character', async () => { + const { service } = createService(createState()); + + await expectRenownDomainError( + service.completeMilestone('unknown-character', 'first-hunt'), + 'CHARACTER_NOT_FOUND', + ); + }); + }); +}); diff --git a/apps/api/src/renown/renown.service.ts b/apps/api/src/renown/renown.service.ts new file mode 100644 index 0000000..7f11aed --- /dev/null +++ b/apps/api/src/renown/renown.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { CharacterRenownMilestone } from './entities/character-renown-milestone.entity'; +import { RenownMilestoneDefinition } from './entities/renown-milestone-definition.entity'; +import { RENOWN_BASE_STATS, RENOWN_MAX } from './renown-base-stats'; +import { + characterNotFound, + renownMilestoneAlreadyCompleted, + renownMilestoneDisabled, + renownMilestoneNotFound, +} from './renown.errors'; + +export interface RenownMilestoneResult { + milestoneKey: string; + previousRenown: number; + newRenown: number; + renownGranted: boolean; +} + +@Injectable() +export class RenownService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Completes a Renown milestone exactly once for non-repeatable milestones + * (spec §12, §31). Recomputes baseHp/baseAttack from the power-curve + * table on every grant -- a lookup, not an increment, so it is immune to + * double-application drift (design R2). + */ + async completeMilestone( + characterId: string, + milestoneKey: string, + manager?: EntityManager, + ): Promise { + const run = async (txManager: EntityManager): Promise => { + const characters = txManager.getRepository(Character); + const milestones = txManager.getRepository(RenownMilestoneDefinition); + const completions = txManager.getRepository(CharacterRenownMilestone); + + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + throw characterNotFound(); + } + + const milestone = await milestones.findOneBy({ key: milestoneKey }); + if (!milestone) { + throw renownMilestoneNotFound(); + } + if (!milestone.enabled) { + throw renownMilestoneDisabled(); + } + + const existing = await completions.findOne({ + where: { characterId, milestoneId: milestone.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (existing && !milestone.repeatable) { + throw renownMilestoneAlreadyCompleted(); + } + + const previousRenown = character.renown; + const newRenown = Math.min(RENOWN_MAX, previousRenown + milestone.renownReward); + const renownGranted = newRenown !== previousRenown; + + if (renownGranted) { + character.renown = newRenown; + const baseStats = RENOWN_BASE_STATS[newRenown]; + character.baseHp = baseStats.baseHp; + character.baseAttack = baseStats.baseAttack; + await characters.save(character); + } + + if (existing) { + existing.timesCompleted += 1; + existing.completedAt = new Date(); + await completions.save(existing); + } else { + await completions.save( + completions.create({ + characterId, + milestoneId: milestone.id, + completedAt: new Date(), + timesCompleted: 1, + }), + ); + } + + return { milestoneKey, previousRenown, newRenown, renownGranted }; + }; + + return manager ? run(manager) : this.dataSource.transaction(run); + } +}