✨Feature: add town systems, saves, recovery, and level progression
This commit is contained in:
505
src/App.tsx
505
src/App.tsx
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user