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 { 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 { 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 { 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 { 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 }; } }