feat(shops): sell loot bags, honour bypass conditions, explain locks

This commit is contained in:
Bastian Wagner
2026-08-22 18:34:24 +02:00
parent 7e1b79315e
commit 7a5e800a18
4 changed files with 539 additions and 54 deletions

View File

@@ -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);
});
});