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 { LootCategory } from '../../items/loot-category.enum';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_LOOT_TABLE_ID,
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
|
BURNED_HOUND_LOOT_TABLE_ID,
|
||||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||||
ITEM_IDS,
|
ITEM_IDS,
|
||||||
ItemKey,
|
ItemKey,
|
||||||
|
RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
||||||
|
RAIDER_SCOUT_LOOT_TABLE_ID,
|
||||||
|
RAIDER_VETERAN_LOOT_TABLE_ID,
|
||||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||||
} from './item.constants';
|
} from './item.constants';
|
||||||
@@ -212,6 +216,29 @@ export const ITEM_DEFINITIONS: SeedItemDefinition[] = [
|
|||||||
{},
|
{},
|
||||||
LootCategory.RAIDER_TROPHY,
|
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 = [
|
export const LOOT_TABLES = [
|
||||||
@@ -231,6 +258,26 @@ export const LOOT_TABLES = [
|
|||||||
key: 'charred-looter-loot',
|
key: 'charred-looter-loot',
|
||||||
name: 'Charred Raider 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 {
|
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, 'reinforced-leather-jacket', 2, '0.1000'),
|
||||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'),
|
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'ash-boots', 3, '0.1000'),
|
||||||
entry(CHARRED_LOOTER_LOOT_TABLE_ID, 'borderwatch-sigil', 4, '0.0800'),
|
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',
|
'bandit-insignia': '50000000-0000-4000-8000-00000000000d',
|
||||||
'tough-hide': '50000000-0000-4000-8000-00000000000e',
|
'tough-hide': '50000000-0000-4000-8000-00000000000e',
|
||||||
'charred-raider-insignia': '50000000-0000-4000-8000-00000000000f',
|
'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;
|
} as const;
|
||||||
|
|
||||||
export type ItemKey = keyof typeof ITEM_IDS;
|
export type ItemKey = keyof typeof ITEM_IDS;
|
||||||
@@ -27,3 +29,14 @@ export const WILD_ROAD_DOG_LOOT_TABLE_ID =
|
|||||||
'60000000-0000-4000-8000-000000000003';
|
'60000000-0000-4000-8000-000000000003';
|
||||||
export const CHARRED_LOOTER_LOOT_TABLE_ID =
|
export const CHARRED_LOOTER_LOOT_TABLE_ID =
|
||||||
'60000000-0000-4000-8000-000000000004';
|
'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,
|
sortOrder: 4,
|
||||||
enabled: true,
|
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,
|
ASH_RAT_LOOT_TABLE_ID,
|
||||||
CHARRED_LOOTER_LOOT_TABLE_ID,
|
CHARRED_LOOTER_LOOT_TABLE_ID,
|
||||||
ITEM_IDS,
|
ITEM_IDS,
|
||||||
|
RAIDER_CAPTAIN_LOOT_TABLE_ID,
|
||||||
ROAD_BANDIT_LOOT_TABLE_ID,
|
ROAD_BANDIT_LOOT_TABLE_ID,
|
||||||
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
WILD_ROAD_DOG_LOOT_TABLE_ID,
|
||||||
} from './item.constants';
|
} from './item.constants';
|
||||||
@@ -532,7 +533,9 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
await seedVisibleVerticalSlice(dataSource);
|
await seedVisibleVerticalSlice(dataSource);
|
||||||
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(itemRepository.rows).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -586,8 +589,10 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One table per monster now that each has its own guaranteed trade good.
|
// One table per monster now that each has its own guaranteed trade good.
|
||||||
expect(lootTableRepository.rows).toHaveLength(4);
|
// Four for the Burned Road plus four more for the Watchpost (Playable
|
||||||
expect(lootEntryRepository.rows).toHaveLength(12);
|
// 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
|
// Every Burned Road enemy carries exactly one trade good as its first
|
||||||
// entry (spec §5). The three common ones were retuned from a guaranteed
|
// 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(borin.locationId).toBe(SOUTH_GATE_ID);
|
||||||
expect(npcShopRepository.rows).toHaveLength(1);
|
expect(npcShopRepository.rows).toHaveLength(1);
|
||||||
|
|
||||||
// All four Burned Road trade goods are accepted (slice 0.8 §5), and the
|
// All four Burned Road trade goods, plus the two Watchpost goods added in
|
||||||
// rare Charred Raider Insignia pays visibly more than the common pelt.
|
// Playable Slice 0.10 §10, are accepted, and the rare Charred Raider
|
||||||
expect(exchangeRuleRepository.rows).toHaveLength(4);
|
// Insignia pays visibly more than the common pelt.
|
||||||
|
expect(exchangeRuleRepository.rows).toHaveLength(6);
|
||||||
const silverByItem = new Map(
|
const silverByItem = new Map(
|
||||||
exchangeRuleRepository.rows.map((row: Row) => [
|
exchangeRuleRepository.rows.map((row: Row) => [
|
||||||
row.inputItemId,
|
row.inputItemId,
|
||||||
@@ -1257,4 +1263,109 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
).find((entry) => entry.poiKey === 'gate-watch') as Record<string, unknown>;
|
).find((entry) => entry.poiKey === 'gate-watch') as Record<string, unknown>;
|
||||||
expect(action.npcKey).toBe(SOUTH_GATE_WARDEN_KEY);
|
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