107 lines
3.5 KiB
TypeScript
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);
|
|
}
|
|
}
|