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>
138 lines
4.3 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|
|
}
|