`purchase` listed a shop's offers with no ordering while `getShopView` orders by `sortOrder`, so the two paths answered "which offer does this key mean" by different rules, one of them at the database's discretion. Harmless today because item and bag keys are disjoint, but not a difference worth keeping. The faction lookup behind requirement labels also read every faction while the condition engine only matches enabled ones, so a gate on a disabled faction would have shown that faction's name next to a requirement the engine treats as unmeetable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
import { GameConditionService } from '../conditions/game-condition.service';
|
|
import { GameConditionType } from '../conditions/game-condition.types';
|
|
import { CharacterItem } from '../items/entities/character-item.entity';
|
|
import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity';
|
|
import { NpcService } from '../npcs/npc.service';
|
|
import { ReputationFaction } from '../reputation/entities/reputation-faction.entity';
|
|
import { NpcShop } from './entities/npc-shop.entity';
|
|
import { ShopOffer } from './entities/shop-offer.entity';
|
|
import {
|
|
describeBagEffect,
|
|
describeItemEffect,
|
|
describeRequirement,
|
|
ShopOfferRequirementDto,
|
|
} from './offer-presentation';
|
|
import {
|
|
characterNotFound,
|
|
merchantReputationTooLow,
|
|
shopBagAlreadyOwned,
|
|
shopDisabled,
|
|
shopInsufficientSilver,
|
|
shopInvalidQuantity,
|
|
shopNotFound,
|
|
shopOfferLocked,
|
|
shopOfferNotFound,
|
|
} from './shop.errors';
|
|
|
|
export const SILVER_CURRENCY = 'SILVER';
|
|
|
|
export interface ShopOfferDto {
|
|
itemKey: string;
|
|
itemName: string;
|
|
itemDescription: string;
|
|
iconPath: string;
|
|
currencyType: string;
|
|
price: number;
|
|
quantity: number;
|
|
/** What buying it does: weapon stats, or a bag's capacity (slice §8). */
|
|
effectSummary: string | null;
|
|
/** Why it is locked, and how close the player is (slice §5). Empty when open. */
|
|
requirements: ShopOfferRequirementDto[];
|
|
/** False when the offer's conditions are not met (Slice 0.8.5 content). */
|
|
unlocked: boolean;
|
|
/** True when the character simply cannot afford an otherwise open offer. */
|
|
affordable: boolean;
|
|
}
|
|
|
|
/**
|
|
* What an offer sells, flattened out of whichever target it points at.
|
|
*
|
|
* Both shapes reduce to a key, a name, an icon and an effect, which is all the
|
|
* presentation layer needs -- and `kind` plus `definitionId` is all the grant
|
|
* needs, so the branch on offer kind lives in one place instead of being spread
|
|
* across the view and the purchase path.
|
|
*/
|
|
interface OfferTarget {
|
|
kind: 'item' | 'bag';
|
|
definitionId: string;
|
|
key: string;
|
|
name: string;
|
|
description: string;
|
|
iconPath: string;
|
|
effectSummary: string | null;
|
|
}
|
|
|
|
export interface ShopViewDto {
|
|
shopKey: string;
|
|
shopName: string;
|
|
npcKey: string;
|
|
silver: number;
|
|
offers: ShopOfferDto[];
|
|
}
|
|
|
|
export interface ShopPurchaseResultDto {
|
|
shopKey: string;
|
|
itemKey: string;
|
|
itemName: string;
|
|
quantity: number;
|
|
silverSpent: number;
|
|
silverBalance: number;
|
|
}
|
|
|
|
/**
|
|
* Sells goods for Silver (NPC spec §15, §30).
|
|
*
|
|
* Prices and availability are read from content on every call. The request
|
|
* names an item and a count and nothing else, so a client cannot set its own
|
|
* price or open a locked offer (spec §37.9, §33).
|
|
*/
|
|
@Injectable()
|
|
export class ShopService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly conditions: GameConditionService,
|
|
private readonly npcs: NpcService,
|
|
) {}
|
|
|
|
async getShopView(
|
|
characterId: string,
|
|
merchantKey: string,
|
|
): Promise<ShopViewDto> {
|
|
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
|
|
|
|
const character = await this.dataSource
|
|
.getRepository(Character)
|
|
.findOneBy({ id: characterId });
|
|
if (!character) {
|
|
throw characterNotFound();
|
|
}
|
|
|
|
const offers = await this.dataSource.getRepository(ShopOffer).find({
|
|
where: { shopId: shop.id, enabled: true },
|
|
relations: { itemDefinition: true, lootBagDefinition: true },
|
|
order: { sortOrder: 'ASC' },
|
|
});
|
|
|
|
// One read for the whole view: every reputation requirement needs a
|
|
// display name, and offers commonly gate on the same faction. Restricted
|
|
// to enabled factions because that is what the condition engine evaluates
|
|
// against -- naming a faction the engine treats as absent would describe a
|
|
// requirement that can never be met.
|
|
const factionNames = new Map(
|
|
(
|
|
await this.dataSource
|
|
.getRepository(ReputationFaction)
|
|
.find({ where: { enabled: true } })
|
|
).map((faction) => [faction.key, faction.name]),
|
|
);
|
|
|
|
const context = { characterId, npcId };
|
|
const view: ShopOfferDto[] = [];
|
|
for (const offer of offers) {
|
|
const target = this.resolveTarget(offer);
|
|
if (!target) {
|
|
continue;
|
|
}
|
|
|
|
const gate = await this.evaluateGate(context, offer);
|
|
// Described even when open, so the UI can show a requirement the player
|
|
// has already met rather than having it vanish on unlock. Only
|
|
// `conditions` are described: a bypass is content's private exception,
|
|
// not something the player is told to go and satisfy.
|
|
const outcomes = await this.conditions.describe(
|
|
context,
|
|
offer.conditions,
|
|
);
|
|
const requirements = outcomes
|
|
.map((outcome) => describeRequirement(outcome, factionNames))
|
|
.filter(
|
|
(requirement): requirement is ShopOfferRequirementDto =>
|
|
requirement !== null,
|
|
);
|
|
|
|
view.push({
|
|
itemKey: target.key,
|
|
itemName: target.name,
|
|
itemDescription: target.description,
|
|
iconPath: target.iconPath,
|
|
currencyType: offer.currencyType,
|
|
price: offer.price,
|
|
quantity: offer.quantity,
|
|
effectSummary: target.effectSummary,
|
|
requirements,
|
|
unlocked: gate.open,
|
|
affordable: character.silver >= offer.price,
|
|
});
|
|
}
|
|
|
|
return {
|
|
shopKey: shop.key,
|
|
shopName: shop.name,
|
|
npcKey: merchantKey,
|
|
silver: character.silver,
|
|
offers: view,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Buys `quantity` lots of one offer, atomically (spec §31).
|
|
*
|
|
* Silver is debited and the item granted in the same transaction, so a
|
|
* failure cannot leave the character paid-up and empty-handed.
|
|
*/
|
|
async purchase(
|
|
characterId: string,
|
|
merchantKey: string,
|
|
itemKey: string,
|
|
quantity: number,
|
|
): Promise<ShopPurchaseResultDto> {
|
|
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
throw shopInvalidQuantity();
|
|
}
|
|
|
|
const { shop, npcId } = await this.requireShop(characterId, merchantKey);
|
|
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const characters = manager.getRepository(Character);
|
|
const character = await characters.findOne({
|
|
where: { id: characterId },
|
|
lock: { mode: 'pessimistic_write' },
|
|
});
|
|
if (!character) {
|
|
throw characterNotFound();
|
|
}
|
|
|
|
// Matched on the joined definition's business key: the offer table is
|
|
// keyed by definition id, while the request carries the stable key.
|
|
const offers = await manager.getRepository(ShopOffer).find({
|
|
where: { shopId: shop.id, enabled: true },
|
|
relations: { itemDefinition: true, lootBagDefinition: true },
|
|
// Same order as the view: both paths answer "which offer does this key
|
|
// mean", so neither may answer it from an arbitrary row order.
|
|
order: { sortOrder: 'ASC' },
|
|
});
|
|
let match: ShopOffer | undefined;
|
|
let target: OfferTarget | undefined;
|
|
for (const candidate of offers) {
|
|
const candidateTarget = this.resolveTarget(candidate);
|
|
if (candidateTarget?.key === itemKey) {
|
|
match = candidate;
|
|
target = candidateTarget;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!match || !target) {
|
|
throw shopOfferNotFound();
|
|
}
|
|
|
|
const gate = await this.evaluateGate(
|
|
{ characterId, npcId },
|
|
match,
|
|
manager,
|
|
);
|
|
if (!gate.open) {
|
|
throw gate.reputationBlocked
|
|
? merchantReputationTooLow()
|
|
: shopOfferLocked();
|
|
}
|
|
|
|
if (!match.repeatable && quantity > 1) {
|
|
throw shopInvalidQuantity();
|
|
}
|
|
// A bag is one object, not a stack: buying two grants nothing extra.
|
|
if (target.kind === 'bag' && quantity > 1) {
|
|
throw shopInvalidQuantity();
|
|
}
|
|
|
|
const silverSpent = match.price * quantity;
|
|
if (character.silver < silverSpent) {
|
|
throw shopInsufficientSilver();
|
|
}
|
|
|
|
// Checked before the debit: the transaction would roll the Silver back
|
|
// anyway, but failing on the cheap read keeps the error honest about
|
|
// what went wrong.
|
|
if (target.kind === 'bag') {
|
|
const owned = await manager.getRepository(CharacterLootBag).findOne({
|
|
where: { characterId, lootBagDefinitionId: target.definitionId },
|
|
});
|
|
if (owned) {
|
|
throw shopBagAlreadyOwned();
|
|
}
|
|
}
|
|
|
|
character.silver -= silverSpent;
|
|
await characters.save(character);
|
|
|
|
if (target.kind === 'bag') {
|
|
await this.grantLootBag(manager, characterId, target.definitionId);
|
|
} else {
|
|
await this.grantItem(
|
|
manager,
|
|
characterId,
|
|
target.definitionId,
|
|
match.quantity * quantity,
|
|
);
|
|
}
|
|
|
|
return {
|
|
shopKey: shop.key,
|
|
itemKey,
|
|
itemName: target.name,
|
|
quantity: match.quantity * quantity,
|
|
silverSpent,
|
|
silverBalance: character.silver,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* What an offer sells, whichever kind of thing that is.
|
|
*
|
|
* The two targets are mutually exclusive by `CHK_shop_offers_single_target`,
|
|
* so resolving them here lets the view and the purchase path agree on what an
|
|
* offer *is* without either of them branching on offer kind itself.
|
|
*/
|
|
private resolveTarget(offer: ShopOffer): OfferTarget | null {
|
|
if (offer.itemDefinition) {
|
|
return {
|
|
kind: 'item',
|
|
definitionId: offer.itemDefinition.id,
|
|
key: offer.itemDefinition.key,
|
|
name: offer.itemDefinition.name,
|
|
description: offer.itemDefinition.description,
|
|
iconPath: offer.itemDefinition.iconPath,
|
|
effectSummary: describeItemEffect(offer.itemDefinition),
|
|
};
|
|
}
|
|
if (offer.lootBagDefinition) {
|
|
return {
|
|
kind: 'bag',
|
|
definitionId: offer.lootBagDefinition.id,
|
|
key: offer.lootBagDefinition.key,
|
|
name: offer.lootBagDefinition.name,
|
|
// A bag definition carries no flavour text of its own. The capacity
|
|
// line is the whole of what there is to say about it, and it is already
|
|
// carried by `effectSummary`; repeating it here would render it twice
|
|
// (slice §5 shows the line once).
|
|
description: '',
|
|
iconPath: offer.lootBagDefinition.iconPath,
|
|
effectSummary: describeBagEffect(offer.lootBagDefinition),
|
|
};
|
|
}
|
|
// CHK_shop_offers_single_target makes this unreachable through the
|
|
// database. Skipping the row beats rendering an offer that sells nothing.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether an offer is open, and why not when it is shut.
|
|
*
|
|
* `conditions` OR `bypassConditions` -- the whole exception model (slice §7).
|
|
* A referral does not lower the requirement; it provides a second, narrower
|
|
* door that content opens deliberately.
|
|
*/
|
|
private async evaluateGate(
|
|
context: { characterId: string; npcId: string },
|
|
offer: ShopOffer,
|
|
manager?: EntityManager,
|
|
): Promise<{ open: boolean; reputationBlocked: boolean }> {
|
|
if (await this.conditions.evaluate(context, offer.conditions, manager)) {
|
|
return { open: true, reputationBlocked: false };
|
|
}
|
|
|
|
const bypass = offer.bypassConditions ?? [];
|
|
if (
|
|
bypass.length > 0 &&
|
|
(await this.conditions.evaluate(context, bypass, manager))
|
|
) {
|
|
return { open: true, reputationBlocked: false };
|
|
}
|
|
|
|
// Which error to raise depends on what is actually short, so the player is
|
|
// told to earn reputation only when reputation is the thing missing.
|
|
const outcomes = await this.conditions.describe(
|
|
context,
|
|
offer.conditions,
|
|
manager,
|
|
);
|
|
const reputationBlocked = outcomes.some(
|
|
(outcome) =>
|
|
!outcome.met &&
|
|
outcome.condition.type === GameConditionType.REGION_REPUTATION,
|
|
);
|
|
|
|
return { open: false, reputationBlocked };
|
|
}
|
|
|
|
/**
|
|
* Hands over a bag, once.
|
|
*
|
|
* A second copy of the same bag grants nothing -- only the roomiest active
|
|
* bag per category counts (Slice 0.7.5 §6) -- so a repeat purchase is
|
|
* refused rather than silently charged. The unique index on
|
|
* (character_id, loot_bag_definition_id) is the real guarantee; this check
|
|
* is what turns a constraint violation into an explainable domain error.
|
|
*/
|
|
private async grantLootBag(
|
|
manager: { getRepository: DataSource['getRepository'] },
|
|
characterId: string,
|
|
lootBagDefinitionId: string,
|
|
): Promise<void> {
|
|
const bags = manager.getRepository(CharacterLootBag);
|
|
const existing = await bags.findOne({
|
|
where: { characterId, lootBagDefinitionId },
|
|
});
|
|
if (existing) {
|
|
throw shopBagAlreadyOwned();
|
|
}
|
|
|
|
await bags.save(
|
|
bags.create({ characterId, lootBagDefinitionId, active: true }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds to an existing stack or starts a new one.
|
|
*
|
|
* Purchases deliberately ignore loot-bag capacity: bags limit trade goods
|
|
* carried out of a hunt, and equipment and consumables are unaffected by
|
|
* them (Slice 0.7.5 §8).
|
|
*/
|
|
private async grantItem(
|
|
manager: { getRepository: DataSource['getRepository'] },
|
|
characterId: string,
|
|
itemDefinitionId: string,
|
|
quantity: number,
|
|
): Promise<void> {
|
|
const characterItems = manager.getRepository(CharacterItem);
|
|
const existing = await characterItems.findOne({
|
|
where: { characterId, itemDefinitionId },
|
|
});
|
|
|
|
if (existing) {
|
|
existing.quantity += quantity;
|
|
await characterItems.save(existing);
|
|
return;
|
|
}
|
|
|
|
await characterItems.save(
|
|
characterItems.create({ characterId, itemDefinitionId, quantity }),
|
|
);
|
|
}
|
|
|
|
private async requireShop(
|
|
characterId: string,
|
|
merchantKey: string,
|
|
): Promise<{ shop: NpcShop; npcId: string }> {
|
|
const npc = await this.npcs.requireReachableNpc(characterId, merchantKey);
|
|
|
|
const shop = await this.dataSource
|
|
.getRepository(NpcShop)
|
|
.findOneBy({ npcId: npc.id });
|
|
if (!shop) {
|
|
throw shopNotFound();
|
|
}
|
|
if (!shop.enabled) {
|
|
throw shopDisabled();
|
|
}
|
|
|
|
return { shop, npcId: npc.id };
|
|
}
|
|
}
|