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, RENOWN_MIN } 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; // Clamp both ends. The ceiling is the power curve's last row; the floor // matters because renown_reward carries no CHECK, so bad content data // would otherwise surface as a raw Postgres constraint violation instead // of a clamped value. const newRenown = Math.min( RENOWN_MAX, Math.max(RENOWN_MIN, 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); } }