From cf98636a527cc5a6a23b41218f33e8a485551552 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 14:16:37 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20post-combat=20r?= =?UTF-8?q?ewards=20system=20with=20XP=20tracking=20and=20creature=20defea?= =?UTF-8?q?t=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 377 ++++++++++++++++++++++++++++++----- src/data/contentHelpers.ts | 13 ++ src/rules/runState.test.ts | 70 +++++++ src/rules/runState.ts | 211 +++++++++++++++++++- src/schemas/state.ts | 2 + src/styles.css | 392 +++++++++++++++++++++++++++++++------ src/types/state.ts | 2 + 7 files changed, 951 insertions(+), 116 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 025e45a..681e4e7 100644 --- a/src/App.tsx +++ b/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(() => 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 (
-

2D6 Dungeon Web

-

Project scaffold is live.

-

- The app now has a Vite + React + TypeScript foundation, shared type - models, and Zod schemas that mirror the planning documents. -

+
+

2D6 Dungeon Web

+

Dungeon Loop Shell

+

+ Traverse generated rooms, auto-resolve room entry, and engage combat + when a room reveals a real encounter. +

+
+ +
+ +
+ Run Status + {run.status} +
+
-
-
-

Planning Set

-
    - {planningDocs.map((doc) => ( -
  • {doc}
  • - ))} -
+ {combatReadyEncounter && !run.activeCombat ? ( +
+
+ Encounter Ready + {currentRoom?.encounter?.resultLabel} +

+ This room contains a combat-ready encounter. Engage now to enter + tactical resolution. +

+
+ +
+ ) : null} + +
+
+
+

Adventurer

+ Level {run.adventurerSnapshot.level} +
+
+
+ HP + + {run.adventurerSnapshot.hp.current}/{run.adventurerSnapshot.hp.max} + +
+
+ Shift + {run.adventurerSnapshot.stats.shift} +
+
+ Discipline + {run.adventurerSnapshot.stats.discipline} +
+
+ Precision + {run.adventurerSnapshot.stats.precision} +
+
+ XP + {run.adventurerSnapshot.xp} +
+
+

+ {run.adventurerSnapshot.name} is equipped with a{" "} + {sampleContentPack.weapons.find( + (weapon) => weapon.id === run.adventurerSnapshot.weaponId, + )?.name ?? "weapon"} + . +

+

+ Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated. +

-

Immediate Build Targets

-
    - {nextTargets.map((target) => ( -
  1. {target}
  2. - ))} -
+
+

Current Room

+ Level {run.currentLevel} +
+

+ {getRoomTitle(run, run.currentRoomId)} +

+

+ {currentRoom?.notes[1] ?? "No encounter notes available yet."} +

+
+ Entered: {currentRoom?.discovery.entered ? "Yes" : "No"} + Cleared: {currentRoom?.discovery.cleared ? "Yes" : "No"} + Exits: {currentRoom?.exits.length ?? 0} +
+
+ + +
+
+ Encounter + {currentRoom?.encounter?.resultLabel ?? "None"} +
-

Sample Content Pack

-
-
-
Tables
-
{sampleContentPack.tables.length}
-
-
-
Weapons
-
{sampleContentPack.weapons.length}
-
-
-
Manoeuvres
-
{sampleContentPack.manoeuvres.length}
-
-
-
Creatures
-
{sampleContentPack.creatures.length}
-
-
+
+

Navigation

+ {availableMoves.length} exits +
+
+ {availableMoves.map((move) => ( + + ))} +
+
+ {currentLevel?.discoveredRoomOrder.map((roomId) => { + const room = currentLevel.rooms[roomId]; + const active = roomId === run.currentRoomId; + + return ( +
+ + {room.position.x},{room.position.y} + + {getRoomTitle(run, roomId)} +
+ ); + })} +
+
+ +
+
+

Combat

+ {run.activeCombat ? `Round ${run.activeCombat.round}` : "Inactive"} +
+ {run.activeCombat ? ( + <> +
+ Acting Side + {run.activeCombat.actingSide} +
+
+ {run.activeCombat.enemies.map((enemy) => ( +
+
+ {enemy.name} + + HP {enemy.hpCurrent}/{enemy.hpMax} + +
+
+ + +
+
+ ))} +
+
+ +
+ + ) : ( +

+ No active combat. Travel until a room reveals a hostile encounter, + then engage it from the banner or room panel. +

+ )} +
+ +
+
+

Run Log

+ {run.log.length} entries +
+
+ {run.log.length === 0 ? ( +

No events recorded yet.

+ ) : ( + run.log + .slice() + .reverse() + .map((entry) => ( +
+ {entry.type} +

{entry.text}

+
+ )) + )} +
diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts index c4f4dfe..927aa60 100644 --- a/src/data/contentHelpers.ts +++ b/src/data/contentHelpers.ts @@ -61,3 +61,16 @@ export function findCreatureByName( return creature; } + +export function findCreatureById( + content: ContentPack, + creatureId: string, +): CreatureDefinition { + const creature = content.creatures.find((entry) => entry.id === creatureId); + + if (!creature) { + throw new Error(`Unknown creature id: ${creatureId}`); + } + + return creature; +} diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts index 31a3264..1ebde01 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -6,9 +6,12 @@ import { createStartingAdventurer } from "./character"; import { createRunState, enterCurrentRoom, + getAvailableMoves, + isCurrentRoomCombatReady, resolveRunEnemyTurn, resolveRunPlayerTurn, startCombatInCurrentRoom, + travelCurrentExit, } from "./runState"; function createSequenceRoller(values: number[]) { @@ -182,5 +185,72 @@ describe("run state flow", () => { expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.encounter?.rewardPending).toBe( false, ); + expect(result.run.adventurerSnapshot.xp).toBe(2); + expect(result.run.xpGained).toBe(2); + expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]); + expect(result.run.log.at(-1)?.text).toContain("Victory rewards"); + }); + + it("lists available traversable exits for the current room", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + expect(getAvailableMoves(run)).toEqual([ + expect.objectContaining({ + direction: "north", + generated: false, + }), + ]); + }); + + it("travels through an unresolved exit, generates a room, and enters it", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + at: "2026-03-15T14:00:00.000Z", + }); + + const result = travelCurrentExit({ + content: sampleContentPack, + run, + exitDirection: "north", + roller: createSequenceRoller([1, 1]), + at: "2026-03-15T14:05:00.000Z", + }); + + expect(result.run.currentRoomId).toBe("room.level1.room.002"); + expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([ + "room.level1.start", + "room.level1.room.002", + ]); + expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe( + true, + ); + expect(result.run.log[0]?.text).toContain("Travelled north"); + }); + + it("flags combat-ready rooms once entry resolves a hostile encounter", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + at: "2026-03-15T14:00:00.000Z", + }); + const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!; + + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1G", + creatureIds: ["a", "b"], + creatureNames: ["Guard", "Warrior"], + resultLabel: "Guard and Warrior", + resolved: true, + }; + + expect(isCurrentRoomCombatReady(run)).toBe(true); }); }); diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 29da125..19fffec 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -6,6 +6,7 @@ import type { RunState, } from "@/types/state"; import type { LogEntry } from "@/types/rules"; +import { findCreatureById } from "@/data/contentHelpers"; import { startCombatFromRoom } from "./combat"; import { @@ -14,7 +15,11 @@ import { type ResolveEnemyTurnOptions, type ResolvePlayerAttackOptions, } from "./combatTurns"; -import { initializeDungeonLevel } from "./dungeon"; +import { + expandLevelFromExit, + getUnresolvedExits, + initializeDungeonLevel, +} from "./dungeon"; import type { DiceRoller } from "./dice"; import { enterRoom } from "./roomEntry"; @@ -55,6 +60,23 @@ export type ResolveRunEnemyTurnOptions = { at?: string; }; +export type TravelCurrentExitOptions = { + content: ContentPack; + run: RunState; + exitDirection: "north" | "east" | "south" | "west"; + roomTableCode?: string; + roller?: DiceRoller; + at?: string; +}; + +export type AvailableMove = { + direction: "north" | "east" | "south" | "west"; + exitType: string; + discovered: boolean; + leadsToRoomId?: string; + generated: boolean; +}; + export type RunTransitionResult = { run: RunState; logEntries: LogEntry[]; @@ -167,6 +189,37 @@ function requireCurrentRoomId(run: RunState) { return run.currentRoomId; } +function requireCurrentRoom(run: RunState) { + const levelState = requireCurrentLevel(run); + const roomId = requireCurrentRoomId(run); + const room = levelState.rooms[roomId]; + + if (!room) { + throw new Error(`Unknown room id: ${roomId}`); + } + + return room; +} + +function inferNextRoomTableCode(run: RunState) { + const room = requireCurrentRoom(run); + const levelState = requireCurrentLevel(run); + + if (room.roomClass === "start") { + return "L1LR"; + } + + if (room.roomClass === "small") { + return "L1LR"; + } + + if (room.roomClass === "large") { + return "L1SR"; + } + + return levelState.discoveredRoomOrder.length % 2 === 0 ? "L1LR" : "L1SR"; +} + function syncPlayerToAdventurer(run: RunState) { if (!run.activeCombat) { return; @@ -180,6 +233,52 @@ function appendLogs(run: RunState, logEntries: LogEntry[]) { run.log.push(...logEntries); } +function createRewardLog( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "progression", + text, + relatedIds, + }; +} + +function applyCombatRewards( + content: ContentPack, + run: RunState, + completedCombat: CombatState, + at: string, +) { + const defeatedCreatureIds = completedCombat.enemies + .filter((enemy) => enemy.hpCurrent === 0 && enemy.sourceDefinitionId) + .map((enemy) => enemy.sourceDefinitionId!); + const xpAwarded = defeatedCreatureIds.reduce((total, creatureId) => { + return total + (findCreatureById(content, creatureId).xpReward ?? 0); + }, 0); + + run.defeatedCreatureIds.push(...defeatedCreatureIds); + run.xpGained += xpAwarded; + run.adventurerSnapshot.xp += xpAwarded; + + if (xpAwarded === 0) { + return [] as LogEntry[]; + } + + return [ + createRewardLog( + `${completedCombat.id}.rewards`, + at, + `Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, + [completedCombat.id, ...defeatedCreatureIds], + ), + ]; +} + export function createRunState(options: CreateRunOptions): RunState { const at = options.at ?? new Date().toISOString(); const levelState = initializeDungeonLevel({ @@ -204,6 +303,8 @@ export function createRunState(options: CreateRunOptions): RunState { globalFlags: [], }, adventurerSnapshot: options.adventurer, + defeatedCreatureIds: [], + xpGained: 0, log: [], pendingEffects: [], }; @@ -232,6 +333,106 @@ export function enterCurrentRoom( }; } +export function getAvailableMoves(run: RunState): AvailableMove[] { + const room = requireCurrentRoom(run); + + return room.exits + .filter((exit) => exit.traversable) + .map((exit) => ({ + direction: exit.direction, + exitType: exit.exitType, + discovered: exit.discovered, + leadsToRoomId: exit.leadsToRoomId, + generated: Boolean(exit.leadsToRoomId), + })); +} + +export function isCurrentRoomCombatReady(run: RunState) { + const room = requireCurrentRoom(run); + + return Boolean( + room.encounter?.resolved && + room.encounter.creatureNames && + room.encounter.creatureNames.length > 0, + ); +} + +export function travelCurrentExit( + options: TravelCurrentExitOptions, +): RunTransitionResult { + const run = cloneRun(options.run); + + if (run.activeCombat) { + throw new Error("Cannot travel while combat is active."); + } + + const levelState = requireCurrentLevel(run); + const roomId = requireCurrentRoomId(run); + const room = requireCurrentRoom(run); + const exit = room.exits.find((candidate) => candidate.direction === options.exitDirection); + + if (!exit) { + throw new Error(`Current room does not have an exit to the ${options.exitDirection}.`); + } + + if (!exit.traversable) { + throw new Error(`Exit ${exit.id} is not traversable.`); + } + + let nextLevelState = levelState; + let destinationRoomId = exit.leadsToRoomId; + const at = options.at ?? new Date().toISOString(); + + if (!destinationRoomId) { + const unresolvedExits = getUnresolvedExits(levelState); + const matchingExit = unresolvedExits.find( + (candidate) => + candidate.roomId === roomId && candidate.direction === options.exitDirection, + ); + + if (!matchingExit) { + throw new Error(`Exit ${exit.id} is no longer available for generation.`); + } + + const expansion = expandLevelFromExit({ + content: options.content, + levelState, + fromRoomId: roomId, + exitDirection: options.exitDirection, + roomTableCode: options.roomTableCode ?? inferNextRoomTableCode(run), + roller: options.roller, + }); + + nextLevelState = expansion.levelState; + destinationRoomId = expansion.createdRoom.id; + } + + run.dungeon.levels[run.currentLevel] = nextLevelState; + run.currentRoomId = destinationRoomId; + + const movedLog: LogEntry = { + id: `${roomId}.travel.${options.exitDirection}.${run.log.length + 1}`, + at, + type: "room", + text: `Travelled ${options.exitDirection} from ${room.id} to ${destinationRoomId}.`, + relatedIds: [room.id, destinationRoomId], + }; + + appendLogs(run, [movedLog]); + + const entered = enterCurrentRoom({ + content: options.content, + run, + roller: options.roller, + at, + }); + + return { + run: entered.run, + logEntries: [movedLog, ...entered.logEntries], + }; +} + export function startCombatInCurrentRoom( options: StartCurrentCombatOptions, ): RunTransitionResult { @@ -285,9 +486,16 @@ export function resolveRunPlayerTurn( appendLogs(run, result.logEntries); if (result.combatEnded) { + const completedCombat = result.combat; const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = levelState.rooms[roomId]; + const rewardLogs = applyCombatRewards( + options.content, + run, + completedCombat, + options.at ?? new Date().toISOString(), + ); if (room?.encounter) { room.encounter.rewardPending = false; @@ -295,6 +503,7 @@ export function resolveRunPlayerTurn( } run.activeCombat = undefined; + appendLogs(run, rewardLogs); } return { diff --git a/src/schemas/state.ts b/src/schemas/state.ts index f844ad9..eb57a3a 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -214,6 +214,8 @@ export const runStateSchema = z.object({ dungeon: dungeonStateSchema, adventurerSnapshot: adventurerStateSchema, activeCombat: combatStateSchema.optional(), + defeatedCreatureIds: z.array(z.string()), + xpGained: z.number().int().nonnegative(), log: z.array(logEntrySchema), pendingEffects: z.array(ruleEffectSchema), }); diff --git a/src/styles.css b/src/styles.css index aa16e3f..a2d350a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,9 +1,10 @@ :root { - font-family: "Segoe UI", "Aptos", sans-serif; - color: #f3f1e8; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + color: #f4efe3; background: - radial-gradient(circle at top, rgba(179, 121, 59, 0.35), transparent 30%), - linear-gradient(180deg, #17130f 0%, #0d0b09 100%); + radial-gradient(circle at top, rgba(177, 91, 29, 0.25), transparent 26%), + radial-gradient(circle at 20% 20%, rgba(227, 188, 101, 0.12), transparent 18%), + linear-gradient(180deg, #160f0b 0%, #0b0807 100%); line-height: 1.5; font-weight: 400; color-scheme: dark; @@ -23,108 +24,373 @@ body { min-height: 100vh; } +button, +input, +textarea, +select { + font: inherit; +} + #root { min-height: 100vh; } .app-shell { - width: min(1100px, calc(100% - 2rem)); + width: min(1200px, calc(100% - 2rem)); margin: 0 auto; - padding: 3rem 0 4rem; + padding: 1.5rem 0 3rem; } .hero { - padding: 2rem; - border: 1px solid rgba(243, 241, 232, 0.16); - background: rgba(21, 18, 14, 0.72); - box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); - backdrop-filter: blur(12px); + display: flex; + justify-content: space-between; + gap: 1.5rem; + align-items: end; + padding: 1.75rem; + border: 1px solid rgba(255, 231, 196, 0.12); + background: + linear-gradient(135deg, rgba(62, 34, 17, 0.92), rgba(24, 18, 15, 0.86)), + rgba(18, 14, 12, 0.9); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42); } .eyebrow { - margin: 0 0 0.75rem; + margin: 0 0 0.85rem; text-transform: uppercase; - letter-spacing: 0.14em; - color: #d8b27a; - font-size: 0.8rem; + letter-spacing: 0.18em; + color: #f1ba73; + font-size: 0.76rem; } .hero h1 { margin: 0; - font-size: clamp(2.5rem, 7vw, 4.5rem); - line-height: 0.95; + font-size: clamp(2.6rem, 6vw, 4.8rem); + line-height: 0.92; } .lede { - width: min(55ch, 100%); - margin: 1.25rem 0 0; - color: rgba(243, 241, 232, 0.82); - font-size: 1.05rem; + width: min(56ch, 100%); + margin: 1rem 0 0; + color: rgba(244, 239, 227, 0.78); } -.panel-grid { +.hero-actions { + display: flex; + flex-direction: column; + gap: 0.85rem; + align-items: flex-end; +} + +.status-chip { + display: flex; + gap: 0.65rem; + align-items: center; + padding: 0.75rem 1rem; + border: 1px solid rgba(255, 231, 196, 0.12); + background: rgba(255, 231, 196, 0.05); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.78rem; +} + +.status-chip strong { + color: #f7d59d; + font-size: 0.95rem; +} + +.alert-banner { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + margin-top: 1rem; + padding: 1rem 1.2rem; + border: 1px solid rgba(255, 176, 94, 0.3); + background: + linear-gradient(90deg, rgba(117, 43, 21, 0.92), rgba(48, 22, 18, 0.92)), + rgba(48, 22, 18, 0.92); +} + +.alert-kicker { + display: block; + color: #ffbf78; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.74rem; +} + +.alert-banner strong { + display: block; + margin-top: 0.25rem; + font-size: 1.15rem; + color: #fff4de; +} + +.alert-banner p { + margin: 0.35rem 0 0; + color: rgba(244, 239, 227, 0.82); +} + +.dashboard-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; } .panel { - padding: 1.25rem; - border: 1px solid rgba(243, 241, 232, 0.14); - background: rgba(29, 24, 19, 0.82); + grid-column: span 4; + padding: 1.2rem; + border: 1px solid rgba(255, 231, 196, 0.1); + background: rgba(25, 19, 16, 0.86); + box-shadow: inset 0 1px 0 rgba(255, 231, 196, 0.03); } -.panel h2 { - margin-top: 0; +.panel-highlight { + background: + linear-gradient(180deg, rgba(101, 52, 28, 0.28), rgba(25, 19, 16, 0.92)), + rgba(25, 19, 16, 0.9); +} + +.panel-log { + grid-column: span 12; +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; margin-bottom: 0.75rem; +} + +.panel-header h2 { + margin: 0; font-size: 1rem; - color: #f6d49e; + color: #f8d79f; } -.panel ul, -.panel ol { - margin: 0; - padding-left: 1.1rem; -} - -.panel li + li { - margin-top: 0.45rem; -} - -.stats { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - margin: 0; -} - -.stats div { - padding: 0.9rem; - background: rgba(243, 241, 232, 0.05); -} - -.stats dt { - color: rgba(243, 241, 232, 0.62); - font-size: 0.8rem; +.panel-header span { + color: rgba(244, 239, 227, 0.58); + font-size: 0.83rem; text-transform: uppercase; letter-spacing: 0.08em; } -.stats dd { - margin: 0.3rem 0 0; - font-size: 1.7rem; - font-weight: 700; +.stat-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; } -@media (max-width: 640px) { +.stat-strip div, +.encounter-box, +.combat-status { + padding: 0.9rem; + background: rgba(255, 245, 223, 0.04); + border: 1px solid rgba(255, 231, 196, 0.08); +} + +.stat-strip span, +.encounter-label, +.combat-status span, +.room-meta span, +.log-entry span { + display: block; + color: rgba(244, 239, 227, 0.56); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stat-strip strong, +.encounter-box strong, +.combat-status strong { + display: block; + margin-top: 0.3rem; + font-size: 1.45rem; + color: #fff2d6; +} + +.supporting-text { + margin: 0.9rem 0 0; + color: rgba(244, 239, 227, 0.76); +} + +.room-title { + margin: 0 0 0.35rem; + font-size: 1.5rem; + color: #fff2d6; +} + +.room-meta { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; + margin-top: 0.95rem; +} + +.button-row, +.enemy-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + margin-top: 1rem; +} + +.button { + border: 1px solid rgba(255, 217, 163, 0.24); + background: rgba(255, 245, 223, 0.04); + color: #f4efe3; + padding: 0.72rem 1rem; + cursor: pointer; + transition: + transform 140ms ease, + background 140ms ease, + border-color 140ms ease; +} + +.button:hover:enabled { + transform: translateY(-1px); + background: rgba(255, 217, 163, 0.09); + border-color: rgba(255, 217, 163, 0.35); +} + +.button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.button-primary { + background: linear-gradient(180deg, #c36b2d, #8d4617); + border-color: rgba(255, 217, 163, 0.32); + color: #fff4e1; +} + +.button-primary:hover:enabled { + background: linear-gradient(180deg, #d97833, #9f501b); +} + +.encounter-box, +.combat-status { + margin-top: 1rem; +} + +.move-list, +.mini-map, +.enemy-list { + display: grid; + gap: 0.75rem; +} + +.mini-map, +.enemy-list { + margin-top: 1rem; +} + +.move-card, +.map-node, +.enemy-card { + width: 100%; + text-align: left; + padding: 0.95rem; + border: 1px solid rgba(255, 231, 196, 0.08); + background: rgba(255, 245, 223, 0.04); +} + +.move-card { + cursor: pointer; + transition: + transform 140ms ease, + background 140ms ease, + border-color 140ms ease; +} + +.move-card:hover:enabled { + transform: translateY(-1px); + background: rgba(255, 217, 163, 0.09); + border-color: rgba(255, 217, 163, 0.35); +} + +.move-card:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.move-card span, +.map-node span { + display: block; + color: rgba(244, 239, 227, 0.56); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.move-card strong, +.map-node strong, +.enemy-card strong { + display: block; + margin-top: 0.3rem; + font-size: 1rem; + color: #fff2d6; +} + +.move-card em, +.enemy-card span { + display: block; + margin-top: 0.3rem; + font-style: normal; + color: rgba(244, 239, 227, 0.7); +} + +.map-node-active { + border-color: rgba(243, 186, 115, 0.55); + background: rgba(243, 186, 115, 0.12); +} + +.log-list { + display: grid; + gap: 0.75rem; + max-height: 340px; + overflow: auto; + padding-right: 0.2rem; +} + +.log-entry { + padding: 0.9rem; + border-left: 3px solid rgba(243, 186, 115, 0.7); + background: rgba(255, 245, 223, 0.04); +} + +.log-entry p { + margin: 0.35rem 0 0; + color: #f4efe3; +} + +@media (max-width: 980px) { + .panel, + .panel-log { + grid-column: span 12; + } +} + +@media (max-width: 720px) { .app-shell { - width: min(100% - 1rem, 1100px); - padding-top: 1rem; + width: min(100% - 1rem, 1200px); + padding-top: 0.75rem; } .hero, - .panel { - padding: 1rem; + .alert-banner { + flex-direction: column; + align-items: stretch; + } + + .hero-actions { + align-items: stretch; + } + + .stat-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/src/types/state.ts b/src/types/state.ts index 2933a89..d10f958 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -215,6 +215,8 @@ export type RunState = { dungeon: DungeonState; adventurerSnapshot: AdventurerState; activeCombat?: CombatState; + defeatedCreatureIds: string[]; + xpGained: number; log: LogEntry[]; pendingEffects: RuleEffect[]; };