feat(renown): add RenownService with milestone completion and base-stat recomputation
This commit is contained in:
97
apps/api/src/renown/renown.service.ts
Normal file
97
apps/api/src/renown/renown.service.ts
Normal file
@@ -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<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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user