{run.adventurerSnapshot.name} is equipped with a{" "} @@ -156,6 +172,9 @@ function App() { )?.name ?? "weapon"} .
++ Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated. +
2D6 Dungeon Web
-- The core loop now runs inside the app: move through generated rooms, - resolve room state on entry, and fight when a room produces a real - encounter. + Traverse generated rooms, auto-resolve room entry, and engage combat + when a room reveals a real encounter.
+ This room contains a combat-ready encounter. Engage now to enter + tactical resolution. +
+{run.adventurerSnapshot.name} is equipped with a{" "} @@ -156,6 +172,9 @@ function App() { )?.name ?? "weapon"} .
++ Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated. +
- No active combat. Travel through the dungeon until a room generates - a creature encounter, then start combat from that room. + No active combat. Travel until a room reveals a hostile encounter, + then engage it from the banner or room panel.
)} 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 b23853c..6e38b36 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -7,6 +7,7 @@ import { createRunState, enterCurrentRoom, getAvailableMoves, + isCurrentRoomCombatReady, resolveRunEnemyTurn, resolveRunPlayerTurn, startCombatInCurrentRoom, @@ -184,6 +185,73 @@ 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); }); it("lists available traversable exits for the current room", () => { diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 9d0c84f..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 { @@ -232,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({ @@ -256,6 +303,8 @@ export function createRunState(options: CreateRunOptions): RunState { globalFlags: [], }, adventurerSnapshot: options.adventurer, + defeatedCreatureIds: [], + xpGained: 0, log: [], pendingEffects: [], }; @@ -298,6 +347,16 @@ export function getAvailableMoves(run: RunState): AvailableMove[] { })); } +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 { @@ -427,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; @@ -437,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 d069556..a2d350a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -98,6 +98,39 @@ select { 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(12, minmax(0, 1fr)); @@ -244,17 +277,20 @@ select { } .move-list, -.mini-map { +.mini-map, +.enemy-list { display: grid; gap: 0.75rem; } -.mini-map { +.mini-map, +.enemy-list { margin-top: 1rem; } .move-card, -.map-node { +.map-node, +.enemy-card { width: 100%; text-align: left; padding: 0.95rem; @@ -291,14 +327,16 @@ select { } .move-card strong, -.map-node strong { +.map-node strong, +.enemy-card strong { display: block; margin-top: 0.3rem; font-size: 1rem; color: #fff2d6; } -.move-card em { +.move-card em, +.enemy-card span { display: block; margin-top: 0.3rem; font-style: normal; @@ -310,28 +348,6 @@ select { background: rgba(243, 186, 115, 0.12); } -.enemy-list { - display: grid; - gap: 0.75rem; - margin-top: 1rem; -} - -.enemy-card { - padding: 0.95rem; - border: 1px solid rgba(255, 231, 196, 0.08); - background: rgba(255, 245, 223, 0.04); -} - -.enemy-card strong { - display: block; - font-size: 1.05rem; - color: #fff2d6; -} - -.enemy-card span { - color: rgba(244, 239, 227, 0.66); -} - .log-list { display: grid; gap: 0.75rem; @@ -364,7 +380,8 @@ select { padding-top: 0.75rem; } - .hero { + .hero, + .alert-banner { flex-direction: column; align-items: stretch; } 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[]; };