From 1a7018d790881e6ad90641bf795c7870f1a5d7b7 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 21:07:26 +0200 Subject: [PATCH] test(seed): prove a re-seed leaves five offers at the tuned numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../seeds/vertical-slice.seed.spec.ts | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index 9d797b3..f3bf441 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -20,7 +20,10 @@ import { NpcDefinition } from '../../npcs/entities/npc-definition.entity'; import { NpcShop } from '../../shops/entities/npc-shop.entity'; import { ShopOffer } from '../../shops/entities/shop-offer.entity'; 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 { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, @@ -29,7 +32,7 @@ import { WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; 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'; type Row = Record; @@ -956,4 +959,98 @@ describe('seedVisibleVerticalSlice', () => { expect(new Set(ids).size).toBe(ids.length); 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( + 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, + }, + ], + }); + }); });