feat(seed): add the watchpost trade goods and their loot tables

This commit is contained in:
Bastian Wagner
2026-08-23 11:38:12 +02:00
parent ac7750902a
commit 35964683f7
4 changed files with 221 additions and 6 deletions

View File

@@ -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'),
];

View File

@@ -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';

View File

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

View File

@@ -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<string, unknown>;
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');
});
});