test(shops): make the gate's guards fail when they are removed
This commit is contained in:
@@ -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<Record<GameConditionType, number>> = {
|
||||
[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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user