✨Feature: implement loot resolution system with gold and item tracking from defeated creatures
This commit is contained in:
172
src/rules/loot.ts
Normal file
172
src/rules/loot.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { findCreatureById, findItemById, findTableByCode } from "@/data/contentHelpers";
|
||||
import type { ContentPack } from "@/types/content";
|
||||
import type { CombatState, InventoryEntry, InventoryState } from "@/types/state";
|
||||
import type { LogEntry } from "@/types/rules";
|
||||
|
||||
import type { DiceRoller } from "./dice";
|
||||
import { lookupTable } from "./tables";
|
||||
|
||||
export type ResolveCombatLootOptions = {
|
||||
content: ContentPack;
|
||||
combat: CombatState;
|
||||
inventory: InventoryState;
|
||||
roller?: DiceRoller;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type ResolveCombatLootResult = {
|
||||
inventory: InventoryState;
|
||||
itemsAwarded: InventoryEntry[];
|
||||
goldAwarded: number;
|
||||
logEntries: LogEntry[];
|
||||
};
|
||||
|
||||
function cloneInventory(inventory: InventoryState): InventoryState {
|
||||
return {
|
||||
...inventory,
|
||||
carried: inventory.carried.map((entry) => ({ ...entry })),
|
||||
equipped: inventory.equipped.map((entry) => ({ ...entry })),
|
||||
stored: inventory.stored.map((entry) => ({ ...entry })),
|
||||
currency: { ...inventory.currency },
|
||||
lightSources: inventory.lightSources.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryEntry(definitionId: string, quantity = 1): InventoryEntry {
|
||||
return {
|
||||
definitionId,
|
||||
quantity,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeInventoryEntry(
|
||||
content: ContentPack,
|
||||
entries: InventoryEntry[],
|
||||
definitionId: string,
|
||||
quantity: number,
|
||||
) {
|
||||
const item = findItemById(content, definitionId);
|
||||
|
||||
if (item.stackable) {
|
||||
const existing = entries.find((entry) => entry.definitionId === definitionId);
|
||||
|
||||
if (existing) {
|
||||
existing.quantity += quantity;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(createInventoryEntry(definitionId, quantity));
|
||||
}
|
||||
|
||||
function createLootLog(
|
||||
id: string,
|
||||
at: string,
|
||||
text: string,
|
||||
relatedIds: string[],
|
||||
): LogEntry {
|
||||
return {
|
||||
id,
|
||||
at,
|
||||
type: "loot",
|
||||
text,
|
||||
relatedIds,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeLoot(
|
||||
goldAwarded: number,
|
||||
itemsAwarded: InventoryEntry[],
|
||||
content: ContentPack,
|
||||
) {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (goldAwarded > 0) {
|
||||
parts.push(`${goldAwarded} gold`);
|
||||
}
|
||||
|
||||
for (const item of itemsAwarded) {
|
||||
const itemName = findItemById(content, item.definitionId).name;
|
||||
parts.push(item.quantity > 1 ? `${item.quantity}x ${itemName}` : itemName);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "nothing useful";
|
||||
}
|
||||
|
||||
export function resolveCombatLoot(
|
||||
options: ResolveCombatLootOptions,
|
||||
): ResolveCombatLootResult {
|
||||
const inventory = cloneInventory(options.inventory);
|
||||
const itemsAwarded: InventoryEntry[] = [];
|
||||
const logEntries: LogEntry[] = [];
|
||||
let goldAwarded = 0;
|
||||
|
||||
const defeatedCreatures = options.combat.enemies.filter(
|
||||
(enemy) => enemy.hpCurrent === 0 && enemy.sourceDefinitionId,
|
||||
);
|
||||
|
||||
for (const enemy of defeatedCreatures) {
|
||||
const creature = findCreatureById(options.content, enemy.sourceDefinitionId!);
|
||||
const lootTableCodes = creature.lootTableCodes ?? [];
|
||||
|
||||
for (const tableCode of lootTableCodes) {
|
||||
const table = findTableByCode(options.content, tableCode);
|
||||
const lookup = lookupTable(table, { roller: options.roller });
|
||||
const rollTotal = lookup.roll.modifiedTotal ?? lookup.roll.total;
|
||||
|
||||
logEntries.push({
|
||||
id: `${options.combat.id}.${creature.id}.${tableCode}.roll`,
|
||||
at: options.at,
|
||||
type: "roll",
|
||||
text: `Rolled loot ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${tableCode} for ${rollTotal}: ${lookup.entry.label}.`,
|
||||
relatedIds: [options.combat.id, creature.id, tableCode],
|
||||
});
|
||||
|
||||
let creatureGold = 0;
|
||||
const creatureItems: InventoryEntry[] = [];
|
||||
|
||||
for (const effect of lookup.entry.effects ?? []) {
|
||||
if (effect.type === "gain-gold" && effect.amount) {
|
||||
creatureGold += effect.amount;
|
||||
}
|
||||
|
||||
if (effect.type === "add-item" && effect.referenceId) {
|
||||
mergeInventoryEntry(options.content, inventory.carried, effect.referenceId, effect.amount ?? 1);
|
||||
mergeInventoryEntry(options.content, itemsAwarded, effect.referenceId, effect.amount ?? 1);
|
||||
mergeInventoryEntry(options.content, creatureItems, effect.referenceId, effect.amount ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const reference of lookup.entry.references ?? []) {
|
||||
if (reference.type !== "item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
mergeInventoryEntry(options.content, inventory.carried, reference.id, 1);
|
||||
mergeInventoryEntry(options.content, itemsAwarded, reference.id, 1);
|
||||
mergeInventoryEntry(options.content, creatureItems, reference.id, 1);
|
||||
}
|
||||
|
||||
if (creatureGold > 0) {
|
||||
inventory.currency.gold += creatureGold;
|
||||
goldAwarded += creatureGold;
|
||||
}
|
||||
|
||||
logEntries.push(
|
||||
createLootLog(
|
||||
`${options.combat.id}.${creature.id}.${tableCode}.loot`,
|
||||
options.at,
|
||||
`${creature.name} yielded ${summarizeLoot(creatureGold, creatureItems, options.content)}.`,
|
||||
[options.combat.id, creature.id, tableCode],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inventory,
|
||||
itemsAwarded,
|
||||
goldAwarded,
|
||||
logEntries,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user