607 lines
22 KiB
TypeScript
607 lines
22 KiB
TypeScript
import React from "react";
|
|
|
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
|
import { createStartingAdventurer } from "@/rules/character";
|
|
import {
|
|
createRunState,
|
|
enterCurrentRoom,
|
|
getAvailableMoves,
|
|
isCurrentRoomCombatReady,
|
|
resolveRunEnemyTurn,
|
|
resolveRunPlayerTurn,
|
|
startCombatInCurrentRoom,
|
|
travelCurrentExit,
|
|
} from "@/rules/runState";
|
|
import { queueTreasureForSale, sellPendingTreasure, sendTreasureToStash } from "@/rules/town";
|
|
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 getDefinitionName(definitionId: string) {
|
|
const item =
|
|
sampleContentPack.items.find((candidate) => candidate.id === definitionId) ??
|
|
sampleContentPack.weapons.find((candidate) => candidate.id === definitionId) ??
|
|
sampleContentPack.armour.find((candidate) => candidate.id === definitionId) ??
|
|
sampleContentPack.scrolls.find((candidate) => candidate.id === definitionId) ??
|
|
sampleContentPack.potions.find((candidate) => candidate.id === definitionId);
|
|
|
|
return item?.name ?? definitionId;
|
|
}
|
|
|
|
function formatInventoryEntry(definitionId: string, quantity: number) {
|
|
const name = getDefinitionName(definitionId);
|
|
return quantity > 1 ? `${quantity}x ${name}` : name;
|
|
}
|
|
|
|
function getTreasureItemIds() {
|
|
return new Set(
|
|
sampleContentPack.items
|
|
.filter((item) => item.itemType === "treasure")
|
|
.map((item) => item.id),
|
|
);
|
|
}
|
|
|
|
function getSupportItemIds() {
|
|
return new Set(
|
|
sampleContentPack.items
|
|
.filter((item) => item.itemType !== "treasure")
|
|
.map((item) => item.id),
|
|
);
|
|
}
|
|
|
|
function getConsumableItemIds() {
|
|
return new Set(
|
|
sampleContentPack.items
|
|
.filter((item) => item.consumable || item.itemType === "ration")
|
|
.map((item) => item.id),
|
|
);
|
|
}
|
|
|
|
const treasureItemIds = getTreasureItemIds();
|
|
const supportItemIds = getSupportItemIds();
|
|
const consumableItemIds = getConsumableItemIds();
|
|
|
|
function App() {
|
|
const [run, setRun] = React.useState<RunState>(() => createDemoRun());
|
|
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 carriedTreasure = run.adventurerSnapshot.inventory.carried.filter((entry) =>
|
|
treasureItemIds.has(entry.definitionId),
|
|
);
|
|
const carriedConsumables = run.adventurerSnapshot.inventory.carried.filter(
|
|
(entry) =>
|
|
consumableItemIds.has(entry.definitionId) ||
|
|
entry.definitionId.startsWith("potion.") ||
|
|
entry.definitionId.startsWith("scroll."),
|
|
);
|
|
const carriedGear = run.adventurerSnapshot.inventory.carried.filter(
|
|
(entry) =>
|
|
supportItemIds.has(entry.definitionId) &&
|
|
!consumableItemIds.has(entry.definitionId),
|
|
);
|
|
const equippedItems = run.adventurerSnapshot.inventory.equipped;
|
|
const latestLoot = run.lootedItems.slice(-4).reverse();
|
|
const pendingSales = run.townState.pendingSales;
|
|
const stash = run.townState.stash;
|
|
|
|
const handleReset = () => {
|
|
setRun(createDemoRun());
|
|
};
|
|
|
|
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 handleQueueSale = (definitionId: string) => {
|
|
setRun((previous) => queueTreasureForSale(sampleContentPack, previous, definitionId).run);
|
|
};
|
|
|
|
const handleStashTreasure = (definitionId: string) => {
|
|
setRun((previous) => sendTreasureToStash(sampleContentPack, previous, definitionId).run);
|
|
};
|
|
|
|
const handleSellPending = () => {
|
|
setRun((previous) => sellPendingTreasure(sampleContentPack, previous).run);
|
|
};
|
|
|
|
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>
|
|
<div className="status-chip">
|
|
<span>Run Status</span>
|
|
<strong>{run.status}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{combatReadyEncounter && !run.activeCombat ? (
|
|
<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}
|
|
|
|
<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>
|
|
<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, {run.goldGained} gold,{" "}
|
|
{run.defeatedCreatureIds.length} foes defeated.
|
|
</p>
|
|
</article>
|
|
|
|
<article className="panel panel-inventory">
|
|
<div className="panel-header">
|
|
<h2>Inventory</h2>
|
|
<span>{run.adventurerSnapshot.inventory.carried.length} carried entries</span>
|
|
</div>
|
|
<div className="inventory-summary">
|
|
<div className="inventory-badge">
|
|
<span>Gold</span>
|
|
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
|
|
</div>
|
|
<div className="inventory-badge">
|
|
<span>Rations</span>
|
|
<strong>{run.adventurerSnapshot.inventory.rationCount}</strong>
|
|
</div>
|
|
<div className="inventory-badge">
|
|
<span>Treasure</span>
|
|
<strong>{carriedTreasure.length}</strong>
|
|
</div>
|
|
<div className="inventory-badge">
|
|
<span>Latest Loot</span>
|
|
<strong>{run.lootedItems.reduce((total, entry) => total + entry.quantity, 0)}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="inventory-grid">
|
|
<section className="inventory-section">
|
|
<span className="inventory-label">Equipped</span>
|
|
<div className="inventory-list">
|
|
{equippedItems.map((entry) => (
|
|
<article key={`equipped-${entry.definitionId}`} className="inventory-card inventory-card-equipped">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Ready for use</span>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section className="inventory-section">
|
|
<span className="inventory-label">Consumables</span>
|
|
<div className="inventory-list">
|
|
{carriedConsumables.length === 0 ? (
|
|
<p className="supporting-text">No consumables carried.</p>
|
|
) : (
|
|
carriedConsumables.map((entry) => (
|
|
<article key={`consumable-${entry.definitionId}`} className="inventory-card">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Combat or run utility</span>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
<section className="inventory-section">
|
|
<span className="inventory-label">Pack Gear</span>
|
|
<div className="inventory-list">
|
|
{carriedGear.length === 0 ? (
|
|
<p className="supporting-text">No general gear carried.</p>
|
|
) : (
|
|
carriedGear.map((entry) => (
|
|
<article key={`gear-${entry.definitionId}`} className="inventory-card">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Travel and exploration kit</span>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
<section className="inventory-section">
|
|
<span className="inventory-label">Treasure Stash</span>
|
|
<div className="inventory-list">
|
|
{carriedTreasure.length === 0 ? (
|
|
<p className="supporting-text">No treasure recovered yet.</p>
|
|
) : (
|
|
carriedTreasure.map((entry) => (
|
|
<article key={`treasure-${entry.definitionId}`} className="inventory-card inventory-card-treasure">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Sellable dungeon spoils</span>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<div className="loot-ribbon">
|
|
<span className="inventory-label">Recent Spoils</span>
|
|
{latestLoot.length === 0 ? (
|
|
<p className="supporting-text">Win a fight to populate the loot ribbon.</p>
|
|
) : (
|
|
<div className="loot-ribbon-list">
|
|
{latestLoot.map((entry) => (
|
|
<article key={`recent-${entry.definitionId}`} className="loot-pill">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
|
|
<article className="panel panel-town">
|
|
<div className="panel-header">
|
|
<h2>Town Market</h2>
|
|
<span>{run.townState.visits} town actions</span>
|
|
</div>
|
|
<div className="town-summary">
|
|
<div className="inventory-badge">
|
|
<span>Known Services</span>
|
|
<strong>{run.townState.knownServices.length}</strong>
|
|
</div>
|
|
<div className="inventory-badge">
|
|
<span>Queued Sales</span>
|
|
<strong>{pendingSales.length}</strong>
|
|
</div>
|
|
<div className="inventory-badge">
|
|
<span>Stash</span>
|
|
<strong>{stash.length}</strong>
|
|
</div>
|
|
</div>
|
|
<div className="town-actions">
|
|
<button
|
|
className="button button-primary"
|
|
onClick={handleSellPending}
|
|
disabled={pendingSales.length === 0}
|
|
>
|
|
Sell Queued Treasure
|
|
</button>
|
|
</div>
|
|
<section className="town-section">
|
|
<span className="inventory-label">Treasure In Pack</span>
|
|
<div className="inventory-list">
|
|
{carriedTreasure.length === 0 ? (
|
|
<p className="supporting-text">No treasure available for town actions.</p>
|
|
) : (
|
|
carriedTreasure.map((entry) => (
|
|
<article key={`town-pack-${entry.definitionId}`} className="town-card">
|
|
<div>
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Choose whether to sell or stash this treasure.</span>
|
|
</div>
|
|
<div className="enemy-actions">
|
|
<button className="button" onClick={() => handleStashTreasure(entry.definitionId)}>
|
|
Send To Stash
|
|
</button>
|
|
<button className="button button-primary" onClick={() => handleQueueSale(entry.definitionId)}>
|
|
Queue For Sale
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
<section className="town-section">
|
|
<span className="inventory-label">Pending Sales</span>
|
|
<div className="inventory-list">
|
|
{pendingSales.length === 0 ? (
|
|
<p className="supporting-text">Nothing queued at the market.</p>
|
|
) : (
|
|
pendingSales.map((entry) => (
|
|
<article key={`pending-${entry.definitionId}`} className="inventory-card inventory-card-treasure">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Ready to convert into gold</span>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
<section className="town-section">
|
|
<span className="inventory-label">Town Stash</span>
|
|
<div className="inventory-list">
|
|
{stash.length === 0 ? (
|
|
<p className="supporting-text">The stash is empty.</p>
|
|
) : (
|
|
stash.map((entry) => (
|
|
<article key={`stash-${entry.definitionId}`} className="inventory-card">
|
|
<strong>{formatInventoryEntry(entry.definitionId, entry.quantity)}</strong>
|
|
<span>Held safely in town storage</span>
|
|
</article>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
</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="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>
|
|
</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>
|
|
</>
|
|
) : (
|
|
<p className="supporting-text">
|
|
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;
|