From 6c2257b0325850f3db91e9f6ac00879f3e987068 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 14:24:39 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20loot=20reso?= =?UTF-8?q?lution=20system=20with=20gold=20and=20item=20tracking=20from=20?= =?UTF-8?q?defeated=20creatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 20 +++- src/data/contentHelpers.test.ts | 8 ++ src/data/contentHelpers.ts | 11 ++ src/data/sampleContentPack.ts | 115 +++++++++++++++++++++ src/rules/loot.test.ts | 91 +++++++++++++++++ src/rules/loot.ts | 172 ++++++++++++++++++++++++++++++++ src/rules/runState.test.ts | 18 +++- src/rules/runState.ts | 46 ++++++++- src/schemas/state.ts | 2 + src/types/state.ts | 2 + 10 files changed, 478 insertions(+), 7 deletions(-) create mode 100644 src/rules/loot.test.ts create mode 100644 src/rules/loot.ts 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 1ebde01..bac1b69 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[]; }; From 71bdc6d031b47cd29429da352a95f5c2628c28d9 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 14:29:03 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8Feature:=20enhance=20inventory=20U?= =?UTF-8?q?I=20with=20structured=20layout=20and=20item=20categorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 172 ++++++++++++++++++++++++++++++++++++++++++++----- src/styles.css | 91 ++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 16 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0483845..e75bded 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,12 +48,72 @@ function getRoomTitle(run: RunState, roomId?: string) { ); } +function getDefinitionName(definitionId: string) { + const item = + sampleContentPack.items.find((candidate) => candidate.id === definitionId) ?? + sampleContentPack.weapons.find((candidate) => candidate.id === definitionId) ?? + sampleContentPack.armour.find((candidate) => candidate.id === definitionId) ?? + sampleContentPack.scrolls.find((candidate) => candidate.id === definitionId) ?? + sampleContentPack.potions.find((candidate) => candidate.id === definitionId); + + return item?.name ?? definitionId; +} + +function formatInventoryEntry(definitionId: string, quantity: number) { + const name = getDefinitionName(definitionId); + return quantity > 1 ? `${quantity}x ${name}` : name; +} + +function getTreasureItemIds() { + return new Set( + sampleContentPack.items + .filter((item) => item.itemType === "treasure") + .map((item) => item.id), + ); +} + +function getSupportItemIds() { + return new Set( + sampleContentPack.items + .filter((item) => item.itemType !== "treasure") + .map((item) => item.id), + ); +} + +function getConsumableItemIds() { + return new Set( + sampleContentPack.items + .filter((item) => item.consumable || item.itemType === "ration") + .map((item) => item.id), + ); +} + +const treasureItemIds = getTreasureItemIds(); +const supportItemIds = getSupportItemIds(); +const consumableItemIds = getConsumableItemIds(); + function App() { const [run, setRun] = React.useState(() => createDemoRun()); const currentLevel = run.dungeon.levels[run.currentLevel]; const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined; const availableMoves = getAvailableMoves(run); const combatReadyEncounter = isCurrentRoomCombatReady(run); + const carriedTreasure = run.adventurerSnapshot.inventory.carried.filter((entry) => + treasureItemIds.has(entry.definitionId), + ); + const carriedConsumables = run.adventurerSnapshot.inventory.carried.filter( + (entry) => + consumableItemIds.has(entry.definitionId) || + entry.definitionId.startsWith("potion.") || + entry.definitionId.startsWith("scroll."), + ); + const carriedGear = run.adventurerSnapshot.inventory.carried.filter( + (entry) => + supportItemIds.has(entry.definitionId) && + !consumableItemIds.has(entry.definitionId), + ); + const equippedItems = run.adventurerSnapshot.inventory.equipped; + const latestLoot = run.lootedItems.slice(-4).reverse(); const handleReset = () => { setRun(createDemoRun()); @@ -176,23 +236,103 @@ function App() { 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(", ")} - . -

+
+
+

Inventory

+ {run.adventurerSnapshot.inventory.carried.length} carried entries +
+
+
+ Gold + {run.adventurerSnapshot.inventory.currency.gold} +
+
+ Rations + {run.adventurerSnapshot.inventory.rationCount} +
+
+ Treasure + {carriedTreasure.length} +
+
+ Latest Loot + {run.lootedItems.reduce((total, entry) => total + entry.quantity, 0)} +
+
+
+
+ Equipped +
+ {equippedItems.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Ready for use +
+ ))} +
+
+
+ Consumables +
+ {carriedConsumables.length === 0 ? ( +

No consumables carried.

+ ) : ( + carriedConsumables.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Combat or run utility +
+ )) + )} +
+
+
+ Pack Gear +
+ {carriedGear.length === 0 ? ( +

No general gear carried.

+ ) : ( + carriedGear.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Travel and exploration kit +
+ )) + )} +
+
+
+ Treasure Stash +
+ {carriedTreasure.length === 0 ? ( +

No treasure recovered yet.

+ ) : ( + carriedTreasure.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Sellable dungeon spoils +
+ )) + )} +
+
+
+
+ Recent Spoils + {latestLoot.length === 0 ? ( +

Win a fight to populate the loot ribbon.

+ ) : ( +
+ {latestLoot.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} +
+ ))} +
+ )} +
diff --git a/src/styles.css b/src/styles.css index a2d350a..26210e5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -152,6 +152,13 @@ select { rgba(25, 19, 16, 0.9); } +.panel-inventory { + grid-column: span 8; + background: + linear-gradient(180deg, rgba(43, 32, 24, 0.92), rgba(22, 17, 14, 0.92)), + rgba(25, 19, 16, 0.9); +} + .panel-log { grid-column: span 12; } @@ -184,6 +191,7 @@ select { } .stat-strip div, +.inventory-badge, .encounter-box, .combat-status { padding: 0.9rem; @@ -192,6 +200,8 @@ select { } .stat-strip span, +.inventory-badge span, +.inventory-label, .encounter-label, .combat-status span, .room-meta span, @@ -204,6 +214,7 @@ select { } .stat-strip strong, +.inventory-badge strong, .encounter-box strong, .combat-status strong { display: block; @@ -217,6 +228,81 @@ select { color: rgba(244, 239, 227, 0.76); } +.inventory-summary, +.inventory-grid { + display: grid; + gap: 0.75rem; +} + +.inventory-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.inventory-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 1rem; +} + +.inventory-section { + padding: 1rem; + border: 1px solid rgba(255, 231, 196, 0.08); + background: + linear-gradient(180deg, rgba(255, 245, 223, 0.04), rgba(255, 245, 223, 0.02)); +} + +.inventory-label { + display: block; + margin-bottom: 0.7rem; +} + +.inventory-list, +.loot-ribbon-list { + display: grid; + gap: 0.65rem; +} + +.inventory-card, +.loot-pill { + padding: 0.85rem 0.9rem; + border: 1px solid rgba(255, 231, 196, 0.08); + background: rgba(11, 8, 7, 0.32); +} + +.inventory-card strong, +.loot-pill strong { + display: block; + color: #fff2d6; +} + +.inventory-card span { + display: block; + margin-top: 0.25rem; + color: rgba(244, 239, 227, 0.62); + font-size: 0.84rem; +} + +.inventory-card-equipped { + border-color: rgba(113, 176, 152, 0.35); + background: rgba(56, 86, 73, 0.18); +} + +.inventory-card-treasure, +.loot-pill { + border-color: rgba(214, 168, 86, 0.35); + background: rgba(111, 76, 20, 0.18); +} + +.loot-ribbon { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 231, 196, 0.08); +} + +.loot-ribbon-list { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin-top: 0.7rem; +} + .room-title { margin: 0 0 0.35rem; font-size: 1.5rem; @@ -393,4 +479,9 @@ select { .stat-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .inventory-summary, + .inventory-grid { + grid-template-columns: 1fr; + } } From 8597b4fdedc16936678d74861e6fb1b718df36c7 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 14:31:53 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20town=20mark?= =?UTF-8?q?et=20functionality=20with=20treasure=20selling=20and=20stashing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 100 +++++++++++++++++++++++++ src/rules/runState.ts | 9 +++ src/rules/town.test.ts | 74 ++++++++++++++++++ src/rules/town.ts | 165 +++++++++++++++++++++++++++++++++++++++++ src/schemas/state.ts | 1 + src/styles.css | 46 +++++++++++- src/types/state.ts | 1 + 7 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 src/rules/town.test.ts create mode 100644 src/rules/town.ts diff --git a/src/App.tsx b/src/App.tsx index e75bded..1ee2ef4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { startCombatInCurrentRoom, travelCurrentExit, } from "@/rules/runState"; +import { queueTreasureForSale, sellPendingTreasure, sendTreasureToStash } from "@/rules/town"; import type { RunState } from "@/types/state"; function createDemoRun() { @@ -114,6 +115,8 @@ function App() { ); const equippedItems = run.adventurerSnapshot.inventory.equipped; const latestLoot = run.lootedItems.slice(-4).reverse(); + const pendingSales = run.townState.pendingSales; + const stash = run.townState.stash; const handleReset = () => { setRun(createDemoRun()); @@ -156,6 +159,18 @@ function App() { ); }; + const handleQueueSale = (definitionId: string) => { + setRun((previous) => queueTreasureForSale(sampleContentPack, previous, definitionId).run); + }; + + const handleStashTreasure = (definitionId: string) => { + setRun((previous) => sendTreasureToStash(sampleContentPack, previous, definitionId).run); + }; + + const handleSellPending = () => { + setRun((previous) => sellPendingTreasure(sampleContentPack, previous).run); + }; + return (
@@ -335,6 +350,91 @@ function App() {
+
+
+

Town Market

+ {run.townState.visits} town actions +
+
+
+ Known Services + {run.townState.knownServices.length} +
+
+ Queued Sales + {pendingSales.length} +
+
+ Stash + {stash.length} +
+
+
+ +
+
+ Treasure In Pack +
+ {carriedTreasure.length === 0 ? ( +

No treasure available for town actions.

+ ) : ( + carriedTreasure.map((entry) => ( +
+
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Choose whether to sell or stash this treasure. +
+
+ + +
+
+ )) + )} +
+
+
+ Pending Sales +
+ {pendingSales.length === 0 ? ( +

Nothing queued at the market.

+ ) : ( + pendingSales.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Ready to convert into gold +
+ )) + )} +
+
+
+ Town Stash +
+ {stash.length === 0 ? ( +

The stash is empty.

+ ) : ( + stash.map((entry) => ( +
+ {formatInventoryEntry(entry.definitionId, entry.quantity)} + Held safely in town storage +
+ )) + )} +
+
+
+

Current Room

diff --git a/src/rules/runState.ts b/src/rules/runState.ts index dae3519..cb77705 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 { resolveCombatLoot } from "./loot"; +import { createInitialTownState } from "./town"; import { resolveEnemyTurn, resolvePlayerAttack, @@ -164,6 +165,13 @@ function cloneRun(run: RunState): RunState { globalFlags: [...run.dungeon.globalFlags], }, activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined, + townState: { + ...run.townState, + knownServices: [...run.townState.knownServices], + stash: run.townState.stash.map((entry) => ({ ...entry })), + pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...run.townState.serviceFlags], + }, defeatedCreatureIds: [...run.defeatedCreatureIds], xpGained: run.xpGained, goldGained: run.goldGained, @@ -336,6 +344,7 @@ export function createRunState(options: CreateRunOptions): RunState { globalFlags: [], }, adventurerSnapshot: options.adventurer, + townState: createInitialTownState(), defeatedCreatureIds: [], xpGained: 0, goldGained: 0, 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 c905de4..469ec64 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -214,6 +214,7 @@ export const runStateSchema = z.object({ dungeon: dungeonStateSchema, adventurerSnapshot: adventurerStateSchema, activeCombat: combatStateSchema.optional(), + townState: townStateSchema, defeatedCreatureIds: z.array(z.string()), xpGained: z.number().int().nonnegative(), goldGained: z.number().int().nonnegative(), diff --git a/src/styles.css b/src/styles.css index 26210e5..fa57a3c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -159,6 +159,13 @@ select { rgba(25, 19, 16, 0.9); } +.panel-town { + grid-column: span 4; + background: + linear-gradient(180deg, rgba(32, 37, 24, 0.92), rgba(20, 22, 14, 0.92)), + rgba(25, 19, 16, 0.9); +} + .panel-log { grid-column: span 12; } @@ -229,12 +236,14 @@ select { } .inventory-summary, -.inventory-grid { +.inventory-grid, +.town-summary { display: grid; gap: 0.75rem; } -.inventory-summary { +.inventory-summary, +.town-summary { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -250,6 +259,36 @@ select { linear-gradient(180deg, rgba(255, 245, 223, 0.04), rgba(255, 245, 223, 0.02)); } +.town-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 231, 196, 0.08); +} + +.town-actions { + margin-top: 1rem; +} + +.town-card { + display: grid; + gap: 0.75rem; + padding: 0.95rem; + border: 1px solid rgba(255, 231, 196, 0.08); + background: rgba(255, 245, 223, 0.04); +} + +.town-card strong { + display: block; + color: #fff2d6; +} + +.town-card span { + display: block; + margin-top: 0.25rem; + color: rgba(244, 239, 227, 0.62); + font-size: 0.84rem; +} + .inventory-label { display: block; margin-bottom: 0.7rem; @@ -481,7 +520,8 @@ select { } .inventory-summary, - .inventory-grid { + .inventory-grid, + .town-summary { grid-template-columns: 1fr; } } diff --git a/src/types/state.ts b/src/types/state.ts index c4ea08b..d253e54 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -215,6 +215,7 @@ export type RunState = { dungeon: DungeonState; adventurerSnapshot: AdventurerState; activeCombat?: CombatState; + townState: TownState; defeatedCreatureIds: string[]; xpGained: number; goldGained: number;