908 lines
31 KiB
TypeScript
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;
|