feat(shops): sell loot bags, honour bypass conditions, explain locks
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
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';
|
||||
@@ -18,6 +27,16 @@ interface Fixture {
|
||||
offerRepeatable?: boolean;
|
||||
offerQuantity?: number;
|
||||
ownedPotions?: number | null;
|
||||
/** Replaces the single item offer with one that sells the trophy pouch. */
|
||||
bagOffer?: 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. */
|
||||
bypassPasses?: boolean;
|
||||
}
|
||||
|
||||
function createWorld(fixture: Fixture = {}) {
|
||||
@@ -36,26 +55,61 @@ function createWorld(fixture: Fixture = {}) {
|
||||
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 = [
|
||||
{
|
||||
id: 'offer-1',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: 'item-potion',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: fixture.offerQuantity ?? 1,
|
||||
repeatable: fixture.offerRepeatable ?? true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
itemDefinition: {
|
||||
id: 'item-potion',
|
||||
key: 'small-healing-potion',
|
||||
name: 'Small Healing Potion',
|
||||
description: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
},
|
||||
},
|
||||
fixture.bagOffer
|
||||
? {
|
||||
id: 'offer-bag',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: null,
|
||||
lootBagDefinitionId: bag.id,
|
||||
currencyType: 'SILVER',
|
||||
price: 40,
|
||||
quantity: 1,
|
||||
repeatable: 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) => {
|
||||
@@ -88,9 +142,35 @@ function createWorld(fixture: Fixture = {}) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(owned),
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
save: (row: Record<string, unknown>) => {
|
||||
grantedItems.push(row);
|
||||
return 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -105,7 +185,27 @@ function createWorld(fixture: Fixture = {}) {
|
||||
} as unknown as DataSource;
|
||||
|
||||
const conditions = {
|
||||
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
|
||||
evaluate: jest.fn(
|
||||
(_context: unknown, list: GameCondition[] | undefined) => {
|
||||
// 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);
|
||||
},
|
||||
),
|
||||
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
|
||||
Promise.resolve(
|
||||
(list ?? []).map((condition) => ({
|
||||
condition,
|
||||
met: fixture.offerUnlocked ?? true,
|
||||
actual: 14,
|
||||
})),
|
||||
),
|
||||
),
|
||||
} as unknown as GameConditionService;
|
||||
|
||||
const npcs = {
|
||||
@@ -116,6 +216,7 @@ function createWorld(fixture: Fixture = {}) {
|
||||
service: new ShopService(dataSource, conditions, npcs),
|
||||
character,
|
||||
grantedItems,
|
||||
grantedBags,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
@@ -261,4 +362,159 @@ describe('ShopService', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('grants a bag rather than stacking it as an item', async () => {
|
||||
const world = createWorld({ bagOffer: true, silver: 100 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
|
||||
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).
|
||||
const world = createWorld({ bagOffer: true, silver: 10 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'basic-trophy-pouch',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||
|
||||
expect(world.grantedBags).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user