Files
2D6-Dungeon/src/App.tsx

908 lines
31 KiB
TypeScript

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,
getAvailableMoves,
isCurrentRoomCombatReady,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
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() {
const adventurer = createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
armourId: "armour.leather-vest",
scrollId: "scroll.lesser-heal",
});
return createRunState({
content: sampleContentPack,
campaignId: "campaign.demo",
adventurer,
});
}
function getRoomTitle(run: RunState, roomId?: string) {
if (!roomId) {
return "Unknown Room";
}
const room = run.dungeon.levels[run.currentLevel]?.rooms[roomId];
if (!room) {
return "Unknown Room";
}
return (
sampleContentPack.roomTemplates.find((template) => template.id === room.templateId)?.title ??
room.notes[0] ??
room.templateId ??
room.id
);
}
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);
};
const handleStartCombat = () => {
setRun((previous) =>
startCombatInCurrentRoom({ content: sampleContentPack, run: previous }).run,
);
};
const handleTravel = (direction: "north" | "east" | "south" | "west") => {
setRun((previous) =>
travelCurrentExit({
content: sampleContentPack,
run: previous,
exitDirection: direction,
}).run,
);
};
const handlePlayerTurn = (manoeuvreId: string, targetEnemyId: string) => {
setRun((previous) =>
resolveRunPlayerTurn({
content: sampleContentPack,
run: previous,
manoeuvreId,
targetEnemyId,
}).run,
);
};
const handleEnemyTurn = () => {
setRun((previous) =>
resolveRunEnemyTurn({ content: sampleContentPack, run: previous }).run,
);
};
const handleReturnToTown = () => {
setRun((previous) => returnToTown(previous).run);
};
const handleResumeDungeon = () => {
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">
<div>
<p className="eyebrow">2D6 Dungeon Web</p>
<h1>Dungeon Loop Shell</h1>
<p className="lede">
Traverse generated rooms, auto-resolve room entry, and engage combat
when a room reveals a real encounter.
</p>
</div>
<div className="hero-actions">
<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}
disabled={Boolean(run.activeCombat)}
>
{inTown ? "Resume Dungeon" : "Return To Town"}
</button>
<div className="status-chip">
<span>Run Phase</span>
<strong>{run.phase}</strong>
</div>
</div>
</section>
{combatReadyEncounter && !run.activeCombat && !inTown ? (
<section className="alert-banner">
<div>
<span className="alert-kicker">Encounter Ready</span>
<strong>{currentRoom?.encounter?.resultLabel}</strong>
<p>
This room contains a combat-ready encounter. Engage now to enter
tactical resolution.
</p>
</div>
<button className="button button-primary" onClick={handleStartCombat}>
Engage Encounter
</button>
</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">
<h2>Adventurer</h2>
<span>Level {run.adventurerSnapshot.level}</span>
</div>
<div className="stat-strip">
<div>
<span>HP</span>
<strong>
{run.adventurerSnapshot.hp.current}/{run.adventurerSnapshot.hp.max}
</strong>
</div>
<div>
<span>Shift</span>
<strong>{run.adventurerSnapshot.stats.shift}</strong>
</div>
<div>
<span>Discipline</span>
<strong>{run.adventurerSnapshot.stats.discipline}</strong>
</div>
<div>
<span>Precision</span>
<strong>{run.adventurerSnapshot.stats.precision}</strong>
</div>
<div>
<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{" "}
{sampleContentPack.weapons.find(
(weapon) => weapon.id === run.adventurerSnapshot.weaponId,
)?.name ?? "weapon"}
.
</p>
<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 ? (
<article className="panel panel-town-hub">
<div className="panel-header">
<h2>Town Hub</h2>
<span>Between Delves</span>
</div>
<h3 className="room-title">Safe Harbor</h3>
<p className="supporting-text">
You are out of the dungeon. Review the current expedition, catch your breath, and
then resume the delve from the same level when you are ready.
</p>
<div className="town-summary-grid">
<div className="encounter-box">
<span className="encounter-label">Current Gold</span>
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Rooms Found</span>
<strong>{currentLevel?.discoveredRoomOrder.length ?? 0}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Foes Defeated</span>
<strong>{run.defeatedCreatureIds.length}</strong>
</div>
</div>
<div className="button-row">
<button className="button button-primary" onClick={handleResumeDungeon}>
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>
) : (
<>
<article className="panel">
<div className="panel-header">
<h2>Current Room</h2>
<span>Level {run.currentLevel}</span>
</div>
<h3 className="room-title">
{getRoomTitle(run, run.currentRoomId)}
</h3>
<p className="supporting-text">
{currentRoom?.notes[1] ?? "No encounter notes available yet."}
</p>
<div className="room-meta">
<span>Entered: {currentRoom?.discovery.entered ? "Yes" : "No"}</span>
<span>Cleared: {currentRoom?.discovery.cleared ? "Yes" : "No"}</span>
<span>Exits: {currentRoom?.exits.length ?? 0}</span>
</div>
<div className="button-row">
<button className="button" onClick={handleEnterRoom}>
Enter Room
</button>
<button
className="button button-primary"
onClick={handleStartCombat}
disabled={!combatReadyEncounter || Boolean(run.activeCombat)}
>
Start Combat
</button>
</div>
<div className="encounter-box">
<span className="encounter-label">Encounter</span>
<strong>{currentRoom?.encounter?.resultLabel ?? "None"}</strong>
</div>
</article>
<article className="panel">
<div className="panel-header">
<h2>Navigation</h2>
<span>{availableMoves.length} exits</span>
</div>
<div className="move-list">
{availableMoves.map((move) => (
<button
key={move.direction}
className="move-card"
onClick={() => handleTravel(move.direction)}
disabled={Boolean(run.activeCombat)}
>
<span>{move.direction}</span>
<strong>{move.leadsToRoomId ? "Travel" : "Generate"}</strong>
<em>
{move.leadsToRoomId
? getRoomTitle(run, move.leadsToRoomId)
: `${move.exitType} exit`}
</em>
</button>
))}
</div>
<div className="mini-map">
{currentLevel?.discoveredRoomOrder.map((roomId) => {
const room = currentLevel.rooms[roomId];
const active = roomId === run.currentRoomId;
return (
<article
key={roomId}
className={`map-node${active ? " map-node-active" : ""}`}
>
<span>
{room.position.x},{room.position.y}
</span>
<strong>{getRoomTitle(run, roomId)}</strong>
</article>
);
})}
</div>
</article>
<article className="panel">
<div className="panel-header">
<h2>Combat</h2>
<span>{run.activeCombat ? `Round ${run.activeCombat.round}` : "Inactive"}</span>
</div>
{run.activeCombat ? (
<>
<div className="combat-status">
<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">
<div>
<strong>{enemy.name}</strong>
<span>
HP {enemy.hpCurrent}/{enemy.hpMax}
</span>
<span>Target {getCombatTargetNumber(enemy.armourValue ?? 0)}</span>
</div>
<div className="enemy-actions">
<button
className="button"
onClick={() =>
handlePlayerTurn("manoeuvre.exact-strike", enemy.id)
}
disabled={
run.activeCombat?.actingSide !== "player" ||
enemy.hpCurrent <= 0
}
>
Exact Strike
</button>
<button
className="button"
onClick={() => handlePlayerTurn("manoeuvre.guard-break", enemy.id)}
disabled={
run.activeCombat?.actingSide !== "player" ||
enemy.hpCurrent <= 0
}
>
Guard Break
</button>
</div>
</div>
))}
</div>
<div className="button-row">
<button
className="button button-primary"
onClick={handleEnemyTurn}
disabled={run.activeCombat.actingSide !== "enemy"}
>
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">
{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>
</>
)}
<article className="panel panel-log">
<div className="panel-header">
<h2>Run Log</h2>
<span>{run.log.length} entries</span>
</div>
<div className="log-list">
{run.log.length === 0 ? (
<p className="supporting-text">No events recorded yet.</p>
) : (
run.log
.slice()
.reverse()
.map((entry) => (
<article key={entry.id} className="log-entry">
<span>{entry.type}</span>
<p>{entry.text}</p>
</article>
))
)}
</div>
</article>
</section>
</main>
);
}
export default App;