diff --git a/apps/api/src/shops/shop.service.spec.ts b/apps/api/src/shops/shop.service.spec.ts index ca7b524..520ff9e 100644 --- a/apps/api/src/shops/shop.service.spec.ts +++ b/apps/api/src/shops/shop.service.spec.ts @@ -23,22 +23,44 @@ 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[]; - /** Which of `conditions` / `bypassConditions` the fake engine says hold. */ + /** 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, @@ -76,7 +98,7 @@ function createWorld(fixture: Fixture = {}) { currencyType: 'SILVER', price: 40, quantity: 1, - repeatable: false, + repeatable: fixture.bagRepeatable ?? false, sortOrder: 1, conditions: fixture.conditions ?? [], bypassConditions: fixture.bypassConditions ?? [], @@ -184,25 +206,40 @@ function createWorld(fixture: Fixture = {}) { 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); } - // `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); + // 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: fixture.offerUnlocked ?? true, - actual: 14, + met: conditionHolds(condition), + actual: FAKE_MEASURED_VALUE[condition.type] ?? null, })), ), ), @@ -291,8 +328,19 @@ describe('ShopService', () => { 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"). - const world = createWorld({ offerUnlocked: false, silver: 1000 }); + // 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( @@ -304,6 +352,37 @@ describe('ShopService', () => { ).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 () => { @@ -312,6 +391,9 @@ describe('ShopService', () => { 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 () => { @@ -325,6 +407,9 @@ describe('ShopService', () => { 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 () => { @@ -338,6 +423,9 @@ describe('ShopService', () => { 2, ), ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); + + expect(world.character.silver).toBe(100); + expect(world.grantedItems).toHaveLength(0); }); it('refuses a closed shop', async () => { @@ -439,6 +527,30 @@ describe('ShopService', () => { ).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 () => { @@ -463,6 +575,44 @@ describe('ShopService', () => { 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('opens an offer whose bypass holds even though its conditions do not', async () => { @@ -515,6 +665,7 @@ describe('ShopService', () => { ), ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); + expect(world.character.silver).toBe(10); expect(world.grantedBags).toHaveLength(0); }); });