Case 7 ("price is still required even when the reputation condition is met")
and case 3 ("sufficient regional reputation allows purchase") both built
fixtures with `conditions: []`. With no requirement present, none can be met,
so neither test touched the gate it was named after. Both now carry a
satisfied REGION_REPUTATION condition.
Case 4 ("insufficient World Renown blocks purchase") had no test at all. It
matters because a renown block must surface as SHOP_OFFER_LOCKED rather than
MERCHANT_REPUTATION_TOO_LOW -- renown is not the merchant's regard, and
telling the player to go and earn reputation would point at the wrong bar.
Verified by widening the reputation-blame check to include WORLD_RENOWN, which
fails the new test alone.
Also pins a bag offer's description to empty, so the duplicate capacity line
cannot come back through the API side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
732 lines
22 KiB
TypeScript
732 lines
22 KiB
TypeScript
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<Record<GameConditionType, number>> = {
|
|
[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<Record<string, unknown>> = [];
|
|
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<Record<string, unknown>> = [];
|
|
|
|
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<string, unknown>) => row,
|
|
save: (row: Record<string, unknown>) => {
|
|
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<string, unknown>) => row,
|
|
save: (row: Record<string, unknown>) => {
|
|
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 <T>(run: (m: EntityManager) => Promise<T>) =>
|
|
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);
|
|
});
|
|
});
|