Files
ashen-realms/apps/api/src/renown/renown.service.ts
Bastian Wagner 081c9f83f9 docs
2026-08-22 16:41:47 +02:00

107 lines
3.5 KiB
TypeScript

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<RenownMilestoneResult> {
const run = async (
txManager: EntityManager,
): Promise<RenownMilestoneResult> => {
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);
}
}