test(seed): prove a re-seed leaves five offers at the tuned numbers

The five offer tests this branch added all read the SHOP_OFFERS constant; none
ran the seed, so the one guarantee the stable-id design exists to provide --
re-seeding does not duplicate content -- was covered by nothing. This runs
`seedVisibleVerticalSlice` twice against an in-memory shop-offer repository and
asserts the five stable ids survive.

Confirmed catchable: reverting the conflict target to
['shopId', 'itemDefinitionId'] leaves 4 rows under the fake, because both bag
offers carry `itemDefinitionId: null` and collapse into one. On Postgres it
would fail outright.

The prices (12/30/40/35/60) and thresholds (reputation 25 and 40, renown 3) are
pinned in the same test: AGENTS.md §39 forbids silent rebalancing, and the
structural tests never looked at a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 21:07:26 +02:00
parent a454cf955e
commit 1a7018d790

View File

@@ -20,7 +20,10 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity';
import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity';
import { ShopOffer } from '../../shops/entities/shop-offer.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity';
import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants'; import { DEMO_CHARACTER_STARTING_WEAPON_ITEM_ID } from '../../demo/demo-character.constants';
import { GameConditionType } from '../../conditions/game-condition.types'; import {
ComparisonOperator,
GameConditionType,
} from '../../conditions/game-condition.types';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
CHARRED_LOOTER_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID,
@@ -29,7 +32,7 @@ import {
WILD_ROAD_DOG_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID,
} from './item.constants'; } from './item.constants';
import { BASIC_HIDE_BAG_ID } from './loot-bag-content'; import { BASIC_HIDE_BAG_ID } from './loot-bag-content';
import { SHOP_OFFERS } from './npc-content'; import { BORIN_OFFER_IDS, SHOP_OFFERS } from './npc-content';
import { seedVisibleVerticalSlice } from './vertical-slice.seed'; import { seedVisibleVerticalSlice } from './vertical-slice.seed';
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@@ -956,4 +959,98 @@ describe('seedVisibleVerticalSlice', () => {
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
expect(ids.every((id) => id.length === 36)).toBe(true); expect(ids.every((id) => id.length === 36)).toBe(true);
}); });
it('still holds exactly five offers after a re-seed, at the tuned numbers', async () => {
const shopOfferRepository = new InMemoryRepository();
const dataSource = createDataSource(
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
new InMemoryRepository(),
shopOfferRepository,
);
// Twice, because that is the whole point of the stable ids (NPC spec §32).
// Both bag offers carry `itemDefinitionId: null`, so the pre-0.8.5 conflict
// target of (shopId, itemDefinitionId) would fold them into a single row
// here -- and would blow up outright on Postgres, where NULL never equals
// NULL in a unique index.
await seedVisibleVerticalSlice(dataSource);
await seedVisibleVerticalSlice(dataSource);
expect(shopOfferRepository.rows).toHaveLength(5);
expect(
shopOfferRepository.rows.map((row: Row) => String(row.id)).sort(),
).toEqual(
[
BORIN_OFFER_IDS.potion,
BORIN_OFFER_IDS.shortsword,
BORIN_OFFER_IDS.trophyPouch,
BORIN_OFFER_IDS.hideBag,
BORIN_OFFER_IDS.banditBlade,
].sort(),
);
// Prices and thresholds are balancing decisions, not incidentals, so a
// refactor must not be able to drift one silently (AGENTS.md §39).
const byId = new Map<string, Row>(
shopOfferRepository.rows.map((row: Row): [string, Row] => [
String(row.id),
row,
]),
);
expect(byId.get(BORIN_OFFER_IDS.potion)).toMatchObject({
price: 12,
conditions: [],
});
expect(byId.get(BORIN_OFFER_IDS.shortsword)).toMatchObject({
price: 30,
conditions: [],
});
expect(byId.get(BORIN_OFFER_IDS.trophyPouch)).toMatchObject({
price: 40,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 25,
},
],
});
expect(byId.get(BORIN_OFFER_IDS.hideBag)).toMatchObject({
price: 35,
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 40,
},
],
});
expect(byId.get(BORIN_OFFER_IDS.banditBlade)).toMatchObject({
price: 60,
conditions: [
{
type: GameConditionType.WORLD_RENOWN,
operator: ComparisonOperator.GTE,
value: 3,
},
],
});
});
}); });