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:
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user