- 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(", ")}
- .
-
+
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;