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;
|
silver?: number;
|
||||||
shopEnabled?: boolean;
|
shopEnabled?: boolean;
|
||||||
hasShop?: boolean;
|
hasShop?: boolean;
|
||||||
|
/** The fake engine's blanket answer for every condition it is handed. */
|
||||||
offerUnlocked?: boolean;
|
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;
|
offerRepeatable?: boolean;
|
||||||
offerQuantity?: number;
|
offerQuantity?: number;
|
||||||
ownedPotions?: number | null;
|
ownedPotions?: number | null;
|
||||||
/** Replaces the single item offer with one that sells the trophy pouch. */
|
/** Replaces the single item offer with one that sells the trophy pouch. */
|
||||||
bagOffer?: boolean;
|
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. */
|
/** Whether the character already holds the bag the offer sells. */
|
||||||
ownsBag?: boolean;
|
ownsBag?: boolean;
|
||||||
/** Conditions on the offer, so a test can gate it on reputation. */
|
/** Conditions on the offer, so a test can gate it on reputation. */
|
||||||
conditions?: GameCondition[];
|
conditions?: GameCondition[];
|
||||||
/** The alternative way in (slice §7). */
|
/** The alternative way in (slice §7). */
|
||||||
bypassConditions?: GameCondition[];
|
bypassConditions?: GameCondition[];
|
||||||
/** Which of `conditions` / `bypassConditions` the fake engine says hold. */
|
/** Whether the fake engine says the bypass list holds. */
|
||||||
bypassPasses?: boolean;
|
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 = {}) {
|
function createWorld(fixture: Fixture = {}) {
|
||||||
const character = {
|
const character = {
|
||||||
id: CHARACTER_ID,
|
id: CHARACTER_ID,
|
||||||
@@ -76,7 +98,7 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
currencyType: 'SILVER',
|
currencyType: 'SILVER',
|
||||||
price: 40,
|
price: 40,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
repeatable: false,
|
repeatable: fixture.bagRepeatable ?? false,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
conditions: fixture.conditions ?? [],
|
conditions: fixture.conditions ?? [],
|
||||||
bypassConditions: fixture.bypassConditions ?? [],
|
bypassConditions: fixture.bypassConditions ?? [],
|
||||||
@@ -184,25 +206,40 @@ function createWorld(fixture: Fixture = {}) {
|
|||||||
run(manager),
|
run(manager),
|
||||||
} as unknown as DataSource;
|
} 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 = {
|
const conditions = {
|
||||||
evaluate: jest.fn(
|
evaluate: jest.fn(
|
||||||
(_context: unknown, list: GameCondition[] | undefined) => {
|
(_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 fixture distinguishes the two lists by identity, so a test can say
|
||||||
// "the gate is shut but the bypass is open".
|
// "the gate is shut but the bypass is open".
|
||||||
if (list === fixture.bypassConditions) {
|
if (list === fixture.bypassConditions) {
|
||||||
return Promise.resolve(fixture.bypassPasses ?? false);
|
return Promise.resolve(fixture.bypassPasses ?? false);
|
||||||
}
|
}
|
||||||
// `offerUnlocked` is the fixture's switch for the offer's own gate,
|
// Answered from the list's own contents, so handing the gate the wrong
|
||||||
// whatever the conditions expressing it happen to be.
|
// list cannot pass unnoticed.
|
||||||
return Promise.resolve(fixture.offerUnlocked ?? true);
|
return Promise.resolve(list.every(conditionHolds));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
|
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
(list ?? []).map((condition) => ({
|
(list ?? []).map((condition) => ({
|
||||||
condition,
|
condition,
|
||||||
met: fixture.offerUnlocked ?? true,
|
met: conditionHolds(condition),
|
||||||
actual: 14,
|
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 () => {
|
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
|
// 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
|
// protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
|
||||||
// gekauft werden").
|
// gekauft werden"). Gated on a flag rather than reputation, because a
|
||||||
const world = createWorld({ offerUnlocked: false, silver: 1000 });
|
// 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(
|
await expect(
|
||||||
world.service.purchase(
|
world.service.purchase(
|
||||||
@@ -304,6 +352,37 @@ describe('ShopService', () => {
|
|||||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||||
|
|
||||||
expect(world.character.silver).toBe(1000);
|
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 () => {
|
it('refuses an item the shop does not stock', async () => {
|
||||||
@@ -312,6 +391,9 @@ describe('ShopService', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
|
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
|
||||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
|
).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 () => {
|
it('refuses a non-positive quantity', async () => {
|
||||||
@@ -325,6 +407,9 @@ describe('ShopService', () => {
|
|||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
).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 () => {
|
it('refuses to buy a one-off offer more than once in a request', async () => {
|
||||||
@@ -338,6 +423,9 @@ describe('ShopService', () => {
|
|||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||||
|
|
||||||
|
expect(world.character.silver).toBe(100);
|
||||||
|
expect(world.grantedItems).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a closed shop', async () => {
|
it('refuses a closed shop', async () => {
|
||||||
@@ -439,6 +527,30 @@ describe('ShopService', () => {
|
|||||||
).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' });
|
).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' });
|
||||||
|
|
||||||
expect(world.character.silver).toBe(100);
|
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 () => {
|
it('names reputation as the reason when a reputation gate is what blocks', async () => {
|
||||||
@@ -463,6 +575,44 @@ describe('ShopService', () => {
|
|||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' });
|
).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 () => {
|
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' });
|
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||||
|
|
||||||
|
expect(world.character.silver).toBe(10);
|
||||||
expect(world.grantedBags).toHaveLength(0);
|
expect(world.grantedBags).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user