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; @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 { const run = async ( txManager: EntityManager, ): Promise => { 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 { 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, }; }); } }