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, + }, + ], + }); + }); });