From 7a5e800a185e45e2084dfd44d407cbb38a8d5259 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 18:34:24 +0200 Subject: [PATCH] feat(shops): sell loot bags, honour bypass conditions, explain locks --- apps/api/src/shops/shop.errors.ts | 30 +++ apps/api/src/shops/shop.service.spec.ts | 300 ++++++++++++++++++++++-- apps/api/src/shops/shop.service.ts | 250 +++++++++++++++++--- apps/api/src/shops/shops.module.ts | 13 +- 4 files changed, 539 insertions(+), 54 deletions(-) diff --git a/apps/api/src/shops/shop.errors.ts b/apps/api/src/shops/shop.errors.ts index 7431145..55aeacd 100644 --- a/apps/api/src/shops/shop.errors.ts +++ b/apps/api/src/shops/shop.errors.ts @@ -5,6 +5,8 @@ export type ShopErrorCode = | 'SHOP_DISABLED' | 'SHOP_OFFER_NOT_FOUND' | 'SHOP_OFFER_LOCKED' + | 'MERCHANT_REPUTATION_TOO_LOW' + | 'SHOP_BAG_ALREADY_OWNED' | 'SHOP_INVALID_QUANTITY' | 'SHOP_INSUFFICIENT_SILVER'; @@ -57,6 +59,34 @@ export function shopOfferLocked(): ShopDomainError { ); } +/** + * The specific case of `SHOP_OFFER_LOCKED` where reputation is what is short + * (Playable Slice 0.8.5 §6). + * + * Added alongside the generic code rather than replacing it: a gate on a quest + * flag or an item is still `SHOP_OFFER_LOCKED`, and telling a player to earn + * reputation they do not need would be worse than saying nothing specific. + */ +export function merchantReputationTooLow(): ShopDomainError { + return new ShopDomainError( + 'MERCHANT_REPUTATION_TOO_LOW', + HttpStatus.FORBIDDEN, + 'You have not earned enough standing for this yet.', + ); +} + +/** + * Only the roomiest active bag per category counts (Slice 0.7.5 §6), so a + * second copy grants nothing. Selling one would be taking Silver for nothing. + */ +export function shopBagAlreadyOwned(): ShopDomainError { + return new ShopDomainError( + 'SHOP_BAG_ALREADY_OWNED', + HttpStatus.CONFLICT, + 'You already carry that.', + ); +} + export function shopInvalidQuantity(): ShopDomainError { return new ShopDomainError( 'SHOP_INVALID_QUANTITY', diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts index 8b8b984..ca7b524 100644 --- a/apps/api/src/shops/shop.service.spec.ts +++ b/apps/api/src/shops/shop.service.spec.ts @@ -1,8 +1,17 @@ import { DataSource, EntityManager } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { GameConditionService } from '../conditions/game-condition.service'; +import { + ComparisonOperator, + GameCondition, + GameConditionType, +} from '../conditions/game-condition.types'; import { CharacterItem } from '../items/entities/character-item.entity'; +import { LootCategory } from '../items/loot-category.enum'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.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 { ShopService } from './shop.service'; @@ -18,6 +27,16 @@ interface Fixture { offerRepeatable?: boolean; offerQuantity?: number; ownedPotions?: number | null; + /** Replaces the single item offer with one that sells the trophy pouch. */ + bagOffer?: boolean; + /** Whether the character already holds the bag the offer sells. */ + ownsBag?: boolean; + /** Conditions on the offer, so a test can gate it on reputation. */ + conditions?: GameCondition[]; + /** The alternative way in (slice §7). */ + bypassConditions?: GameCondition[]; + /** Which of `conditions` / `bypassConditions` the fake engine says hold. */ + bypassPasses?: boolean; } function createWorld(fixture: Fixture = {}) { @@ -36,26 +55,61 @@ function createWorld(fixture: Fixture = {}) { quantity: fixture.ownedPotions, }; + const bag = { + id: 'bag-trophy-pouch', + key: 'basic-trophy-pouch', + name: 'Basic Trophy Pouch', + lootCategory: LootCategory.RAIDER_TROPHY, + capacity: 5, + iconPath: '/images/items/basic-trophy-pouch.png', + }; + + const grantedBags: Array> = []; + const offers = [ - { - id: 'offer-1', - shopId: 'shop-1', - itemDefinitionId: 'item-potion', - currencyType: 'SILVER', - price: 12, - quantity: fixture.offerQuantity ?? 1, - repeatable: fixture.offerRepeatable ?? true, - sortOrder: 1, - conditions: [], - enabled: true, - itemDefinition: { - id: 'item-potion', - key: 'small-healing-potion', - name: 'Small Healing Potion', - description: 'A bitter draught.', - iconPath: '/images/items/potion.png', - }, - }, + fixture.bagOffer + ? { + id: 'offer-bag', + shopId: 'shop-1', + itemDefinitionId: null, + lootBagDefinitionId: bag.id, + currencyType: 'SILVER', + price: 40, + quantity: 1, + repeatable: false, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: null, + lootBagDefinition: bag, + } + : { + id: 'offer-1', + shopId: 'shop-1', + itemDefinitionId: 'item-potion', + lootBagDefinitionId: null, + currencyType: 'SILVER', + price: 12, + quantity: fixture.offerQuantity ?? 1, + repeatable: fixture.offerRepeatable ?? true, + sortOrder: 1, + conditions: fixture.conditions ?? [], + bypassConditions: fixture.bypassConditions ?? [], + enabled: true, + itemDefinition: { + id: 'item-potion', + key: 'small-healing-potion', + name: 'Small Healing Potion', + description: 'A bitter draught.', + iconPath: '/images/items/potion.png', + weaponDamage: 0, + bonusAttack: 0, + bonusHp: 0, + bonusArmor: 0, + }, + lootBagDefinition: null, + }, ] as unknown as ShopOffer[]; const repositories = (entity: unknown) => { @@ -88,9 +142,35 @@ function createWorld(fixture: Fixture = {}) { return { findOne: () => Promise.resolve(owned), create: (row: Record) => row, - save: async (row: Record) => { + save: (row: Record) => { grantedItems.push(row); - return row; + return Promise.resolve(row); + }, + }; + } + if (entity === ReputationFaction) { + return { + find: () => + Promise.resolve([ + { id: 'faction-1', key: 'border-guard', name: 'Border Watch' }, + ]), + }; + } + if (entity === LootBagDefinition) { + return { findOneBy: () => Promise.resolve(bag) }; + } + if (entity === CharacterLootBag) { + return { + findOne: () => + Promise.resolve( + fixture.ownsBag + ? { characterId: CHARACTER_ID, lootBagDefinitionId: bag.id } + : null, + ), + create: (row: Record) => row, + save: (row: Record) => { + grantedBags.push(row); + return Promise.resolve(row); }, }; } @@ -105,7 +185,27 @@ function createWorld(fixture: Fixture = {}) { } as unknown as DataSource; const conditions = { - evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)), + evaluate: jest.fn( + (_context: unknown, list: GameCondition[] | undefined) => { + // The fixture distinguishes the two lists by identity, so a test can say + // "the gate is shut but the bypass is open". + if (list === fixture.bypassConditions) { + return Promise.resolve(fixture.bypassPasses ?? false); + } + // `offerUnlocked` is the fixture's switch for the offer's own gate, + // whatever the conditions expressing it happen to be. + return Promise.resolve(fixture.offerUnlocked ?? true); + }, + ), + describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => + Promise.resolve( + (list ?? []).map((condition) => ({ + condition, + met: fixture.offerUnlocked ?? true, + actual: 14, + })), + ), + ), } as unknown as GameConditionService; const npcs = { @@ -116,6 +216,7 @@ function createWorld(fixture: Fixture = {}) { service: new ShopService(dataSource, conditions, npcs), character, grantedItems, + grantedBags, owned, }; } @@ -261,4 +362,159 @@ describe('ShopService', () => { expect(result.quantity).toBe(6); expect(result.silverSpent).toBe(24); }); + + it('shows the requirement and the current value on a locked offer', async () => { + const world = createWorld({ + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + // Locked, but still listed: a visible reward is a goal (slice §5). + expect(view.offers).toHaveLength(1); + expect(view.offers[0].unlocked).toBe(false); + expect(view.offers[0].requirements).toEqual([ + { + label: 'Requires Border Watch Reputation 25', + current: 14, + required: 25, + met: false, + }, + ]); + }); + + it('lists a bag offer with its capacity as the effect', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); + + expect(view.offers[0]).toMatchObject({ + itemKey: 'basic-trophy-pouch', + itemName: 'Basic Trophy Pouch', + price: 40, + effectSummary: 'Capacity: 5 Raider Trophies', + unlocked: true, + }); + }); + + it('grants a bag rather than stacking it as an item', async () => { + const world = createWorld({ bagOffer: true, silver: 100 }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedItems).toHaveLength(0); + expect(world.grantedBags[0]).toMatchObject({ + characterId: CHARACTER_ID, + lootBagDefinitionId: 'bag-trophy-pouch', + active: true, + }); + }); + + it('refuses to sell a bag the character already carries', async () => { + // A second copy grants nothing (only the roomiest active bag per category + // counts) so charging for it would be taking Silver for nothing. + const world = createWorld({ bagOffer: true, ownsBag: true, silver: 100 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' }); + + expect(world.character.silver).toBe(100); + }); + + it('names reputation as the reason when a reputation gate is what blocks', async () => { + const world = createWorld({ + offerUnlocked: false, + silver: 1000, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 25, + }, + ], + }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'small-healing-potion', + 1, + ), + ).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' }); + }); + + it('opens an offer whose bypass holds even though its conditions do not', async () => { + // The Slice 0.9 referral: the warden's word is worth more than the + // reputation the player has not earned yet (slice §7). + const bypassConditions: GameCondition[] = [ + { + type: GameConditionType.FLAG_SET, + key: 'referred-by-south-gate-warden', + value: true, + }, + ]; + const world = createWorld({ + bagOffer: true, + silver: 100, + offerUnlocked: false, + conditions: [ + { + type: GameConditionType.REGION_REPUTATION, + key: 'border-guard', + operator: ComparisonOperator.GTE, + value: 40, + }, + ], + bypassConditions, + bypassPasses: true, + }); + + const result = await world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ); + + expect(result.silverSpent).toBe(40); + expect(world.grantedBags).toHaveLength(1); + }); + + it('still charges the price when a requirement is met', async () => { + // Reputation opens the offer; it does not pay for it (slice §10). + const world = createWorld({ bagOffer: true, silver: 10 }); + + await expect( + world.service.purchase( + CHARACTER_ID, + MERCHANT_KEY, + 'basic-trophy-pouch', + 1, + ), + ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + + expect(world.grantedBags).toHaveLength(0); + }); }); diff --git a/apps/api/src/shops/shop.service.ts b/apps/api/src/shops/shop.service.ts index 654f762..81f40e5 100644 --- a/apps/api/src/shops/shop.service.ts +++ b/apps/api/src/shops/shop.service.ts @@ -1,13 +1,24 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +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, @@ -26,12 +37,34 @@ export interface ShopOfferDto { 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; @@ -79,31 +112,53 @@ export class ShopService { const offers = await this.dataSource.getRepository(ShopOffer).find({ where: { shopId: shop.id, enabled: true }, - relations: { itemDefinition: 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. + const factionNames = new Map( + (await this.dataSource.getRepository(ReputationFaction).find()).map( + (faction) => [faction.key, faction.name], + ), + ); + + const context = { characterId, npcId }; const view: ShopOfferDto[] = []; for (const offer of offers) { - const unlocked = await this.conditions.evaluate( - { characterId, npcId }, + 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, + ); - // TEMPORARY (Task 1 of Slice 0.8.5): `itemDefinition` became nullable - // when the offer table gained a loot-bag target alongside the item one. - // Every offer sold an item until Task 4 adds bag offers here, so the - // assertion is safe for now -- Task 4 replaces it with a real branch on - // offer kind. view.push({ - itemKey: offer.itemDefinition!.key, - itemName: offer.itemDefinition!.name, - itemDescription: offer.itemDefinition!.description, - iconPath: offer.itemDefinition!.iconPath, + itemKey: target.key, + itemName: target.name, + itemDescription: target.description, + iconPath: target.iconPath, currencyType: offer.currencyType, price: offer.price, quantity: offer.quantity, - unlocked, + effectSummary: target.effectSummary, + requirements, + unlocked: gate.open, affordable: character.silver >= offer.price, }); } @@ -146,51 +201,80 @@ export class ShopService { } // Matched on the joined definition's business key: the offer table is - // keyed by item definition id, while the request carries the stable key. + // 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 }, + relations: { itemDefinition: true, lootBagDefinition: true }, }); - const match = offers.find( - (candidate) => candidate.itemDefinition!.key === itemKey, - ); + 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) { + if (!match || !target) { throw shopOfferNotFound(); } - const unlocked = await this.conditions.evaluate( + const gate = await this.evaluateGate( { characterId, npcId }, - match.conditions, + match, manager, ); - if (!unlocked) { - throw shopOfferLocked(); + 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); - await this.grantItem( - manager, - characterId, - match.itemDefinitionId!, - match.quantity * quantity, - ); + 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: match.itemDefinition!.name, + itemName: target.name, quantity: match.quantity * quantity, silverSpent, silverBalance: character.silver, @@ -198,6 +282,110 @@ export class ShopService { }); } + /** + * 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 honest description of what it is. + description: describeBagEffect(offer.lootBagDefinition), + 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. * diff --git a/apps/api/src/shops/shops.module.ts b/apps/api/src/shops/shops.module.ts index 8647f2a..e126d9f 100644 --- a/apps/api/src/shops/shops.module.ts +++ b/apps/api/src/shops/shops.module.ts @@ -3,7 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Character } from '../characters/entities/character.entity'; import { ConditionsModule } from '../conditions/conditions.module'; import { CharacterItem } from '../items/entities/character-item.entity'; +import { CharacterLootBag } from '../loot-bags/entities/character-loot-bag.entity'; +import { LootBagDefinition } from '../loot-bags/entities/loot-bag-definition.entity'; import { NpcsModule } from '../npcs/npcs.module'; +import { ReputationFaction } from '../reputation/entities/reputation-faction.entity'; import { NpcShop } from './entities/npc-shop.entity'; import { ShopOffer } from './entities/shop-offer.entity'; import { ShopController } from './shop.controller'; @@ -12,7 +15,15 @@ import { ShopService } from './shop.service'; /** Buying things for Silver (NPC spec §15, §29). */ @Module({ imports: [ - TypeOrmModule.forFeature([Character, CharacterItem, NpcShop, ShopOffer]), + TypeOrmModule.forFeature([ + Character, + CharacterItem, + CharacterLootBag, + LootBagDefinition, + NpcShop, + ReputationFaction, + ShopOffer, + ]), ConditionsModule, NpcsModule, ],