✨Feature: implement post-combat rewards system with XP tracking and creature defeat logging
This commit is contained in:
377
src/App.tsx
377
src/App.tsx
@@ -1,71 +1,344 @@
|
||||
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 type { RunState } from "@/types/state";
|
||||
|
||||
const planningDocs = [
|
||||
"Planning/PROJECT_PLAN.md",
|
||||
"Planning/GAME_SPEC.md",
|
||||
"Planning/content-checklist.json",
|
||||
"Planning/DATA_MODEL.md",
|
||||
"Planning/IMPLEMENTATION_NOTES.md",
|
||||
];
|
||||
function createDemoRun() {
|
||||
const adventurer = createStartingAdventurer(sampleContentPack, {
|
||||
name: "Aster",
|
||||
weaponId: "weapon.short-sword",
|
||||
armourId: "armour.leather-vest",
|
||||
scrollId: "scroll.lesser-heal",
|
||||
});
|
||||
|
||||
const nextTargets = [
|
||||
"Encode Level 1 foundational tables into structured JSON.",
|
||||
"Implement dice utilities for D3, D6, 2D6, and D66.",
|
||||
"Create character creation state and validation.",
|
||||
"Build deterministic room generation for the Level 1 loop.",
|
||||
];
|
||||
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 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 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,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero">
|
||||
<p className="eyebrow">2D6 Dungeon Web</p>
|
||||
<h1>Project scaffold is live.</h1>
|
||||
<p className="lede">
|
||||
The app now has a Vite + React + TypeScript foundation, shared type
|
||||
models, and Zod schemas that mirror the planning documents.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<section className="panel-grid">
|
||||
<article className="panel">
|
||||
<h2>Planning Set</h2>
|
||||
<ul>
|
||||
{planningDocs.map((doc) => (
|
||||
<li key={doc}>{doc}</li>
|
||||
))}
|
||||
</ul>
|
||||
{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 earned, {run.defeatedCreatureIds.length} foes defeated.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<h2>Immediate Build Targets</h2>
|
||||
<ol>
|
||||
{nextTargets.map((target) => (
|
||||
<li key={target}>{target}</li>
|
||||
))}
|
||||
</ol>
|
||||
<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">
|
||||
<h2>Sample Content Pack</h2>
|
||||
<dl className="stats">
|
||||
<div>
|
||||
<dt>Tables</dt>
|
||||
<dd>{sampleContentPack.tables.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Weapons</dt>
|
||||
<dd>{sampleContentPack.weapons.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Manoeuvres</dt>
|
||||
<dd>{sampleContentPack.manoeuvres.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Creatures</dt>
|
||||
<dd>{sampleContentPack.creatures.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user