Compare commits
17 Commits
feature/po
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d2890cd9 | ||
|
|
626d5ca05c | ||
| 68654a8cc0 | |||
| 40ce9644ab | |||
| 473ea83cdf | |||
| 7fb3bd6cf5 | |||
|
|
fb6cbfe9fb | ||
| 9c7acf6825 | |||
| 102cbfeaad | |||
| 377a533466 | |||
| e3f90ca545 | |||
| 79b9e448b7 | |||
| aea00d31e8 | |||
| fdaa2e3135 | |||
| 67453df51e | |||
| cff5f786a0 | |||
| 5debb5bd5e |
66
src/App.tsx
66
src/App.tsx
@@ -9,6 +9,8 @@ import {
|
||||
isCurrentRoomCombatReady,
|
||||
resolveRunEnemyTurn,
|
||||
resolveRunPlayerTurn,
|
||||
resumeDungeon,
|
||||
returnToTown,
|
||||
startCombatInCurrentRoom,
|
||||
travelCurrentExit,
|
||||
} from "@/rules/runState";
|
||||
@@ -54,6 +56,7 @@ function App() {
|
||||
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
|
||||
const availableMoves = getAvailableMoves(run);
|
||||
const combatReadyEncounter = isCurrentRoomCombatReady(run);
|
||||
const inTown = run.phase === "town";
|
||||
|
||||
const handleReset = () => {
|
||||
setRun(createDemoRun());
|
||||
@@ -96,6 +99,14 @@ function App() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleReturnToTown = () => {
|
||||
setRun((previous) => returnToTown(previous).run);
|
||||
};
|
||||
|
||||
const handleResumeDungeon = () => {
|
||||
setRun((previous) => resumeDungeon(previous).run);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero">
|
||||
@@ -112,14 +123,21 @@ function App() {
|
||||
<button className="button button-primary" onClick={handleReset}>
|
||||
Reset Demo 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 Status</span>
|
||||
<strong>{run.status}</strong>
|
||||
<span>Run Phase</span>
|
||||
<strong>{run.phase}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{combatReadyEncounter && !run.activeCombat ? (
|
||||
{combatReadyEncounter && !run.activeCombat && !inTown ? (
|
||||
<section className="alert-banner">
|
||||
<div>
|
||||
<span className="alert-kicker">Encounter Ready</span>
|
||||
@@ -175,9 +193,47 @@ function App() {
|
||||
<p className="supporting-text">
|
||||
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
|
||||
</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>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
{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>
|
||||
</article>
|
||||
) : (
|
||||
<>
|
||||
<article className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Current Room</h2>
|
||||
<span>Level {run.currentLevel}</span>
|
||||
@@ -318,6 +374,8 @@ function App() {
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
|
||||
<article className="panel panel-log">
|
||||
<div className="panel-header">
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isCurrentRoomCombatReady,
|
||||
resolveRunEnemyTurn,
|
||||
resolveRunPlayerTurn,
|
||||
resumeDungeon,
|
||||
returnToTown,
|
||||
startCombatInCurrentRoom,
|
||||
travelCurrentExit,
|
||||
} from "./runState";
|
||||
@@ -44,6 +46,7 @@ describe("run state flow", () => {
|
||||
|
||||
expect(run.currentLevel).toBe(1);
|
||||
expect(run.currentRoomId).toBe("room.level1.start");
|
||||
expect(run.phase).toBe("dungeon");
|
||||
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -253,4 +256,22 @@ describe("run state flow", () => {
|
||||
|
||||
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns to town and later resumes the dungeon", () => {
|
||||
const run = createRunState({
|
||||
content: sampleContentPack,
|
||||
campaignId: "campaign.1",
|
||||
adventurer: createAdventurer(),
|
||||
at: "2026-03-15T14:00:00.000Z",
|
||||
});
|
||||
|
||||
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
|
||||
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
|
||||
|
||||
expect(inTown.phase).toBe("town");
|
||||
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
|
||||
expect(getAvailableMoves(inTown)).toEqual([]);
|
||||
expect(resumed.phase).toBe("dungeon");
|
||||
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,22 @@ export type RunTransitionResult = {
|
||||
logEntries: LogEntry[];
|
||||
};
|
||||
|
||||
function createLogEntry(
|
||||
id: string,
|
||||
at: string,
|
||||
type: LogEntry["type"],
|
||||
text: string,
|
||||
relatedIds?: string[],
|
||||
): LogEntry {
|
||||
return {
|
||||
id,
|
||||
at,
|
||||
type,
|
||||
text,
|
||||
relatedIds,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCombat(combat: CombatState): CombatState {
|
||||
return {
|
||||
...combat,
|
||||
@@ -290,6 +306,7 @@ export function createRunState(options: CreateRunOptions): RunState {
|
||||
id: options.runId ?? "run.active",
|
||||
campaignId: options.campaignId,
|
||||
status: "active",
|
||||
phase: "dungeon",
|
||||
startedAt: at,
|
||||
currentLevel: 1,
|
||||
currentRoomId: "room.level1.start",
|
||||
@@ -310,10 +327,68 @@ export function createRunState(options: CreateRunOptions): RunState {
|
||||
};
|
||||
}
|
||||
|
||||
export function returnToTown(
|
||||
run: RunState,
|
||||
at = new Date().toISOString(),
|
||||
): RunTransitionResult {
|
||||
const nextRun = cloneRun(run);
|
||||
|
||||
if (nextRun.activeCombat) {
|
||||
throw new Error("Cannot return to town during active combat.");
|
||||
}
|
||||
|
||||
nextRun.phase = "town";
|
||||
nextRun.lastTownAt = at;
|
||||
|
||||
const logEntry = createLogEntry(
|
||||
`run.return-to-town.${nextRun.log.length + 1}`,
|
||||
at,
|
||||
"town",
|
||||
`Returned to town from level ${nextRun.currentLevel}.`,
|
||||
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
|
||||
);
|
||||
|
||||
appendLogs(nextRun, [logEntry]);
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
logEntries: [logEntry],
|
||||
};
|
||||
}
|
||||
|
||||
export function resumeDungeon(
|
||||
run: RunState,
|
||||
at = new Date().toISOString(),
|
||||
): RunTransitionResult {
|
||||
const nextRun = cloneRun(run);
|
||||
|
||||
nextRun.phase = "dungeon";
|
||||
|
||||
const logEntry = createLogEntry(
|
||||
`run.resume-dungeon.${nextRun.log.length + 1}`,
|
||||
at,
|
||||
"room",
|
||||
`Left town and resumed the dungeon delve on level ${nextRun.currentLevel}.`,
|
||||
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
|
||||
);
|
||||
|
||||
appendLogs(nextRun, [logEntry]);
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
logEntries: [logEntry],
|
||||
};
|
||||
}
|
||||
|
||||
export function enterCurrentRoom(
|
||||
options: EnterCurrentRoomOptions,
|
||||
): RunTransitionResult {
|
||||
const run = cloneRun(options.run);
|
||||
|
||||
if (run.phase !== "dungeon") {
|
||||
throw new Error("Cannot enter rooms while the run is in town.");
|
||||
}
|
||||
|
||||
const levelState = requireCurrentLevel(run);
|
||||
const roomId = requireCurrentRoomId(run);
|
||||
const entry = enterRoom({
|
||||
@@ -334,6 +409,10 @@ export function enterCurrentRoom(
|
||||
}
|
||||
|
||||
export function getAvailableMoves(run: RunState): AvailableMove[] {
|
||||
if (run.phase !== "dungeon") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const room = requireCurrentRoom(run);
|
||||
|
||||
return room.exits
|
||||
@@ -348,6 +427,10 @@ export function getAvailableMoves(run: RunState): AvailableMove[] {
|
||||
}
|
||||
|
||||
export function isCurrentRoomCombatReady(run: RunState) {
|
||||
if (run.phase !== "dungeon") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const room = requireCurrentRoom(run);
|
||||
|
||||
return Boolean(
|
||||
@@ -362,6 +445,10 @@ export function travelCurrentExit(
|
||||
): RunTransitionResult {
|
||||
const run = cloneRun(options.run);
|
||||
|
||||
if (run.phase !== "dungeon") {
|
||||
throw new Error("Cannot travel while the run is in town.");
|
||||
}
|
||||
|
||||
if (run.activeCombat) {
|
||||
throw new Error("Cannot travel while combat is active.");
|
||||
}
|
||||
@@ -437,6 +524,11 @@ export function startCombatInCurrentRoom(
|
||||
options: StartCurrentCombatOptions,
|
||||
): RunTransitionResult {
|
||||
const run = cloneRun(options.run);
|
||||
|
||||
if (run.phase !== "dungeon") {
|
||||
throw new Error("Cannot start combat while the run is in town.");
|
||||
}
|
||||
|
||||
const levelState = requireCurrentLevel(run);
|
||||
const roomId = requireCurrentRoomId(run);
|
||||
const room = levelState.rooms[roomId];
|
||||
@@ -467,6 +559,10 @@ export function resolveRunPlayerTurn(
|
||||
): RunTransitionResult {
|
||||
const run = cloneRun(options.run);
|
||||
|
||||
if (run.phase !== "dungeon") {
|
||||
throw new Error("Cannot resolve combat while the run is in town.");
|
||||
}
|
||||
|
||||
if (!run.activeCombat) {
|
||||
throw new Error("Run does not have an active combat.");
|
||||
}
|
||||
@@ -517,6 +613,10 @@ export function resolveRunEnemyTurn(
|
||||
): RunTransitionResult {
|
||||
const run = cloneRun(options.run);
|
||||
|
||||
if (run.phase !== "dungeon") {
|
||||
throw new Error("Cannot resolve combat while the run is in town.");
|
||||
}
|
||||
|
||||
if (!run.activeCombat) {
|
||||
throw new Error("Run does not have an active combat.");
|
||||
}
|
||||
|
||||
@@ -208,7 +208,9 @@ export const runStateSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
campaignId: z.string().min(1),
|
||||
status: z.enum(["active", "paused", "completed", "failed"]),
|
||||
phase: z.enum(["dungeon", "town"]),
|
||||
startedAt: z.string().min(1),
|
||||
lastTownAt: z.string().optional(),
|
||||
currentLevel: z.number().int().positive(),
|
||||
currentRoomId: z.string().optional(),
|
||||
dungeon: dungeonStateSchema,
|
||||
|
||||
@@ -156,6 +156,10 @@ select {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.panel-town-hub {
|
||||
grid-column: span 8;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -217,6 +221,13 @@ select {
|
||||
color: rgba(244, 239, 227, 0.76);
|
||||
}
|
||||
|
||||
.town-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
@@ -393,4 +404,8 @@ select {
|
||||
.stat-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.town-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,9 @@ export type RunState = {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
status: "active" | "paused" | "completed" | "failed";
|
||||
phase: "dungeon" | "town";
|
||||
startedAt: string;
|
||||
lastTownAt?: string;
|
||||
currentLevel: number;
|
||||
currentRoomId?: string;
|
||||
dungeon: DungeonState;
|
||||
|
||||
Reference in New Issue
Block a user