diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts index 9eacdb1..8cff061 100644 --- a/src/data/contentHelpers.test.ts +++ b/src/data/contentHelpers.test.ts @@ -5,8 +5,8 @@ import { lookupTable } from "@/rules/tables"; import { findCreatureByName, findItemById, - findPotionById, findRoomTemplateForLookup, + findPotionById, findScrollById, findTableByCode, } from "./contentHelpers"; @@ -68,6 +68,13 @@ describe("level 1 content helpers", () => { expect(item.valueGp).toBe(12); }); + 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"); + }); + it("finds potion and scroll definitions by id", () => { expect(findPotionById(sampleContentPack, "potion.healing").name).toBe("Healing Potion"); expect(findScrollById(sampleContentPack, "scroll.lesser-heal").name).toBe("Lesser Heal"); diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index bd2f0d5..54858bb 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", @@ -137,6 +210,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, + }, { id: "item.silver-chalice", name: "Silver Chalice", @@ -195,6 +304,7 @@ const samplePack = { numberAppearing: "1-2", }, xpReward: 1, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "sample"], mvp: true, @@ -214,6 +324,7 @@ const samplePack = { armour: 1, }, xpReward: 2, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -233,6 +344,7 @@ const samplePack = { armour: 1, }, xpReward: 3, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -249,6 +361,7 @@ const samplePack = { damage: 1, }, xpReward: 1, + lootTableCodes: ["L1HL"], sourcePage: 102, traits: ["level-1", "martial"], mvp: true, @@ -265,6 +378,7 @@ const samplePack = { damage: 1, }, xpReward: 1, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "beast"], mvp: true, @@ -281,6 +395,7 @@ const samplePack = { damage: 1, }, xpReward: 2, + lootTableCodes: ["L1BL"], sourcePage: 102, traits: ["level-1", "beast"], mvp: true, @@ -311,21 +426,21 @@ const samplePack = { id: "service.market", name: "Market", serviceType: "market", - costRules: ["buy-ration:1"], + costRules: ["gold:1"], mvp: true, }, { id: "service.healer", name: "Healer", serviceType: "healer", - costRules: ["restore-to-full:2"], + costRules: ["gold:2"], mvp: true, }, { id: "service.tavern", name: "Tavern", serviceType: "tavern", - costRules: ["rest:1"], + costRules: ["gold:1"], 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 322e191..b7231c2 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -179,7 +179,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", }); @@ -231,7 +231,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", }); diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 0e9eaff..5eed5ca 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -10,6 +10,7 @@ import { findCreatureById } from "@/data/contentHelpers"; import { startCombatFromRoom } from "./combat"; import { createInitialTownState } from "./townServices"; +import { resolveCombatLoot } from "./loot"; import { applyLevelProgression } from "./progression"; import { resolveEnemyTurn, @@ -200,6 +201,10 @@ function cloneRun(run: RunState): RunState { pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })), serviceFlags: [...run.townState.serviceFlags], }, + 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, @@ -289,6 +294,7 @@ function applyCombatRewards( content: ContentPack, run: RunState, completedCombat: CombatState, + roller: DiceRoller | undefined, at: string, ) { const defeatedCreatureIds = completedCombat.enemies @@ -308,6 +314,26 @@ function applyCombatRewards( }); run.adventurerSnapshot = progression.adventurer; run.lastLevelUp = progression.levelUps.at(-1); + 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 }); + } run.lastCombatOutcome = { result: "victory", at, @@ -316,8 +342,9 @@ function applyCombatRewards( xpAwarded, }; + const lootLogs = [...lootResult.logEntries]; const rewardLogs = - xpAwarded === 0 + xpAwarded === 0 && lootResult.goldAwarded === 0 && lootResult.itemsAwarded.length === 0 ? [ createRewardLog( `${completedCombat.id}.victory`, @@ -336,7 +363,7 @@ function applyCombatRewards( 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], ), ]; @@ -350,7 +377,7 @@ function applyCombatRewards( ), ); - return [...rewardLogs, ...levelLogs]; + return [...lootLogs, ...rewardLogs, ...levelLogs]; } export function createRunState(options: CreateRunOptions): RunState { @@ -383,6 +410,8 @@ export function createRunState(options: CreateRunOptions): RunState { townState: createInitialTownState(), defeatedCreatureIds: [], xpGained: 0, + goldGained: 0, + lootedItems: [], log: [], pendingEffects: [], }; @@ -653,6 +682,7 @@ export function resolveRunPlayerTurn( options.content, run, completedCombat, + options.roller, options.at ?? new Date().toISOString(), ); diff --git a/src/rules/town.test.ts b/src/rules/town.test.ts new file mode 100644 index 0000000..0986243 --- /dev/null +++ b/src/rules/town.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { createRunState } from "./runState"; +import { queueTreasureForSale, sellPendingTreasure, sendTreasureToStash } from "./town"; + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("town flow", () => { + it("moves treasure from carried inventory into the stash", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.inventory.carried.push({ + definitionId: "item.silver-clasp", + quantity: 1, + }); + + const result = sendTreasureToStash( + sampleContentPack, + run, + "item.silver-clasp", + "2026-03-15T15:00:00.000Z", + ); + + expect(result.run.townState.stash).toEqual([ + { definitionId: "item.silver-clasp", quantity: 1 }, + ]); + expect(result.run.adventurerSnapshot.inventory.carried).not.toEqual( + expect.arrayContaining([expect.objectContaining({ definitionId: "item.silver-clasp" })]), + ); + }); + + it("queues treasure and sells it for item value", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.inventory.carried.push({ + definitionId: "item.keeper-keyring", + quantity: 1, + }); + + const queued = queueTreasureForSale( + sampleContentPack, + run, + "item.keeper-keyring", + "2026-03-15T15:05:00.000Z", + ).run; + const sold = sellPendingTreasure( + sampleContentPack, + queued, + "2026-03-15T15:06:00.000Z", + ).run; + + expect(sold.townState.pendingSales).toEqual([]); + expect(sold.adventurerSnapshot.inventory.currency.gold).toBe(5); + expect(sold.log.at(-1)?.text).toContain("for 5 gold"); + }); +}); diff --git a/src/rules/town.ts b/src/rules/town.ts new file mode 100644 index 0000000..1e70b2e --- /dev/null +++ b/src/rules/town.ts @@ -0,0 +1,165 @@ +import { findItemById } from "@/data/contentHelpers"; +import type { ContentPack } from "@/types/content"; +import type { InventoryEntry, RunState, TownState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +export type TownActionResult = { + run: RunState; + logEntries: LogEntry[]; +}; + +function cloneTownState(townState: TownState): TownState { + return { + ...townState, + knownServices: [...townState.knownServices], + stash: townState.stash.map((entry) => ({ ...entry })), + pendingSales: townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...townState.serviceFlags], + }; +} + +function cloneRun(run: RunState): RunState { + return { + ...run, + adventurerSnapshot: { + ...run.adventurerSnapshot, + hp: { ...run.adventurerSnapshot.hp }, + stats: { ...run.adventurerSnapshot.stats }, + favour: { ...run.adventurerSnapshot.favour }, + statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })), + inventory: { + ...run.adventurerSnapshot.inventory, + carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })), + equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })), + stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })), + currency: { ...run.adventurerSnapshot.inventory.currency }, + lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })), + }, + progressionFlags: [...run.adventurerSnapshot.progressionFlags], + manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds], + }, + townState: cloneTownState(run.townState), + defeatedCreatureIds: [...run.defeatedCreatureIds], + lootedItems: run.lootedItems.map((entry) => ({ ...entry })), + log: run.log.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined })), + pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), + }; +} + +function mergeEntry(entries: InventoryEntry[], entry: InventoryEntry) { + const existing = entries.find((candidate) => candidate.definitionId === entry.definitionId); + + if (existing) { + existing.quantity += entry.quantity; + return; + } + + entries.push({ ...entry }); +} + +function extractEntry(entries: InventoryEntry[], definitionId: string) { + const index = entries.findIndex((entry) => entry.definitionId === definitionId); + + if (index === -1) { + throw new Error(`No inventory entry found for ${definitionId}.`); + } + + const [removed] = entries.splice(index, 1); + return removed; +} + +function createTownLog(id: string, at: string, text: string, relatedIds: string[]): LogEntry { + return { + id, + at, + type: "town", + text, + relatedIds, + }; +} + +export function createInitialTownState(): TownState { + return { + visits: 0, + knownServices: ["service.market"], + stash: [], + pendingSales: [], + serviceFlags: [], + }; +} + +export function sendTreasureToStash( + content: ContentPack, + run: RunState, + definitionId: string, + at = new Date().toISOString(), +): TownActionResult { + const nextRun = cloneRun(run); + const removed = extractEntry(nextRun.adventurerSnapshot.inventory.carried, definitionId); + mergeEntry(nextRun.townState.stash, removed); + nextRun.townState.visits += 1; + const item = findItemById(content, definitionId); + const logEntry = createTownLog( + `town.stash.${definitionId}.${nextRun.log.length + 1}`, + at, + `Moved ${item.name} into the town stash.`, + [definitionId], + ); + nextRun.log.push(logEntry); + + return { run: nextRun, logEntries: [logEntry] }; +} + +export function queueTreasureForSale( + content: ContentPack, + run: RunState, + definitionId: string, + at = new Date().toISOString(), +): TownActionResult { + const nextRun = cloneRun(run); + const removed = extractEntry(nextRun.adventurerSnapshot.inventory.carried, definitionId); + mergeEntry(nextRun.townState.pendingSales, removed); + nextRun.townState.visits += 1; + const item = findItemById(content, definitionId); + const logEntry = createTownLog( + `town.queue.${definitionId}.${nextRun.log.length + 1}`, + at, + `Queued ${item.name} for sale at the market.`, + [definitionId], + ); + nextRun.log.push(logEntry); + + return { run: nextRun, logEntries: [logEntry] }; +} + +export function sellPendingTreasure( + content: ContentPack, + run: RunState, + at = new Date().toISOString(), +): TownActionResult { + const nextRun = cloneRun(run); + const soldEntries = nextRun.townState.pendingSales; + const goldEarned = soldEntries.reduce((total, entry) => { + const item = findItemById(content, entry.definitionId); + return total + (item.valueGp ?? 0) * entry.quantity; + }, 0); + + nextRun.adventurerSnapshot.inventory.currency.gold += goldEarned; + nextRun.townState.pendingSales = []; + nextRun.townState.visits += 1; + + const soldText = + soldEntries.length === 0 + ? "No treasure was queued for sale." + : `Sold ${soldEntries.reduce((total, entry) => total + entry.quantity, 0)} treasure item${soldEntries.reduce((total, entry) => total + entry.quantity, 0) === 1 ? "" : "s"} for ${goldEarned} gold.`; + + const logEntry = createTownLog( + `town.sell.${nextRun.log.length + 1}`, + at, + soldText, + soldEntries.map((entry) => entry.definitionId), + ); + nextRun.log.push(logEntry); + + return { run: nextRun, logEntries: [logEntry] }; +} diff --git a/src/schemas/state.ts b/src/schemas/state.ts index e838d32..fbb1521 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -238,6 +238,8 @@ export const runStateSchema = z.object({ townState: townStateSchema, 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 4c26405..4b1553c 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -239,6 +239,8 @@ export type RunState = { townState: TownState; defeatedCreatureIds: string[]; xpGained: number; + goldGained: number; + lootedItems: InventoryEntry[]; log: LogEntry[]; pendingEffects: RuleEffect[]; };