Feature: add town systems, saves, recovery, and level progression

This commit is contained in:
Keith Solomon
2026-03-18 19:37:01 -05:00
parent a4d2890cd9
commit 8600f611a6
22 changed files with 2434 additions and 16 deletions

View File

@@ -2,6 +2,14 @@ import React from "react";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "@/rules/character";
import {
deleteSavedRun,
getBrowserStorage,
listSavedRuns,
loadSavedRun,
saveRun,
type SavedRunSummary,
} from "@/rules/persistence";
import {
createRunState,
enterCurrentRoom,
@@ -14,6 +22,21 @@ import {
startCombatInCurrentRoom,
travelCurrentExit,
} from "@/rules/runState";
import { getNextLevelXpThreshold, MAX_ADVENTURER_LEVEL } from "@/rules/progression";
import {
getConsumableCounts,
restWithRation,
usePotion,
useScroll,
} from "@/rules/recovery";
import {
grantDebugTreasure,
queueTreasureForSale,
sellPendingTreasure,
stashCarriedTreasure,
withdrawStashedTreasure,
} from "@/rules/townInventory";
import { useTownService } from "@/rules/townServices";
import type { RunState } from "@/types/state";
function createDemoRun() {
@@ -50,18 +73,90 @@ function getRoomTitle(run: RunState, roomId?: string) {
);
}
function getTownServiceDescription(serviceId: string) {
switch (serviceId) {
case "service.healer":
return "Restore HP to full for 2 gold.";
case "service.market":
return "Buy 1 ration for 1 gold.";
case "service.tavern":
return "Recover 2 HP for 1 gold.";
default:
return "Visit this service.";
}
}
function getItemName(definitionId: string) {
return sampleContentPack.items.find((item) => item.id === definitionId)?.name ?? definitionId;
}
function getItemValue(definitionId: string) {
return sampleContentPack.items.find((item) => item.id === definitionId)?.valueGp ?? 0;
}
function getManoeuvreName(manoeuvreId: string) {
return sampleContentPack.manoeuvres.find((entry) => entry.id === manoeuvreId)?.name ?? manoeuvreId;
}
function getCombatTargetNumber(enemyArmourValue = 0) {
return 7 + enemyArmourValue;
}
function App() {
const [run, setRun] = React.useState<RunState>(() => createDemoRun());
const [savedRuns, setSavedRuns] = React.useState<SavedRunSummary[]>([]);
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 inTown = run.phase === "town";
const knownServices = sampleContentPack.townServices.filter((service) =>
run.townState.knownServices.includes(service.id),
);
const carriedTreasure = run.adventurerSnapshot.inventory.carried.filter((entry) =>
sampleContentPack.items.find((item) => item.id === entry.definitionId)?.itemType === "treasure",
);
const stashedTreasure = run.townState.stash;
const pendingSales = run.townState.pendingSales;
const pendingSaleValue = pendingSales.reduce(
(total, entry) => total + getItemValue(entry.definitionId) * entry.quantity,
0,
);
const consumableCounts = getConsumableCounts(run);
const latestCombatLogs = run.activeCombat?.combatLog.slice(-3).reverse() ?? [];
const nextLevelXpThreshold =
run.adventurerSnapshot.level >= MAX_ADVENTURER_LEVEL
? undefined
: getNextLevelXpThreshold(run.adventurerSnapshot.level);
const xpToNextLevel =
nextLevelXpThreshold === undefined
? 0
: Math.max(0, nextLevelXpThreshold - run.adventurerSnapshot.xp);
React.useEffect(() => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setSavedRuns(listSavedRuns(storage));
}, []);
const handleReset = () => {
setRun(createDemoRun());
};
const refreshSavedRuns = React.useCallback(() => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setSavedRuns(listSavedRuns(storage));
}, []);
const handleEnterRoom = () => {
setRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run);
};
@@ -107,6 +202,129 @@ function App() {
setRun((previous) => resumeDungeon(previous).run);
};
const handleUseTownService = (serviceId: string) => {
setRun((previous) =>
useTownService({
content: sampleContentPack,
run: previous,
serviceId,
}).run,
);
};
const handleGrantTreasure = (definitionId: string) => {
setRun((previous) =>
grantDebugTreasure({
content: sampleContentPack,
run: previous,
definitionId,
}).run,
);
};
const handleStashTreasure = (definitionId: string) => {
setRun((previous) =>
stashCarriedTreasure({
content: sampleContentPack,
run: previous,
definitionId,
}).run,
);
};
const handleWithdrawTreasure = (definitionId: string) => {
setRun((previous) =>
withdrawStashedTreasure({
content: sampleContentPack,
run: previous,
definitionId,
}).run,
);
};
const handleQueueTreasure = (definitionId: string, source: "carried" | "stash") => {
setRun((previous) =>
queueTreasureForSale({
content: sampleContentPack,
run: previous,
definitionId,
source,
}).run,
);
};
const handleSellPending = () => {
setRun((previous) =>
sellPendingTreasure({
content: sampleContentPack,
run: previous,
}).run,
);
};
const handleUsePotion = () => {
setRun((previous) =>
usePotion({
content: sampleContentPack,
run: previous,
definitionId: "potion.healing",
}).run,
);
};
const handleUseScroll = () => {
setRun((previous) =>
useScroll({
content: sampleContentPack,
run: previous,
definitionId: "scroll.lesser-heal",
roller: () => 4,
}).run,
);
};
const handleRationRest = () => {
setRun((previous) =>
restWithRation({
content: sampleContentPack,
run: previous,
definitionId: "item.ration",
}).run,
);
};
const handleSaveRun = () => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
saveRun(storage, run);
refreshSavedRuns();
};
const handleLoadRun = (saveId: string) => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setRun(loadSavedRun(storage, saveId));
refreshSavedRuns();
};
const handleDeleteSave = (saveId: string) => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setSavedRuns(deleteSavedRun(storage, saveId));
};
return (
<main className="app-shell">
<section className="hero">
@@ -123,6 +341,9 @@ function App() {
<button className="button button-primary" onClick={handleReset}>
Reset Demo Run
</button>
<button className="button" onClick={handleSaveRun}>
Save Run
</button>
<button
className="button"
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
@@ -153,6 +374,38 @@ function App() {
</section>
) : null}
{run.lastCombatOutcome ? (
<section
className={`alert-banner ${run.lastCombatOutcome.result === "victory" ? "alert-banner-victory" : "alert-banner-defeat"}`}
>
<div>
<span className="alert-kicker">
{run.lastCombatOutcome.result === "victory" ? "Last Victory" : "Last Defeat"}
</span>
<strong>{run.lastCombatOutcome.summary}</strong>
<p>
{run.lastCombatOutcome.enemyNames.length > 0
? `Opposition: ${run.lastCombatOutcome.enemyNames.join(", ")}.`
: "No enemy details recorded."}
</p>
</div>
</section>
) : null}
{run.lastLevelUp ? (
<section className="alert-banner alert-banner-level">
<div>
<span className="alert-kicker">Latest Level Up</span>
<strong>{run.lastLevelUp.summary}</strong>
<p>
{run.lastLevelUp.unlockedManoeuvreIds.length > 0
? `Unlocked: ${run.lastLevelUp.unlockedManoeuvreIds.map(getManoeuvreName).join(", ")}.`
: "No additional manoeuvres unlocked on that level."}
</p>
</div>
</section>
) : null}
<section className="dashboard-grid">
<article className="panel panel-highlight">
<div className="panel-header">
@@ -182,6 +435,14 @@ function App() {
<span>XP</span>
<strong>{run.adventurerSnapshot.xp}</strong>
</div>
<div>
<span>Next Level</span>
<strong>
{nextLevelXpThreshold === undefined
? "Max"
: `${xpToNextLevel} to go`}
</strong>
</div>
</div>
<p className="supporting-text">
{run.adventurerSnapshot.name} is equipped with a{" "}
@@ -193,11 +454,98 @@ function App() {
<p className="supporting-text">
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
</p>
<p className="supporting-text">
{nextLevelXpThreshold === undefined
? `${run.adventurerSnapshot.name} has reached the current maximum supported level.`
: `Next level at ${nextLevelXpThreshold} XP. Current manoeuvres: ${run.adventurerSnapshot.manoeuvreIds.map(getManoeuvreName).join(", ")}.`}
</p>
<p className="supporting-text">
{inTown
? `The party is currently in town${run.lastTownAt ? ` as of ${new Date(run.lastTownAt).toLocaleString()}` : ""}.`
: "The party is still delving below ground."}
</p>
<div className="recovery-panel">
<div className="panel-header">
<h2>Recovery Kit</h2>
<span>{inTown ? "Town-ready" : "Field-ready"}</span>
</div>
<div className="recovery-grid">
<article className="recovery-card">
<span className="encounter-label">Potion</span>
<strong>Healing Potion</strong>
<p className="supporting-text">
Restore 3 HP. Carried: {consumableCounts.healingPotion}
</p>
<button
className="button"
onClick={handleUsePotion}
disabled={consumableCounts.healingPotion === 0}
>
Use Potion
</button>
</article>
<article className="recovery-card">
<span className="encounter-label">Scroll</span>
<strong>Lesser Heal</strong>
<p className="supporting-text">
Restore 2 HP on a successful cast. Carried: {consumableCounts.lesserHealScroll}
</p>
<button
className="button"
onClick={handleUseScroll}
disabled={consumableCounts.lesserHealScroll === 0}
>
Cast Scroll
</button>
</article>
<article className="recovery-card">
<span className="encounter-label">Ration</span>
<strong>Town Rest</strong>
<p className="supporting-text">
Spend 1 ration in town to recover 2 HP. Carried: {consumableCounts.ration}
</p>
<button
className="button"
onClick={handleRationRest}
disabled={!inTown || consumableCounts.ration === 0}
>
Eat And Rest
</button>
</article>
</div>
</div>
</article>
<article className="panel panel-saves">
<div className="panel-header">
<h2>Save Archive</h2>
<span>{savedRuns.length} saves</span>
</div>
{savedRuns.length === 0 ? (
<p className="supporting-text">No saved runs yet. Save the current run to persist progress.</p>
) : (
<div className="save-list">
{savedRuns.map((save) => (
<article key={save.id} className="save-card">
<div>
<span className="encounter-label">{save.phase}</span>
<strong>{save.label}</strong>
<p className="supporting-text">
Saved {new Date(save.savedAt).toLocaleString()} · Level {save.currentLevel}
</p>
</div>
<div className="save-actions">
<button className="button" onClick={() => handleLoadRun(save.id)}>
Load
</button>
<button className="button" onClick={() => handleDeleteSave(save.id)}>
Delete
</button>
</div>
</article>
))}
</div>
)}
</article>
{inTown ? (
@@ -230,6 +578,131 @@ function App() {
Resume Delve
</button>
</div>
<div className="town-services">
{knownServices.map((service) => (
<article key={service.id} className="town-service-card">
<div>
<span className="encounter-label">{service.serviceType}</span>
<strong>{service.name}</strong>
<p className="supporting-text">{getTownServiceDescription(service.id)}</p>
</div>
<button
className="button"
onClick={() => handleUseTownService(service.id)}
>
Use Service
</button>
</article>
))}
</div>
<div className="town-ledger">
<div className="panel-header">
<h2>Treasure Ledger</h2>
<span>{pendingSaleValue} gp pending</span>
</div>
<div className="button-row">
<button
className="button"
onClick={() => handleGrantTreasure("item.silver-chalice")}
>
Debug: Add Chalice
</button>
<button
className="button"
onClick={() => handleGrantTreasure("item.garnet-ring")}
>
Debug: Add Ring
</button>
<button
className="button button-primary"
onClick={handleSellPending}
disabled={pendingSales.length === 0}
>
Sell Queued Treasure
</button>
</div>
<div className="town-ledger-grid">
<article className="town-ledger-card">
<h3>Pack Treasure</h3>
{carriedTreasure.length === 0 ? (
<p className="supporting-text">No treasure currently in the pack.</p>
) : (
carriedTreasure.map((entry) => (
<div key={`carried-${entry.definitionId}`} className="treasure-row">
<div>
<strong>{getItemName(entry.definitionId)}</strong>
<p className="supporting-text">
Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each
</p>
</div>
<div className="treasure-actions">
<button
className="button"
onClick={() => handleStashTreasure(entry.definitionId)}
>
Stash
</button>
<button
className="button"
onClick={() => handleQueueTreasure(entry.definitionId, "carried")}
>
Queue Sale
</button>
</div>
</div>
))
)}
</article>
<article className="town-ledger-card">
<h3>Town Stash</h3>
{stashedTreasure.length === 0 ? (
<p className="supporting-text">No treasure stored in town.</p>
) : (
stashedTreasure.map((entry) => (
<div key={`stash-${entry.definitionId}`} className="treasure-row">
<div>
<strong>{getItemName(entry.definitionId)}</strong>
<p className="supporting-text">
Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each
</p>
</div>
<div className="treasure-actions">
<button
className="button"
onClick={() => handleWithdrawTreasure(entry.definitionId)}
>
Withdraw
</button>
<button
className="button"
onClick={() => handleQueueTreasure(entry.definitionId, "stash")}
>
Queue Sale
</button>
</div>
</div>
))
)}
</article>
<article className="town-ledger-card">
<h3>Queued Sales</h3>
{pendingSales.length === 0 ? (
<p className="supporting-text">Nothing is queued for sale yet.</p>
) : (
pendingSales.map((entry) => (
<div key={`sale-${entry.definitionId}`} className="treasure-row">
<div>
<strong>{getItemName(entry.definitionId)}</strong>
<p className="supporting-text">
Qty {entry.quantity} · {getItemValue(entry.definitionId) * entry.quantity} gp total
</p>
</div>
</div>
))
)}
</article>
</div>
</div>
</article>
) : (
<>
@@ -321,6 +794,24 @@ function App() {
<span>Acting Side</span>
<strong>{run.activeCombat.actingSide}</strong>
</div>
<div className="combat-summary-grid">
<div className="encounter-box">
<span className="encounter-label">Player HP</span>
<strong>
{run.activeCombat.player.hpCurrent}/{run.activeCombat.player.hpMax}
</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Enemies Standing</span>
<strong>
{run.activeCombat.enemies.filter((enemy) => enemy.hpCurrent > 0).length}
</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Last Roll</span>
<strong>{run.activeCombat.lastRoll?.total ?? "-"}</strong>
</div>
</div>
<div className="enemy-list">
{run.activeCombat.enemies.map((enemy) => (
<div key={enemy.id} className="enemy-card">
@@ -329,6 +820,7 @@ function App() {
<span>
HP {enemy.hpCurrent}/{enemy.hpMax}
</span>
<span>Target {getCombatTargetNumber(enemy.armourValue ?? 0)}</span>
</div>
<div className="enemy-actions">
<button
@@ -366,11 +858,20 @@ function App() {
Resolve Enemy Turn
</button>
</div>
<div className="combat-feed">
{latestCombatLogs.map((entry) => (
<article key={entry.id} className="log-entry">
<span>{entry.type}</span>
<p>{entry.text}</p>
</article>
))}
</div>
</>
) : (
<p className="supporting-text">
No active combat. Travel until a room reveals a hostile encounter,
then engage it from the banner or room panel.
{run.lastCombatOutcome
? "No active combat. Review the last battle above, then continue the delve or recover in town."
: "No active combat. Travel until a room reveals a hostile encounter, then engage it from the banner or room panel."}
</p>
)}
</article>