Files
ashen-realms/apps/api/src/reputation/reputation.service.ts
Bastian Wagner 23ed527eea fix: address the final whole-branch review's findings
The branch review approved the slice with no blocking findings. These
are the substantive non-blocking ones.

N1, the one no per-task review could see: every seeded monster rolls
silverMin/silverMax = 0 (R7), so the victory screen showed "Silber +0"
after every fight in the shipped game. Three tasks were each correct in
isolation -- the mechanism stays, the values are zero, the field still
exists -- and the composite was wrong. Now conditional, with a test.
This does not contradict R16: R16 deleted the XP block because the
field ceased to exist, leaving nothing to hide. Silver still exists and
can be non-zero, so a conditional is the right tool.

N2: world.store.ts still said a combat "granted XP and silver". Same
false-fact-in-a-comment defect fixed in b5bcd50, one file over.

N4: resolveReputationRank threw a TypeError on negative input, since
findIndex returns -1 and REPUTATION_RANKS[-1] is undefined. Unreachable
today, but grantReputation is public and accepts any number.

N5/N6: grantReputation resolved factions without the enabled filter the
read path applies, so a disabled faction could accumulate invisible
reputation -- "disabled" was not actually a kill switch. The dense read
also had no ORDER BY, so the list could reorder between requests.

N8: design 13 requires silver stay 0 for every seeded monster; only two
of four were pinned. Re-adding silver to the others would have shipped
silently.

N9: spec 36's "a normal kill grants no Renown" had no test. It was
structurally guaranteed but unasserted -- now locked down against a
later slice wiring renown into combat.

N10: an impossible renown: 0 fixture, and RENOWN_MIN exported but never
used to clamp the floor.

API 268/268, web 230/230, API build zero errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:50:30 +02:00

138 lines
4.3 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { CharacterReputation } from './entities/character-reputation.entity';
import { ReputationFaction } from './entities/reputation-faction.entity';
import {
characterNotFound,
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 characters = txManager.getRepository(Character);
const factions = txManager.getRepository(ReputationFaction);
const reputations = txManager.getRepository(CharacterReputation);
// When TurnInService (Task 6) passes its own `manager`, it will already
// hold a pessimistic write lock on this same character row from earlier
// in that transaction. Re-locking it here is a no-op re-lock in
// Postgres, not a deadlock risk -- CombatService.performAction relies on
// the same behavior for grantVictoryRewards. Do not remove this check
// to "avoid" the re-lock.
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
const faction = await factions.findOneBy({ key: factionKey, enabled: true });
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 }, order: { key: 'ASC' } });
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,
};
});
}
}