diff --git a/src/App.tsx b/src/App.tsx index 681e4e7..0483845 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -173,7 +173,25 @@ function App() { .
- Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated. + Run rewards: {run.xpGained} XP, {run.goldGained} gold,{" "} + {run.defeatedCreatureIds.length} foes defeated. +
++ Carried gold: {run.adventurerSnapshot.inventory.currency.gold}. Looted items:{" "} + {run.lootedItems.length === 0 + ? "none yet" + : run.lootedItems + .map((entry) => { + const item = sampleContentPack.items.find( + (candidate) => candidate.id === entry.definitionId, + ); + + return entry.quantity > 1 + ? `${entry.quantity}x ${item?.name ?? entry.definitionId}` + : item?.name ?? entry.definitionId; + }) + .join(", ")} + .
diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts index ccc4880..75ab385 100644 --- a/src/data/contentHelpers.test.ts +++ b/src/data/contentHelpers.test.ts @@ -4,6 +4,7 @@ import { lookupTable } from "@/rules/tables"; import { findCreatureByName, + findItemById, findRoomTemplateForLookup, findTableByCode, } from "./contentHelpers"; @@ -57,4 +58,11 @@ describe("level 1 content helpers", () => { expect(creature.id).toBe("creature.level1.guard"); expect(creature.hp).toBeGreaterThan(0); }); + + it("finds a loot item definition by id", () => { + const item = findItemById(sampleContentPack, "item.silver-clasp"); + + expect(item.name).toBe("Silver Clasp"); + expect(item.itemType).toBe("treasure"); + }); }); diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts index 927aa60..ad04c7b 100644 --- a/src/data/contentHelpers.ts +++ b/src/data/contentHelpers.ts @@ -1,6 +1,7 @@ import type { ContentPack, CreatureDefinition, + ItemDefinition, RoomTemplate, TableDefinition, } from "@/types/content"; @@ -74,3 +75,13 @@ export function findCreatureById( return creature; } + +export function findItemById(content: ContentPack, itemId: string): ItemDefinition { + const item = content.items.find((entry) => entry.id === itemId); + + if (!item) { + throw new Error(`Unknown item id: ${itemId}`); + } + + return item; +} diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index b2cd348..2418d8b 100644 --- a/src/data/sampleContentPack.ts +++ b/src/data/sampleContentPack.ts @@ -13,6 +13,79 @@ const samplePack = { ], tables: [ ...level1EncounterTables, + { + id: "table.level1.humanoid-loot", + code: "L1HL", + name: "Level 1 Humanoid Loot", + category: "loot", + level: 1, + page: 122, + diceKind: "d6", + entries: [ + { + key: "1-2", + min: 1, + max: 2, + label: "Scavenged coins", + effects: [{ type: "gain-gold", amount: 1, target: "self" }], + }, + { + key: "3-4", + min: 3, + max: 4, + label: "Bone Charm", + references: [{ type: "item", id: "item.bone-charm" }], + }, + { + key: "5", + exact: 5, + label: "Silver Clasp and coins", + references: [{ type: "item", id: "item.silver-clasp" }], + effects: [{ type: "gain-gold", amount: 2, target: "self" }], + }, + { + key: "6", + exact: 6, + label: "Key Ring and coin purse", + references: [{ type: "item", id: "item.keeper-keyring" }], + effects: [{ type: "gain-gold", amount: 3, target: "self" }], + }, + ], + notes: ["Starter loot table for martial and humanoid encounters."], + mvp: true, + }, + { + id: "table.level1.beast-loot", + code: "L1BL", + name: "Level 1 Beast Loot", + category: "loot", + level: 1, + page: 122, + diceKind: "d6", + entries: [ + { + key: "1-4", + min: 1, + max: 4, + label: "Nothing useful", + }, + { + key: "5", + exact: 5, + label: "Trophy Fang", + references: [{ type: "item", id: "item.trophy-fang" }], + }, + { + key: "6", + exact: 6, + label: "Trophy Fang and stray coin", + references: [{ type: "item", id: "item.trophy-fang" }], + effects: [{ type: "gain-gold", amount: 1, target: "self" }], + }, + ], + notes: ["Starter loot table for basic animal encounters."], + mvp: true, + }, { id: "table.weapon-manoeuvres-1", code: "WMT1", @@ -123,6 +196,42 @@ const samplePack = { consumable: false, mvp: true, }, + { + id: "item.bone-charm", + name: "Bone Charm", + itemType: "treasure", + stackable: false, + consumable: false, + valueGp: 2, + mvp: true, + }, + { + id: "item.silver-clasp", + name: "Silver Clasp", + itemType: "treasure", + stackable: false, + consumable: false, + valueGp: 4, + mvp: true, + }, + { + id: "item.keeper-keyring", + name: "Keeper Keyring", + itemType: "treasure", + stackable: false, + consumable: false, + valueGp: 5, + mvp: true, + }, + { + id: "item.trophy-fang", + name: "Trophy Fang", + itemType: "treasure", + stackable: true, + consumable: false, + valueGp: 1, + mvp: true, + }, ], potions: [ { @@ -163,6 +272,7 @@ const samplePack = { numberAppearing: "1-2", }, xpReward: 1, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "sample"], mvp: true, @@ -182,6 +292,7 @@ const samplePack = { armour: 1, }, xpReward: 2, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -201,6 +312,7 @@ const samplePack = { armour: 1, }, xpReward: 3, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -217,6 +329,7 @@ const samplePack = { damage: 1, }, xpReward: 1, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -233,6 +346,7 @@ const samplePack = { damage: 1, }, xpReward: 1, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "beast"], mvp: true, @@ -249,6 +363,7 @@ const samplePack = { damage: 1, }, xpReward: 2, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "beast"], mvp: true, diff --git a/src/rules/loot.test.ts b/src/rules/loot.test.ts new file mode 100644 index 0000000..e4336d8 --- /dev/null +++ b/src/rules/loot.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { startCombatFromRoom } from "./combat"; +import { resolveCombatLoot } from "./loot"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("loot resolution", () => { + it("awards loot from defeated creatures into carried inventory", () => { + const room = { + id: "room.level1.start", + level: 1, + position: { x: 0, y: 0 }, + dimensions: { width: 4, height: 4 }, + roomClass: "start" as const, + exits: [], + discovery: { + generated: true, + entered: true, + cleared: false, + searched: false, + }, + encounter: { + id: "room.level1.start.encounter", + sourceTableCode: "L1G", + creatureIds: ["room.level1.start.creature.1"], + creatureNames: ["Guard"], + resultLabel: "Guard", + resolved: true, + }, + objects: [], + notes: ["Entry Chamber"], + flags: [], + }; + + const started = startCombatFromRoom({ + content: sampleContentPack, + adventurer: createAdventurer(), + room, + at: "2026-03-15T14:02:00.000Z", + }); + + started.combat.enemies[0]!.hpCurrent = 0; + + const loot = resolveCombatLoot({ + content: sampleContentPack, + combat: started.combat, + inventory: createAdventurer().inventory, + roller: createSequenceRoller([5]), + at: "2026-03-15T14:03:00.000Z", + }); + + expect(loot.goldAwarded).toBe(2); + expect(loot.itemsAwarded).toEqual([ + { + definitionId: "item.silver-clasp", + quantity: 1, + }, + ]); + expect(loot.inventory.currency.gold).toBe(2); + expect(loot.inventory.carried).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.silver-clasp", + quantity: 1, + }), + ]), + ); + expect(loot.logEntries.some((entry) => entry.type === "loot")).toBe(true); + }); +}); diff --git a/src/rules/loot.ts b/src/rules/loot.ts new file mode 100644 index 0000000..d622d53 --- /dev/null +++ b/src/rules/loot.ts @@ -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, + }; +} diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts index 6e38b36..7455e97 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -176,7 +176,7 @@ describe("run state flow", () => { run: withCombat, manoeuvreId: "manoeuvre.guard-break", targetEnemyId: withCombat.activeCombat!.enemies[0]!.id, - roller: createSequenceRoller([6, 6]), + roller: createSequenceRoller([6, 6, 5]), at: "2026-03-15T14:03:00.000Z", }); @@ -187,7 +187,23 @@ describe("run state flow", () => { ); expect(result.run.adventurerSnapshot.xp).toBe(2); expect(result.run.xpGained).toBe(2); + expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(2); + expect(result.run.goldGained).toBe(2); expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]); + expect(result.run.lootedItems).toEqual([ + { + definitionId: "item.silver-clasp", + quantity: 1, + }, + ]); + expect(result.run.adventurerSnapshot.inventory.carried).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.silver-clasp", + quantity: 1, + }), + ]), + ); expect(result.run.log.at(-1)?.text).toContain("Victory rewards"); }); diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 19fffec..dae3519 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -9,6 +9,7 @@ import type { LogEntry } from "@/types/rules"; import { findCreatureById } from "@/data/contentHelpers"; import { startCombatFromRoom } from "./combat"; +import { resolveCombatLoot } from "./loot"; import { resolveEnemyTurn, resolvePlayerAttack, @@ -163,6 +164,10 @@ function cloneRun(run: RunState): RunState { globalFlags: [...run.dungeon.globalFlags], }, activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined, + defeatedCreatureIds: [...run.defeatedCreatureIds], + xpGained: run.xpGained, + goldGained: run.goldGained, + lootedItems: run.lootedItems.map((entry) => ({ ...entry })), log: run.log.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, @@ -252,6 +257,7 @@ function applyCombatRewards( content: ContentPack, run: RunState, completedCombat: CombatState, + roller: DiceRoller | undefined, at: string, ) { const defeatedCreatureIds = completedCombat.enemies @@ -265,18 +271,45 @@ function applyCombatRewards( run.xpGained += xpAwarded; run.adventurerSnapshot.xp += xpAwarded; - if (xpAwarded === 0) { - return [] as LogEntry[]; + const lootResult = resolveCombatLoot({ + content, + combat: completedCombat, + inventory: run.adventurerSnapshot.inventory, + roller, + at, + }); + + run.adventurerSnapshot.inventory = lootResult.inventory; + run.goldGained += lootResult.goldAwarded; + + for (const item of lootResult.itemsAwarded) { + const existing = run.lootedItems.find( + (entry) => entry.definitionId === item.definitionId, + ); + + if (existing) { + existing.quantity += item.quantity; + continue; + } + + run.lootedItems.push({ ...item }); + } + const rewardLogs = [...lootResult.logEntries]; + + if (xpAwarded === 0 && lootResult.goldAwarded === 0 && lootResult.itemsAwarded.length === 0) { + return rewardLogs; } - return [ + rewardLogs.push( createRewardLog( `${completedCombat.id}.rewards`, at, - `Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, + `Victory rewards: gained ${xpAwarded} XP, ${lootResult.goldAwarded} gold, and ${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0)} loot item${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0) === 1 ? "" : "s"} from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, [completedCombat.id, ...defeatedCreatureIds], ), - ]; + ); + + return rewardLogs; } export function createRunState(options: CreateRunOptions): RunState { @@ -305,6 +338,8 @@ export function createRunState(options: CreateRunOptions): RunState { adventurerSnapshot: options.adventurer, defeatedCreatureIds: [], xpGained: 0, + goldGained: 0, + lootedItems: [], log: [], pendingEffects: [], }; @@ -494,6 +529,7 @@ export function resolveRunPlayerTurn( options.content, run, completedCombat, + options.roller, options.at ?? new Date().toISOString(), ); diff --git a/src/schemas/state.ts b/src/schemas/state.ts index eb57a3a..c905de4 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -216,6 +216,8 @@ export const runStateSchema = z.object({ activeCombat: combatStateSchema.optional(), defeatedCreatureIds: z.array(z.string()), xpGained: z.number().int().nonnegative(), + goldGained: z.number().int().nonnegative(), + lootedItems: z.array(inventoryEntrySchema), log: z.array(logEntrySchema), pendingEffects: z.array(ruleEffectSchema), }); diff --git a/src/types/state.ts b/src/types/state.ts index d10f958..c4ea08b 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -217,6 +217,8 @@ export type RunState = { activeCombat?: CombatState; defeatedCreatureIds: string[]; xpGained: number; + goldGained: number; + lootedItems: InventoryEntry[]; log: LogEntry[]; pendingEffects: RuleEffect[]; };