507 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|