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'; const CHARACTER_ID = 'character-1'; const MERCHANT_KEY = 'borin-quartermaster'; interface Fixture { silver?: number; shopEnabled?: boolean; hasShop?: boolean; /** The fake engine's blanket answer for every condition it is handed. */ offerUnlocked?: boolean; /** * Condition types the fake engine says do *not* hold, whatever * `offerUnlocked` says. Lets one list mix a met requirement with an unmet * one, which is what distinguishes "reputation is short" from "something * else is". */ unmetConditionTypes?: GameConditionType[]; offerRepeatable?: boolean; offerQuantity?: number; ownedPotions?: number | null; /** Replaces the single item offer with one that sells the trophy pouch. */ bagOffer?: boolean; /** Bag offers are one-off by nature; a test can lift that to reach the * bag-specific quantity rule underneath it. */ bagRepeatable?: 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[]; /** Whether the fake engine says the bypass list holds. */ bypassPasses?: boolean; } /** * What the fake engine reports as measured, per condition type. * * Only conditions with a scale have one: a flag is set or it is not, and the * real engine reports `actual: null` for it. */ const FAKE_MEASURED_VALUE: Partial> = { [GameConditionType.REGION_REPUTATION]: 14, [GameConditionType.WORLD_RENOWN]: 14, }; function createWorld(fixture: Fixture = {}) { const character = { id: CHARACTER_ID, silver: fixture.silver ?? 100, } as Character; const grantedItems: Array> = []; const owned = fixture.ownedPotions === undefined || fixture.ownedPotions === null ? null : { characterId: CHARACTER_ID, itemDefinitionId: 'item-potion', 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 = [ fixture.bagOffer ? { id: 'offer-bag', shopId: 'shop-1', itemDefinitionId: null, lootBagDefinitionId: bag.id, currencyType: 'SILVER', price: 40, quantity: 1, repeatable: fixture.bagRepeatable ?? 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) => { if (entity === Character) { return { findOne: () => Promise.resolve(character), findOneBy: () => Promise.resolve(character), save: (row: Character) => Promise.resolve(row), }; } if (entity === NpcShop) { return { findOneBy: () => Promise.resolve( (fixture.hasShop ?? true) ? { id: 'shop-1', key: 'borin-supplies', name: "Quartermaster's Supplies", enabled: fixture.shopEnabled ?? true, } : null, ), }; } if (entity === ShopOffer) { return { find: () => Promise.resolve(offers) }; } if (entity === CharacterItem) { return { findOne: () => Promise.resolve(owned), create: (row: Record) => row, save: (row: Record) => { grantedItems.push(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); }, }; } throw new Error('Unexpected repository'); }; const manager = { getRepository: repositories } as unknown as EntityManager; const dataSource = { getRepository: repositories, transaction: async (run: (m: EntityManager) => Promise) => run(manager), } as unknown as DataSource; /** One condition's verdict, so `evaluate` and `describe` cannot disagree. */ const conditionHolds = (condition: GameCondition) => { if (fixture.unmetConditionTypes?.includes(condition.type)) { return false; } return fixture.offerUnlocked ?? true; }; const conditions = { evaluate: jest.fn( (_context: unknown, list: GameCondition[] | undefined) => { // Mirrors the real engine (game-condition.service.ts): an AND over // nothing holds, so an empty list is not a gate at all. Keeping this // faithful is what makes the service's `bypass.length > 0` guard // load-bearing rather than decorative. if (!list || list.length === 0) { return Promise.resolve(true); } // 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); } // Answered from the list's own contents, so handing the gate the wrong // list cannot pass unnoticed. return Promise.resolve(list.every(conditionHolds)); }, ), describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => Promise.resolve( (list ?? []).map((condition) => ({ condition, met: conditionHolds(condition), actual: FAKE_MEASURED_VALUE[condition.type] ?? null, })), ), ), } as unknown as GameConditionService; const npcs = { requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })), } as unknown as NpcService; return { service: new ShopService(dataSource, conditions, npcs), character, grantedItems, grantedBags, owned, }; } describe('ShopService', () => { it('lists offers with the price the server holds', async () => { const world = createWorld({ silver: 100 }); const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); expect(view.silver).toBe(100); expect(view.offers[0]).toMatchObject({ itemKey: 'small-healing-potion', price: 12, unlocked: true, affordable: true, }); }); it('marks an offer the character cannot afford without hiding it', async () => { const world = createWorld({ silver: 3 }); const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY); expect(view.offers[0]).toMatchObject({ unlocked: true, affordable: false }); }); it('debits Silver and grants the item', async () => { const world = createWorld({ silver: 50 }); const result = await world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 2, ); expect(result.silverSpent).toBe(24); expect(world.character.silver).toBe(26); expect(world.grantedItems[0]).toMatchObject({ quantity: 2 }); }); it('stacks onto an existing pile rather than starting a second one', async () => { const world = createWorld({ silver: 50, ownedPotions: 3 }); await world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ); expect(world.grantedItems[0]).toMatchObject({ quantity: 4 }); }); it('refuses a purchase the character cannot afford, leaving Silver intact', async () => { const world = createWorld({ silver: 5 }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ), ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); expect(world.character.silver).toBe(5); expect(world.grantedItems).toHaveLength(0); }); it('refuses a locked offer even when the request asks for it directly', async () => { // The gate is enforced server-side, so hiding it in the UI is not the // protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API // gekauft werden"). Gated on a flag rather than reputation, because a // non-reputation gate is what keeps the generic code in play. const world = createWorld({ offerUnlocked: false, silver: 1000, conditions: [ { type: GameConditionType.FLAG_SET, key: 'vouched-for-by-the-warden', value: true, }, ], }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ), ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); expect(world.character.silver).toBe(1000); expect(world.grantedItems).toHaveLength(0); }); it('keeps an offer shut when its bypass list is empty', async () => { // An empty condition list is an AND over nothing, so it *holds*. Without // the service's emptiness guard, every offer in the database -- whose // `bypass_conditions` default to '[]' -- would open (slice §7). const world = createWorld({ offerUnlocked: false, silver: 1000, conditions: [ { type: GameConditionType.FLAG_SET, key: 'vouched-for-by-the-warden', value: true, }, ], bypassConditions: [], }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ), ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); expect(world.character.silver).toBe(1000); expect(world.grantedItems).toHaveLength(0); }); it('refuses an item the shop does not stock', async () => { const world = createWorld(); await expect( world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1), ).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' }); expect(world.character.silver).toBe(100); expect(world.grantedItems).toHaveLength(0); }); it('refuses a non-positive quantity', async () => { const world = createWorld(); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 0, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); expect(world.character.silver).toBe(100); expect(world.grantedItems).toHaveLength(0); }); it('refuses to buy a one-off offer more than once in a request', async () => { const world = createWorld({ offerRepeatable: false }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 2, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); expect(world.character.silver).toBe(100); expect(world.grantedItems).toHaveLength(0); }); it('refuses a closed shop', async () => { const world = createWorld({ shopEnabled: false }); await expect( world.service.getShopView(CHARACTER_ID, MERCHANT_KEY), ).rejects.toMatchObject({ code: 'SHOP_DISABLED' }); }); it('grants the offer bundle size, not the request count', async () => { // An offer that sells three at a time, bought twice, is six items. const world = createWorld({ silver: 100, offerQuantity: 3 }); const result = await world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 2, ); 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, }); // The capacity line is sent once, as the effect. Repeating it as the // description makes the row render it twice (slice §5). expect(view.offers[0].itemDescription).toBe(''); }); it('grants a bag rather than stacking it as an item', async () => { // Slice §10 case 3: the reputation gate is present *and* satisfied, so the // purchase goes through. A fixture with no conditions at all would pass // without the gate ever being consulted. const world = createWorld({ bagOffer: true, silver: 100, conditions: [ { type: GameConditionType.REGION_REPUTATION, key: 'border-guard', operator: ComparisonOperator.GTE, value: 25, }, ], }); 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); expect(world.grantedBags).toHaveLength(0); }); it('refuses to sell two of the same bag in one request', async () => { // A bag is one object, not a stack: the second grants nothing. The offer // is made repeatable here so the bag rule is what answers, not the // one-off rule that normally sits in front of it. const world = createWorld({ bagOffer: true, bagRepeatable: true, silver: 1000, }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'basic-trophy-pouch', 2, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); expect(world.character.silver).toBe(1000); expect(world.grantedBags).toHaveLength(0); }); 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' }); expect(world.character.silver).toBe(1000); expect(world.grantedItems).toHaveLength(0); }); it('does not blame reputation when reputation is not what is short', async () => { // The standing is already earned; a quest flag is the thing missing. // Telling this player to go and earn reputation would send them after // something they already have (slice §6). const world = createWorld({ silver: 1000, unmetConditionTypes: [GameConditionType.FLAG_SET], conditions: [ { type: GameConditionType.REGION_REPUTATION, key: 'border-guard', operator: ComparisonOperator.GTE, value: 10, }, { type: GameConditionType.FLAG_SET, key: 'vouched-for-by-the-warden', value: true, }, ], }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ), ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); expect(world.character.silver).toBe(1000); expect(world.grantedItems).toHaveLength(0); }); it('blocks a renown-gated offer without blaming merchant reputation', async () => { // Slice §10 case 4, and the discrimination this branch introduced: World // Renown is not the merchant's regard, so a renown block must read as a // plain lock. Sending this player off to trade pelts with Borin would be // pointing at the wrong bar entirely. const world = createWorld({ silver: 1000, unmetConditionTypes: [GameConditionType.WORLD_RENOWN], conditions: [ { type: GameConditionType.WORLD_RENOWN, operator: ComparisonOperator.GTE, value: 3, }, ], }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'small-healing-potion', 1, ), ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); expect(world.character.silver).toBe(1000); expect(world.grantedItems).toHaveLength(0); }); 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). The // requirement has to actually exist and hold for that to be the thing // under test. const world = createWorld({ bagOffer: true, silver: 10, conditions: [ { type: GameConditionType.REGION_REPUTATION, key: 'border-guard', operator: ComparisonOperator.GTE, value: 25, }, ], }); await expect( world.service.purchase( CHARACTER_ID, MERCHANT_KEY, 'basic-trophy-pouch', 1, ), ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); expect(world.character.silver).toBe(10); expect(world.grantedBags).toHaveLength(0); }); });