feat(reputation): add ReputationService, rank resolver integration, and GET /api/reputation

This commit is contained in:
Bastian Wagner
2026-08-21 09:22:36 +02:00
parent 4c9397336f
commit 79d4e04172
7 changed files with 439 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity';
import { reputationFactionNotFound } from './reputation.errors';
import { resolveReputationRank } from './reputation-rank';
export interface ReputationGrantResult {
factionKey: string;
previousReputation: number;
newReputation: number;
previousRank: string;
newRank: string;
rankChanged: boolean;
}
export interface CharacterReputationDto {
factionKey: string;
factionName: string;
reputation: number;
rank: string;
rankLabel: string;
nextThreshold: number | null;
}
type RepositoryScope = Pick<DataSource, 'getRepository'>;
@Injectable()
export class ReputationService {
constructor(private readonly dataSource: DataSource) {}
/** Grants Regional Reputation, server-authoritative (spec §11, §30). */
async grantReputation(
characterId: string,
factionKey: string,
amount: number,
manager?: EntityManager,
): Promise<ReputationGrantResult> {
const run = async (txManager: EntityManager): Promise<ReputationGrantResult> => {
const factions = txManager.getRepository(ReputationFaction);
const reputations = txManager.getRepository(CharacterReputation);
const faction = await factions.findOneBy({ key: factionKey });
if (!faction) {
throw reputationFactionNotFound();
}
const existing = await reputations.findOne({
where: { characterId, factionId: faction.id },
lock: { mode: 'pessimistic_write' },
});
const previousReputation = existing?.reputation ?? 0;
const newReputation = previousReputation + amount;
if (existing) {
existing.reputation = newReputation;
await reputations.save(existing);
} else {
await reputations.save(
reputations.create({ characterId, factionId: faction.id, reputation: newReputation }),
);
}
const previousRank = resolveReputationRank(previousReputation);
const newRank = resolveReputationRank(newReputation);
return {
factionKey,
previousReputation,
newReputation,
previousRank: previousRank.key,
newRank: newRank.key,
rankChanged: previousRank.key !== newRank.key,
};
};
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* Every enabled faction, one entry each -- a faction the character has
* never interacted with reads as 0 Reputation / Stranger (spec §36,
* design R10), not as an absent entry.
*/
async getCharacterReputation(characterId: string): Promise<CharacterReputationDto[]> {
const scope: RepositoryScope = this.dataSource;
const factions = await scope.getRepository(ReputationFaction).find({ where: { enabled: true } });
const reputations = await scope.getRepository(CharacterReputation).find({ where: { characterId } });
return factions.map((faction) => {
const existing = reputations.find((row) => row.factionId === faction.id);
const reputation = existing?.reputation ?? 0;
const rank = resolveReputationRank(reputation);
return {
factionKey: faction.key,
factionName: faction.name,
reputation,
rank: rank.key,
rankLabel: rank.label,
nextThreshold: rank.nextThreshold,
};
});
}
}