feat(seed): add the watchpost trade goods and their loot tables
This commit is contained in:
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user