This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -0,0 +1,264 @@
import { DataSource, EntityManager } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { GameConditionService } from '../conditions/game-condition.service';
import { CharacterItem } from '../items/entities/character-item.entity';
import { NpcService } from '../npcs/npc.service';
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;
offerUnlocked?: boolean;
offerRepeatable?: boolean;
offerQuantity?: number;
ownedPotions?: number | null;
}
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 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',
},
},
] 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: async (row: Record<string, unknown>) => {
grantedItems.push(row);
return 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;
const conditions = {
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
} 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,
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").
const world = createWorld({ offerUnlocked: false, silver: 1000 });
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);
});
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' });
});
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' });
});
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' });
});
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);
});
});