docs
This commit is contained in:
506
apps/api/src/exchanges/exchange.service.ts
Normal file
506
apps/api/src/exchanges/exchange.service.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user