diff --git a/apps/api/src/database/seeds/item-content.ts b/apps/api/src/database/seeds/item-content.ts index 4115761..a780985 100644 --- a/apps/api/src/database/seeds/item-content.ts +++ b/apps/api/src/database/seeds/item-content.ts @@ -4,9 +4,13 @@ import { ItemType } from '../../items/item-type.enum'; import { LootCategory } from '../../items/loot-category.enum'; import { ASH_RAT_LOOT_TABLE_ID, + BURNED_HOUND_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, ItemKey, + RAIDER_CAPTAIN_LOOT_TABLE_ID, + RAIDER_SCOUT_LOOT_TABLE_ID, + RAIDER_VETERAN_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; @@ -212,6 +216,29 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [ {}, LootCategory.RAIDER_TROPHY, ), + // Watchpost trade goods (Playable Slice 0.10 §6). One per carrying + // category, so both the Hide Bag and the Trophy Pouch matter at this + // location and a player who owns neither runs into the capacity of 1. + item( + 'scorched-hide', + 'Scorched Hide', + 'Hound hide burned hard as bark, the cracks in it still warm.', + ItemType.TRADE_GOOD, + null, + ItemRarity.COMMON, + {}, + LootCategory.HIDE, + ), + item( + 'raider-warband-mark', + 'Raider Warband Mark', + 'A watchpost tally-token, re-stamped with the mark of the band that took it.', + ItemType.TROPHY, + null, + ItemRarity.COMMON, + {}, + LootCategory.RAIDER_TROPHY, + ), ]; export const LOOT_TABLES = [ @@ -231,6 +258,26 @@ export const LOOT_TABLES = [ key: 'charred-looter-loot', name: 'Charred Raider Loot', }, + { + id: RAIDER_SCOUT_LOOT_TABLE_ID, + key: 'raider-scout-loot', + name: 'Raider Scout Loot', + }, + { + id: BURNED_HOUND_LOOT_TABLE_ID, + key: 'burned-hound-loot', + name: 'Burned Hound Loot', + }, + { + id: RAIDER_VETERAN_LOOT_TABLE_ID, + key: 'raider-veteran-loot', + name: 'Raider Veteran Loot', + }, + { + id: RAIDER_CAPTAIN_LOOT_TABLE_ID, + key: 'raider-captain-loot', + name: 'Raider Captain Loot', + }, ]; export interface SeedLootTableEntry { @@ -290,4 +337,21 @@ export const LOOT_TABLE_ENTRIES: SeedLootTableEntry[] = [ entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.1000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'), entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'), + // Watchpost (Playable Slice 0.10 §7). The Tier-1 pieces already exist in + // content; what this location changes is how often they show up. The + // Charred Captain's Pendant stays out -- it belongs to the Slice 0.11 boss, + // not to this elite. + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.6000'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-blade', 2, '0.2500'), + entry(RAIDER_SCOUT_LOOT_TABLE_ID, 'bandit-hood', 3, '0.1800'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'scorched-hide', 1, '0.6000'), + entry(BURNED_HOUND_LOOT_TABLE_ID, 'ash-boots', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '0.7000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'raider-gloves', 2, '0.1500'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 3, '0.2000'), + entry(RAIDER_VETERAN_LOOT_TABLE_ID, 'guardsman-legs', 4, '0.2200'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'raider-warband-mark', 1, '1.0000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'reinforced-leather-jacket', 2, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'guardsman-legs', 3, '0.3000'), + entry(RAIDER_CAPTAIN_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.2000'), ]; diff --git a/apps/api/src/database/seeds/item.constants.ts b/apps/api/src/database/seeds/item.constants.ts index 54b6036..72a14a9 100644 --- a/apps/api/src/database/seeds/item.constants.ts +++ b/apps/api/src/database/seeds/item.constants.ts @@ -15,6 +15,8 @@ export const ITEM_IDS = { 'bandit-insignia': '50000000-0000-4000-8000-00000000000d', 'tough-hide': '50000000-0000-4000-8000-00000000000e', 'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f', + 'scorched-hide': '50000000-0000-4000-8000-000000000010', + 'raider-warband-mark': '50000000-0000-4000-8000-000000000011', } as const; export type ItemKey = keyof typeof ITEM_IDS; @@ -27,3 +29,14 @@ export const WILD_ROAD_DOG_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000003'; export const CHARRED_LOOTER_LOOT_TABLE_ID = '60000000-0000-4000-8000-000000000004'; + +// Playable Slice 0.10 §6: one table per Watchpost enemy, same rule as the +// Burned Road -- each enemy owns its guaranteed trade good. +export const RAIDER_SCOUT_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000005'; +export const BURNED_HOUND_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000006'; +export const RAIDER_VETERAN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000007'; +export const RAIDER_CAPTAIN_LOOT_TABLE_ID = + '60000000-0000-4000-8000-000000000008'; diff --git a/apps/api/src/database/seeds/npc-content.ts b/apps/api/src/database/seeds/npc-content.ts index 12a4cf5..df54c14 100644 --- a/apps/api/src/database/seeds/npc-content.ts +++ b/apps/api/src/database/seeds/npc-content.ts @@ -550,4 +550,31 @@ export const EXCHANGE_RULES: SeedExchangeRule[] = [ sortOrder: 4, enabled: true, }, + // Watchpost goods (Playable Slice 0.10 §10). Priced above the road tier so + // the longer trip pays, and below the rare Charred Raider Insignia so the + // rare drop stays the best thing in the region. + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['scorched-hide'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 12, + regionReputationReward: 4, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 5, + enabled: true, + }, + { + profileId: BORIN_EXCHANGE_PROFILE_ID, + inputItemId: ITEM_IDS['raider-warband-mark'], + inputQuantity: 1, + factionId: BORDER_GUARD_FACTION_ID, + silverReward: 20, + regionReputationReward: 7, + renownMilestoneKey: FIRST_TRADE_MILESTONE_KEY, + conditions: [], + sortOrder: 6, + enabled: true, + }, ]; 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 781a4e4..f4fa68a 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -28,6 +28,7 @@ import { ASH_RAT_LOOT_TABLE_ID, CHARRED_LOOTER_LOOT_TABLE_ID, ITEM_IDS, + RAIDER_CAPTAIN_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, WILD_ROAD_DOG_LOOT_TABLE_ID, } from './item.constants'; @@ -532,7 +533,9 @@ describe('seedVisibleVerticalSlice', () => { await seedVisibleVerticalSlice(dataSource); await seedVisibleVerticalSlice(dataSource); - expect(itemRepository.rows).toHaveLength(15); + // 15 Burned Road items plus the two Watchpost trade goods (Playable + // Slice 0.10 §6): the same item repository holds both locations' content. + expect(itemRepository.rows).toHaveLength(17); expect(itemRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -586,8 +589,10 @@ describe('seedVisibleVerticalSlice', () => { } // One table per monster now that each has its own guaranteed trade good. - expect(lootTableRepository.rows).toHaveLength(4); - expect(lootEntryRepository.rows).toHaveLength(12); + // Four for the Burned Road plus four more for the Watchpost (Playable + // Slice 0.10 §6, §7), all upserted into this same loot table repository. + expect(lootTableRepository.rows).toHaveLength(8); + expect(lootEntryRepository.rows).toHaveLength(25); // Every Burned Road enemy carries exactly one trade good as its first // entry (spec §5). The three common ones were retuned from a guaranteed @@ -868,9 +873,10 @@ describe('seedVisibleVerticalSlice', () => { expect(borin.locationId).toBe(SOUTH_GATE_ID); expect(npcShopRepository.rows).toHaveLength(1); - // All four Burned Road trade goods are accepted (slice 0.8 §5), and the - // rare Charred Raider Insignia pays visibly more than the common pelt. - expect(exchangeRuleRepository.rows).toHaveLength(4); + // All four Burned Road trade goods, plus the two Watchpost goods added in + // Playable Slice 0.10 §10, are accepted, and the rare Charred Raider + // Insignia pays visibly more than the common pelt. + expect(exchangeRuleRepository.rows).toHaveLength(6); const silverByItem = new Map( exchangeRuleRepository.rows.map((row: Row) => [ row.inputItemId, @@ -1257,4 +1263,109 @@ describe('seedVisibleVerticalSlice', () => { ).find((entry) => entry.poiKey === 'gate-watch') as Record; expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY); }); + + it('gives each new trade good the carrying category it belongs to', async () => { + const itemRepository = new InMemoryRepository(); + const dataSource = createDataSource({ item: itemRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const hide = itemRepository.rows.find( + (row) => row.key === 'scorched-hide', + ); + const mark = itemRepository.rows.find( + (row) => row.key === 'raider-warband-mark', + ); + + expect(hide?.lootCategory).toBe('HIDE'); + expect(mark?.lootCategory).toBe('RAIDER_TROPHY'); + }); + + it('lets Borin buy both watchpost trade goods', async () => { + const exchangeRuleRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + exchangeRule: exchangeRuleRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const hideRule = exchangeRuleRepository.rows.find( + (row) => row.inputItemId === ITEM_IDS['scorched-hide'], + ); + const markRule = exchangeRuleRepository.rows.find( + (row) => row.inputItemId === ITEM_IDS['raider-warband-mark'], + ); + + expect(hideRule).toMatchObject({ + silverReward: 12, + regionReputationReward: 4, + }); + expect(markRule).toMatchObject({ + silverReward: 20, + regionReputationReward: 7, + }); + }); + + it('pays more for watchpost goods than for road goods', async () => { + const exchangeRuleRepository = new InMemoryRepository(); + const dataSource = createDataSource({ + exchangeRule: exchangeRuleRepository, + }); + + await seedVisibleVerticalSlice(dataSource); + + const silverFor = (key: keyof typeof ITEM_IDS) => + exchangeRuleRepository.rows.find( + (row) => row.inputItemId === ITEM_IDS[key], + )?.silverReward as number; + + // The longer trip has to pay, or §10's loop has no pull (slice §6). + expect(silverFor('scorched-hide')).toBeGreaterThan( + silverFor('tough-hide'), + ); + expect(silverFor('raider-warband-mark')).toBeGreaterThan( + silverFor('bandit-insignia'), + ); + }); + + it('guarantees the captain trophy and offers its focused drop', async () => { + const lootEntryRepository = new InMemoryRepository(); + const dataSource = createDataSource({ lootEntry: lootEntryRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const entries = lootEntryRepository.rows.filter( + (row) => row.lootTableId === RAIDER_CAPTAIN_LOOT_TABLE_ID, + ); + + expect( + entries.find( + (row) => row.itemDefinitionId === ITEM_IDS['raider-warband-mark'], + )?.dropChance, + ).toBe('1.0000'); + expect( + entries.find( + (row) => row.itemDefinitionId === ITEM_IDS['borderwatch-sigil'], + )?.dropChance, + ).toBe('0.2000'); + }); + + it('leaves the road bandit loot table untouched', async () => { + const lootEntryRepository = new InMemoryRepository(); + const dataSource = createDataSource({ lootEntry: lootEntryRepository }); + + await seedVisibleVerticalSlice(dataSource); + + const entries = lootEntryRepository.rows.filter( + (row) => row.lootTableId === ROAD_BANDIT_LOOT_TABLE_ID, + ); + + // This slice adds a pool entry for the bandit, not a rebalance + // (AGENTS.md §39). + expect(entries).toHaveLength(4); + expect( + entries.find((row) => row.itemDefinitionId === ITEM_IDS['bandit-blade']) + ?.dropChance, + ).toBe('0.1800'); + }); });