From 8597b4fdedc16936678d74861e6fb1b718df36c7 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 14:31:53 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20town=20market?= =?UTF-8?q?=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; -- 2.49.1