import { Injectable } from '@nestjs/common'; import { DataSource, EntityManager, In } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; import { CharacterItem } from '../items/entities/character-item.entity'; import { LootCapacityDto, LootCapacityService, } from '../loot-bags/loot-capacity.service'; import { NpcService } from '../npcs/npc.service'; import { RenownService } from '../renown/renown.service'; import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { ReputationService } from '../reputation/reputation.service'; import { ExchangeRule } from './entities/exchange-rule.entity'; import { NpcExchangeProfile } from './entities/npc-exchange-profile.entity'; import { characterNotFound, exchangeDisabled, exchangeEmptyRequest, exchangeInsufficientQuantity, exchangeInvalidQuantity, exchangeItemNotAccepted, exchangeNotFound, } from './exchange.errors'; export interface ExchangeOfferDto { itemKey: string; itemName: string; iconPath: string; /** How many the character is carrying right now. */ quantityCarried: number; /** Smallest tradeable step. Quantities must be a multiple of this. */ inputQuantity: number; silverPerStep: number; reputationPerStep: number; factionKey: string; factionName: string; /** The renown milestone this rule can still award, if any. */ renownMilestoneKey: string | null; } export interface ExchangeViewDto { profileKey: string; profileName: string; npcKey: string; offers: ExchangeOfferDto[]; capacities: LootCapacityDto[]; } export interface ExchangeRequestItem { itemKey: string; quantity: number; } export interface ExchangeConsumedDto { itemKey: string; itemName: string; quantity: number; } export interface ExchangeResultDto { profileKey: string; consumed: ExchangeConsumedDto[]; rewards: { silver: number; regionalReputation: number; worldRenown: number; }; balances: { silver: number; regionalReputation: number; worldRenown: number; }; /** Ranks crossed by this trade, so the UI can call them out. */ reputationRankChanged: boolean; newReputationRank: string | null; /** Milestone keys this trade completed. Empty on every later trade. */ renownMilestonesCompleted: string[]; capacities: LootCapacityDto[]; } /** * Turns carried materials into Silver, regional reputation and World Renown * (NPC spec §17, §30; Playable Slice 0.8 §4, §8). * * This is the only place a normal hunt becomes progression: kills grant no * money or reputation at all (slice 0.7 §7), so everything the player earns * passes through here. * * Every reward is computed from persisted content. The request carries item * keys and quantities and nothing else -- never a price, never a reward * (spec §37.9). */ @Injectable() export class ExchangeService { constructor( private readonly dataSource: DataSource, private readonly reputation: ReputationService, private readonly renown: RenownService, private readonly lootCapacity: LootCapacityService, private readonly conditions: GameConditionService, private readonly npcs: NpcService, ) {} /** * The trade-in view for a merchant, addressed by NPC key (slice §9). * * Reachability is checked first: the character must be standing where the * merchant is, so a client cannot sell into Graufurt from the Burned Road. */ async getMerchantExchangeView( characterId: string, merchantKey: string, ): Promise { const profileKey = await this.resolveMerchantProfileKey( characterId, merchantKey, ); return this.getExchangeView(characterId, profileKey); } /** Trades goods in with a merchant addressed by NPC key (slice §9). */ async exchangeWithMerchant( characterId: string, merchantKey: string, requested: ExchangeRequestItem[], ): Promise { const profileKey = await this.resolveMerchantProfileKey( characterId, merchantKey, ); return this.exchange(characterId, profileKey, requested); } private async resolveMerchantProfileKey( characterId: string, merchantKey: string, ): Promise { const npc = await this.npcs.requireReachableNpc(characterId, merchantKey); const profile = await this.dataSource .getRepository(NpcExchangeProfile) .findOneBy({ npcId: npc.id, enabled: true }); if (!profile) { throw exchangeNotFound(); } return profile.key; } /** What this merchant will take, and what the character is carrying. */ async getExchangeView( characterId: string, profileKey: string, ): Promise { const profile = await this.dataSource .getRepository(NpcExchangeProfile) .findOne({ where: { key: profileKey }, relations: { npc: true } }); if (!profile) { throw exchangeNotFound(); } if (!profile.enabled) { throw exchangeDisabled(); } const rules = await this.dataSource.getRepository(ExchangeRule).find({ where: { profileId: profile.id, enabled: true }, relations: { inputItem: true, faction: true }, order: { sortOrder: 'ASC' }, }); const carried = await this.loadCarriedQuantities( characterId, rules.map((rule) => rule.inputItemId), this.dataSource, ); const offers: ExchangeOfferDto[] = []; for (const rule of rules) { const unlocked = await this.conditions.evaluate( { characterId, npcId: profile.npcId }, rule.conditions, ); if (!unlocked) { continue; } offers.push({ itemKey: rule.inputItem.key, itemName: rule.inputItem.name, iconPath: rule.inputItem.iconPath, quantityCarried: carried.get(rule.inputItemId) ?? 0, inputQuantity: rule.inputQuantity, silverPerStep: rule.silverReward, reputationPerStep: rule.regionReputationReward, factionKey: rule.faction.key, factionName: rule.faction.name, renownMilestoneKey: rule.renownMilestoneKey, }); } return { profileKey: profile.key, profileName: profile.name, npcKey: profile.npc.key, offers, capacities: await this.lootCapacity.getCapacities(characterId), }; } /** * Hands goods over and pays out, all or nothing (slice §8). * * The whole thing runs in one transaction: goods are removed, Silver * credited, reputation granted and any renown milestone completed together, * so a failure anywhere leaves the character exactly as they were and the * same pelt can never be sold twice. */ async exchange( characterId: string, profileKey: string, requested: ExchangeRequestItem[], ): Promise { const merged = this.mergeRequest(requested); return this.dataSource.transaction(async (manager) => { const character = await manager.getRepository(Character).findOne({ where: { id: characterId }, lock: { mode: 'pessimistic_write' }, }); if (!character) { throw characterNotFound(); } const profile = await manager .getRepository(NpcExchangeProfile) .findOneBy({ key: profileKey }); if (!profile) { throw exchangeNotFound(); } if (!profile.enabled) { throw exchangeDisabled(); } const rules = await manager.getRepository(ExchangeRule).find({ where: { profileId: profile.id, enabled: true }, relations: { inputItem: true }, }); const rulesByItemKey = new Map( rules.map((rule) => [rule.inputItem.key, rule]), ); let silverGranted = 0; const reputationByFaction = new Map(); const milestoneKeys: string[] = []; const consumed: ExchangeConsumedDto[] = []; for (const [itemKey, quantity] of merged) { const rule = rulesByItemKey.get(itemKey); if (!rule) { throw exchangeItemNotAccepted(itemKey); } const unlocked = await this.conditions.evaluate( { characterId, npcId: profile.npcId }, rule.conditions, manager, ); if (!unlocked) { throw exchangeItemNotAccepted(itemKey); } // A batch rule only trades in whole steps: five pelts for a fixed // payout cannot be redeemed three at a time. if (quantity % rule.inputQuantity !== 0) { throw exchangeInvalidQuantity(); } await this.consumeItems(manager, characterId, rule, quantity, itemKey); const steps = quantity / rule.inputQuantity; silverGranted += rule.silverReward * steps; reputationByFaction.set( rule.factionId, (reputationByFaction.get(rule.factionId) ?? 0) + rule.regionReputationReward * steps, ); if (rule.renownMilestoneKey) { milestoneKeys.push(rule.renownMilestoneKey); } consumed.push({ itemKey, itemName: rule.inputItem.name, quantity, }); } character.silver += silverGranted; await manager.getRepository(Character).save(character); const reputationResult = await this.grantReputation( manager, characterId, reputationByFaction, ); const renownResult = await this.grantRenown( manager, characterId, milestoneKeys, ); // Re-read: RenownService rewrites base stats when a milestone lands, so // the row loaded at the top of the transaction is already stale. const settled = await manager .getRepository(Character) .findOneByOrFail({ id: characterId }); return { profileKey, consumed, rewards: { silver: silverGranted, regionalReputation: reputationResult.granted, worldRenown: renownResult.granted, }, balances: { silver: settled.silver, regionalReputation: reputationResult.balance, worldRenown: settled.renown, }, reputationRankChanged: reputationResult.rankChanged, newReputationRank: reputationResult.newRank, renownMilestonesCompleted: renownResult.completed, capacities: await this.lootCapacity.getCapacities(characterId, manager), }; }); } /** * Folds a request down to one entry per item. * * Without this, a client could send the same key twice and have each line * pass the ownership check against the same untouched stack -- trading three * pelts twice while carrying three. */ private mergeRequest(requested: ExchangeRequestItem[]): Map { if (!requested || requested.length === 0) { throw exchangeEmptyRequest(); } const merged = new Map(); for (const entry of requested) { if (!Number.isInteger(entry.quantity) || entry.quantity <= 0) { throw exchangeInvalidQuantity(); } merged.set( entry.itemKey, (merged.get(entry.itemKey) ?? 0) + entry.quantity, ); } return merged; } /** Removes exactly `quantity`, deleting the stack when it empties. */ private async consumeItems( manager: EntityManager, characterId: string, rule: ExchangeRule, quantity: number, itemKey: string, ): Promise { const characterItems = manager.getRepository(CharacterItem); const owned = await characterItems.findOne({ where: { characterId, itemDefinitionId: rule.inputItemId }, lock: { mode: 'pessimistic_write' }, }); if (!owned || owned.quantity < quantity) { throw exchangeInsufficientQuantity(itemKey); } if (owned.quantity === quantity) { await characterItems.remove(owned); return; } owned.quantity -= quantity; await characterItems.save(owned); } private async grantReputation( manager: EntityManager, characterId: string, amountByFaction: Map, ): Promise<{ granted: number; balance: number; rankChanged: boolean; newRank: string | null; }> { let granted = 0; let balance = 0; let rankChanged = false; let newRank: string | null = null; for (const [factionId, amount] of amountByFaction) { if (amount <= 0) { continue; } const faction = await manager .getRepository(ReputationFaction) .findOneBy({ id: factionId }); if (!faction) { throw exchangeNotFound(); } const result = await this.reputation.grantReputation( characterId, faction.key, amount, manager, ); granted += amount; // Slice 0.8 reports a single regional reputation figure. Every seeded // rule pays the same region, so this is that region's balance; if a // profile ever spans factions, the last one wins and the response shape // needs to grow into a list. balance = result.newReputation; rankChanged = rankChanged || result.rankChanged; newRank = result.rankChanged ? result.newRank : newRank; } return { granted, balance, rankChanged, newRank }; } /** * Completes any renown milestones this trade earned. * * A milestone that is already done is not an error -- it is the normal case * from the second trade onward -- so `RenownService`'s "already completed" * rejection is swallowed rather than allowed to fail the trade. */ private async grantRenown( manager: EntityManager, characterId: string, milestoneKeys: string[], ): Promise<{ granted: number; completed: string[] }> { let granted = 0; const completed: string[] = []; for (const key of new Set(milestoneKeys)) { try { const result = await this.renown.completeMilestone( characterId, key, manager, ); if (result.renownGranted) { granted += result.newRenown - result.previousRenown; completed.push(key); } } catch (error) { if (!this.isAlreadyCompleted(error)) { throw error; } } } return { granted, completed }; } private isAlreadyCompleted(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && error.code === 'RENOWN_MILESTONE_ALREADY_COMPLETED' ); } private async loadCarriedQuantities( characterId: string, itemDefinitionIds: string[], scope: Pick, ): Promise> { if (itemDefinitionIds.length === 0) { return new Map(); } const owned = await scope.getRepository(CharacterItem).find({ where: { characterId, itemDefinitionId: In(itemDefinitionIds) }, }); const totals = new Map(); for (const item of owned) { totals.set( item.itemDefinitionId, (totals.get(item.itemDefinitionId) ?? 0) + item.quantity, ); } return totals; } }