Files
ashen-realms/apps/api/src/exchanges/exchange.service.ts
Bastian Wagner 081c9f83f9 docs
2026-08-22 16:41:47 +02:00

507 lines
15 KiB
TypeScript

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<ExchangeViewDto> {
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<ExchangeResultDto> {
const profileKey = await this.resolveMerchantProfileKey(
characterId,
merchantKey,
);
return this.exchange(characterId, profileKey, requested);
}
private async resolveMerchantProfileKey(
characterId: string,
merchantKey: string,
): Promise<string> {
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<ExchangeViewDto> {
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<ExchangeResultDto> {
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<string, number>();
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<string, number> {
if (!requested || requested.length === 0) {
throw exchangeEmptyRequest();
}
const merged = new Map<string, number>();
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<void> {
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<string, number>,
): 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<DataSource, 'getRepository'>,
): Promise<Map<string, number>> {
if (itemDefinitionIds.length === 0) {
return new Map();
}
const owned = await scope.getRepository(CharacterItem).find({
where: { characterId, itemDefinitionId: In(itemDefinitionIds) },
});
const totals = new Map<string, number>();
for (const item of owned) {
totals.set(
item.itemDefinitionId,
(totals.get(item.itemDefinitionId) ?? 0) + item.quantity,
);
}
return totals;
}
}