diff --git a/src/App.tsx b/src/App.tsx index 57373d8..3a8ff8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,14 @@ import React from "react"; import { sampleContentPack } from "@/data/sampleContentPack"; import { createStartingAdventurer } from "@/rules/character"; +import { + deleteSavedRun, + getBrowserStorage, + listSavedRuns, + loadSavedRun, + saveRun, + type SavedRunSummary, +} from "@/rules/persistence"; import { createRunState, enterCurrentRoom, @@ -14,6 +22,21 @@ import { startCombatInCurrentRoom, travelCurrentExit, } from "@/rules/runState"; +import { getNextLevelXpThreshold, MAX_ADVENTURER_LEVEL } from "@/rules/progression"; +import { + getConsumableCounts, + restWithRation, + usePotion, + useScroll, +} from "@/rules/recovery"; +import { + grantDebugTreasure, + queueTreasureForSale, + sellPendingTreasure, + stashCarriedTreasure, + withdrawStashedTreasure, +} from "@/rules/townInventory"; +import { useTownService } from "@/rules/townServices"; import type { RunState } from "@/types/state"; function createDemoRun() { @@ -50,18 +73,90 @@ function getRoomTitle(run: RunState, roomId?: string) { ); } +function getTownServiceDescription(serviceId: string) { + switch (serviceId) { + case "service.healer": + return "Restore HP to full for 2 gold."; + case "service.market": + return "Buy 1 ration for 1 gold."; + case "service.tavern": + return "Recover 2 HP for 1 gold."; + default: + return "Visit this service."; + } +} + +function getItemName(definitionId: string) { + return sampleContentPack.items.find((item) => item.id === definitionId)?.name ?? definitionId; +} + +function getItemValue(definitionId: string) { + return sampleContentPack.items.find((item) => item.id === definitionId)?.valueGp ?? 0; +} + +function getManoeuvreName(manoeuvreId: string) { + return sampleContentPack.manoeuvres.find((entry) => entry.id === manoeuvreId)?.name ?? manoeuvreId; +} + +function getCombatTargetNumber(enemyArmourValue = 0) { + return 7 + enemyArmourValue; +} + function App() { const [run, setRun] = React.useState(() => createDemoRun()); + const [savedRuns, setSavedRuns] = React.useState([]); 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 inTown = run.phase === "town"; + const knownServices = sampleContentPack.townServices.filter((service) => + run.townState.knownServices.includes(service.id), + ); + const carriedTreasure = run.adventurerSnapshot.inventory.carried.filter((entry) => + sampleContentPack.items.find((item) => item.id === entry.definitionId)?.itemType === "treasure", + ); + const stashedTreasure = run.townState.stash; + const pendingSales = run.townState.pendingSales; + const pendingSaleValue = pendingSales.reduce( + (total, entry) => total + getItemValue(entry.definitionId) * entry.quantity, + 0, + ); + const consumableCounts = getConsumableCounts(run); + const latestCombatLogs = run.activeCombat?.combatLog.slice(-3).reverse() ?? []; + const nextLevelXpThreshold = + run.adventurerSnapshot.level >= MAX_ADVENTURER_LEVEL + ? undefined + : getNextLevelXpThreshold(run.adventurerSnapshot.level); + const xpToNextLevel = + nextLevelXpThreshold === undefined + ? 0 + : Math.max(0, nextLevelXpThreshold - run.adventurerSnapshot.xp); + + React.useEffect(() => { + const storage = getBrowserStorage(); + + if (!storage) { + return; + } + + setSavedRuns(listSavedRuns(storage)); + }, []); const handleReset = () => { setRun(createDemoRun()); }; + const refreshSavedRuns = React.useCallback(() => { + const storage = getBrowserStorage(); + + if (!storage) { + return; + } + + setSavedRuns(listSavedRuns(storage)); + }, []); + const handleEnterRoom = () => { setRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run); }; @@ -107,6 +202,129 @@ function App() { setRun((previous) => resumeDungeon(previous).run); }; + const handleUseTownService = (serviceId: string) => { + setRun((previous) => + useTownService({ + content: sampleContentPack, + run: previous, + serviceId, + }).run, + ); + }; + + const handleGrantTreasure = (definitionId: string) => { + setRun((previous) => + grantDebugTreasure({ + content: sampleContentPack, + run: previous, + definitionId, + }).run, + ); + }; + + const handleStashTreasure = (definitionId: string) => { + setRun((previous) => + stashCarriedTreasure({ + content: sampleContentPack, + run: previous, + definitionId, + }).run, + ); + }; + + const handleWithdrawTreasure = (definitionId: string) => { + setRun((previous) => + withdrawStashedTreasure({ + content: sampleContentPack, + run: previous, + definitionId, + }).run, + ); + }; + + const handleQueueTreasure = (definitionId: string, source: "carried" | "stash") => { + setRun((previous) => + queueTreasureForSale({ + content: sampleContentPack, + run: previous, + definitionId, + source, + }).run, + ); + }; + + const handleSellPending = () => { + setRun((previous) => + sellPendingTreasure({ + content: sampleContentPack, + run: previous, + }).run, + ); + }; + + const handleUsePotion = () => { + setRun((previous) => + usePotion({ + content: sampleContentPack, + run: previous, + definitionId: "potion.healing", + }).run, + ); + }; + + const handleUseScroll = () => { + setRun((previous) => + useScroll({ + content: sampleContentPack, + run: previous, + definitionId: "scroll.lesser-heal", + roller: () => 4, + }).run, + ); + }; + + const handleRationRest = () => { + setRun((previous) => + restWithRation({ + content: sampleContentPack, + run: previous, + definitionId: "item.ration", + }).run, + ); + }; + + const handleSaveRun = () => { + const storage = getBrowserStorage(); + + if (!storage) { + return; + } + + saveRun(storage, run); + refreshSavedRuns(); + }; + + const handleLoadRun = (saveId: string) => { + const storage = getBrowserStorage(); + + if (!storage) { + return; + } + + setRun(loadSavedRun(storage, saveId)); + refreshSavedRuns(); + }; + + const handleDeleteSave = (saveId: string) => { + const storage = getBrowserStorage(); + + if (!storage) { + return; + } + + setSavedRuns(deleteSavedRun(storage, saveId)); + }; + return (
@@ -123,6 +341,9 @@ function App() { + + +
+ Scroll + Lesser Heal +

+ Restore 2 HP on a successful cast. Carried: {consumableCounts.lesserHealScroll} +

+ +
+
+ Ration + Town Rest +

+ Spend 1 ration in town to recover 2 HP. Carried: {consumableCounts.ration} +

+ +
+ + + + +
+
+

Save Archive

+ {savedRuns.length} saves +
+ {savedRuns.length === 0 ? ( +

No saved runs yet. Save the current run to persist progress.

+ ) : ( +
+ {savedRuns.map((save) => ( +
+
+ {save.phase} + {save.label} +

+ Saved {new Date(save.savedAt).toLocaleString()} · Level {save.currentLevel} +

+
+
+ + +
+
+ ))} +
+ )}
{inTown ? ( @@ -230,6 +578,131 @@ function App() { Resume Delve +
+ {knownServices.map((service) => ( +
+
+ {service.serviceType} + {service.name} +

{getTownServiceDescription(service.id)}

+
+ +
+ ))} +
+
+
+

Treasure Ledger

+ {pendingSaleValue} gp pending +
+
+ + + +
+
+
+

Pack Treasure

+ {carriedTreasure.length === 0 ? ( +

No treasure currently in the pack.

+ ) : ( + carriedTreasure.map((entry) => ( +
+
+ {getItemName(entry.definitionId)} +

+ Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each +

+
+
+ + +
+
+ )) + )} +
+
+

Town Stash

+ {stashedTreasure.length === 0 ? ( +

No treasure stored in town.

+ ) : ( + stashedTreasure.map((entry) => ( +
+
+ {getItemName(entry.definitionId)} +

+ Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each +

+
+
+ + +
+
+ )) + )} +
+
+

Queued Sales

+ {pendingSales.length === 0 ? ( +

Nothing is queued for sale yet.

+ ) : ( + pendingSales.map((entry) => ( +
+
+ {getItemName(entry.definitionId)} +

+ Qty {entry.quantity} · {getItemValue(entry.definitionId) * entry.quantity} gp total +

+
+
+ )) + )} +
+
+
) : ( <> @@ -321,6 +794,24 @@ function App() { Acting Side {run.activeCombat.actingSide} +
+
+ Player HP + + {run.activeCombat.player.hpCurrent}/{run.activeCombat.player.hpMax} + +
+
+ Enemies Standing + + {run.activeCombat.enemies.filter((enemy) => enemy.hpCurrent > 0).length} + +
+
+ Last Roll + {run.activeCombat.lastRoll?.total ?? "-"} +
+
{run.activeCombat.enemies.map((enemy) => (
@@ -329,6 +820,7 @@ function App() { HP {enemy.hpCurrent}/{enemy.hpMax} + Target {getCombatTargetNumber(enemy.armourValue ?? 0)}
+
+ {latestCombatLogs.map((entry) => ( +
+ {entry.type} +

{entry.text}

+
+ ))} +
) : (

- No active combat. Travel until a room reveals a hostile encounter, - then engage it from the banner or room panel. + {run.lastCombatOutcome + ? "No active combat. Review the last battle above, then continue the delve or recover in town." + : "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.test.ts b/src/data/contentHelpers.test.ts index ccc4880..9eacdb1 100644 --- a/src/data/contentHelpers.test.ts +++ b/src/data/contentHelpers.test.ts @@ -4,7 +4,10 @@ import { lookupTable } from "@/rules/tables"; import { findCreatureByName, + findItemById, + findPotionById, findRoomTemplateForLookup, + findScrollById, findTableByCode, } from "./contentHelpers"; import { sampleContentPack } from "./sampleContentPack"; @@ -57,4 +60,16 @@ describe("level 1 content helpers", () => { expect(creature.id).toBe("creature.level1.guard"); expect(creature.hp).toBeGreaterThan(0); }); + + it("finds an item definition by id", () => { + const item = findItemById(sampleContentPack, "item.garnet-ring"); + + expect(item.itemType).toBe("treasure"); + expect(item.valueGp).toBe(12); + }); + + it("finds potion and scroll definitions by id", () => { + expect(findPotionById(sampleContentPack, "potion.healing").name).toBe("Healing Potion"); + expect(findScrollById(sampleContentPack, "scroll.lesser-heal").name).toBe("Lesser Heal"); + }); }); diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts index 927aa60..a043a19 100644 --- a/src/data/contentHelpers.ts +++ b/src/data/contentHelpers.ts @@ -1,7 +1,10 @@ import type { ContentPack, CreatureDefinition, + ItemDefinition, + PotionDefinition, RoomTemplate, + ScrollDefinition, TableDefinition, } from "@/types/content"; import type { TableLookupResult } from "@/rules/tables"; @@ -74,3 +77,33 @@ export function findCreatureById( return creature; } + +export function findItemById(content: ContentPack, itemId: string): ItemDefinition { + const item = content.items.find((entry) => entry.id === itemId); + + if (!item) { + throw new Error(`Unknown item id: ${itemId}`); + } + + return item; +} + +export function findPotionById(content: ContentPack, potionId: string): PotionDefinition { + const potion = content.potions.find((entry) => entry.id === potionId); + + if (!potion) { + throw new Error(`Unknown potion id: ${potionId}`); + } + + return potion; +} + +export function findScrollById(content: ContentPack, scrollId: string): ScrollDefinition { + const scroll = content.scrolls.find((entry) => entry.id === scrollId); + + if (!scroll) { + throw new Error(`Unknown scroll id: ${scrollId}`); + } + + return scroll; +} diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index b2cd348..bd2f0d5 100644 --- a/src/data/sampleContentPack.ts +++ b/src/data/sampleContentPack.ts @@ -39,7 +39,11 @@ const samplePack = { category: "melee", handedness: "one-handed", baseDamage: 1, - allowedManoeuvreIds: ["manoeuvre.exact-strike", "manoeuvre.guard-break"], + allowedManoeuvreIds: [ + "manoeuvre.exact-strike", + "manoeuvre.guard-break", + "manoeuvre.sweeping-cut", + ], tags: ["starter"], startingOption: true, }, @@ -63,6 +67,16 @@ const samplePack = { effectText: "Trades shift for a stronger hit.", mvp: true, }, + { + id: "manoeuvre.sweeping-cut", + name: "Sweeping Cut", + weaponCategories: ["melee"], + minimumLevel: 2, + shiftCost: 1, + damageModifier: 2, + effectText: "A heavier follow-through unlocked after the first level-up.", + mvp: true, + }, ], armour: [ { @@ -123,6 +137,24 @@ const samplePack = { consumable: false, mvp: true, }, + { + id: "item.silver-chalice", + name: "Silver Chalice", + itemType: "treasure", + stackable: false, + consumable: false, + valueGp: 8, + mvp: true, + }, + { + id: "item.garnet-ring", + name: "Garnet Ring", + itemType: "treasure", + stackable: false, + consumable: false, + valueGp: 12, + mvp: true, + }, ], potions: [ { @@ -279,6 +311,21 @@ const samplePack = { id: "service.market", name: "Market", serviceType: "market", + costRules: ["buy-ration:1"], + mvp: true, + }, + { + id: "service.healer", + name: "Healer", + serviceType: "healer", + costRules: ["restore-to-full:2"], + mvp: true, + }, + { + id: "service.tavern", + name: "Tavern", + serviceType: "tavern", + costRules: ["rest:1"], mvp: true, }, ], diff --git a/src/rules/character.ts b/src/rules/character.ts index 527eaba..fff3dca 100644 --- a/src/rules/character.ts +++ b/src/rules/character.ts @@ -72,7 +72,11 @@ export function createStartingAdventurer( throw new Error(`Scroll ${selectedScroll.id} is not a legal starting option.`); } - const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds; + const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds.filter((manoeuvreId) => { + const manoeuvre = requireDefinition(content.manoeuvres, manoeuvreId, "manoeuvre"); + + return (manoeuvre.minimumLevel ?? 1) <= 1; + }); if (allowedManoeuvreIds.length === 0) { throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`); diff --git a/src/rules/persistence.test.ts b/src/rules/persistence.test.ts new file mode 100644 index 0000000..ccabc54 --- /dev/null +++ b/src/rules/persistence.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { + deleteSavedRun, + loadSavedRun, + saveRun, + listSavedRuns, + type StorageLike, +} from "./persistence"; +import { createRunState, returnToTown } from "./runState"; + +function createMemoryStorage(): StorageLike { + const values = new Map(); + + return { + getItem(key) { + return values.get(key) ?? null; + }, + setItem(key, value) { + values.set(key, value); + }, + removeItem(key) { + values.delete(key); + }, + }; +} + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("run persistence", () => { + it("saves and lists runs with newest first", () => { + const storage = createMemoryStorage(); + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + saveRun(storage, run, { + saveId: "save.one", + savedAt: "2026-03-18T23:00:00.000Z", + }); + saveRun(storage, returnToTown(run).run, { + saveId: "save.two", + savedAt: "2026-03-18T23:10:00.000Z", + }); + + const saves = listSavedRuns(storage); + + expect(saves).toHaveLength(2); + expect(saves[0]?.id).toBe("save.two"); + expect(saves[0]?.phase).toBe("town"); + }); + + it("loads a saved run back into state", () => { + const storage = createMemoryStorage(); + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + saveRun(storage, run, { + saveId: "save.one", + savedAt: "2026-03-18T23:00:00.000Z", + }); + + const loaded = loadSavedRun(storage, "save.one"); + + expect(loaded.currentRoomId).toBe(run.currentRoomId); + expect(loaded.adventurerSnapshot.name).toBe("Aster"); + }); + + it("deletes saved runs", () => { + const storage = createMemoryStorage(); + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + saveRun(storage, run, { + saveId: "save.one", + savedAt: "2026-03-18T23:00:00.000Z", + }); + + const remaining = deleteSavedRun(storage, "save.one"); + + expect(remaining).toEqual([]); + expect(listSavedRuns(storage)).toEqual([]); + }); +}); diff --git a/src/rules/persistence.ts b/src/rules/persistence.ts new file mode 100644 index 0000000..13b2c11 --- /dev/null +++ b/src/rules/persistence.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; + +import { runStateSchema } from "@/schemas/state"; +import type { RunState } from "@/types/state"; + +export type StorageLike = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +export type SavedRunRecord = { + id: string; + label: string; + savedAt: string; + run: RunState; +}; + +export type SavedRunSummary = { + id: string; + label: string; + savedAt: string; + phase: RunState["phase"]; + currentLevel: number; + currentRoomId?: string; + adventurerName: string; +}; + +const STORAGE_KEY = "d2d6-dungeon.run-saves.v1"; + +const savedRunRecordSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + savedAt: z.string().min(1), + run: runStateSchema, +}); + +const savedRunRecordListSchema = z.array(savedRunRecordSchema); + +function readSaveRecords(storage: StorageLike): SavedRunRecord[] { + const raw = storage.getItem(STORAGE_KEY); + + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as unknown; + return savedRunRecordListSchema.parse(parsed); +} + +function writeSaveRecords(storage: StorageLike, records: SavedRunRecord[]) { + storage.setItem(STORAGE_KEY, JSON.stringify(records)); +} + +function toSummary(record: SavedRunRecord): SavedRunSummary { + return { + id: record.id, + label: record.label, + savedAt: record.savedAt, + phase: record.run.phase, + currentLevel: record.run.currentLevel, + currentRoomId: record.run.currentRoomId, + adventurerName: record.run.adventurerSnapshot.name, + }; +} + +export function buildSaveLabel(run: RunState) { + const roomLabel = run.currentRoomId ?? "unknown-room"; + return `${run.adventurerSnapshot.name} · L${run.currentLevel} · ${run.phase} · ${roomLabel}`; +} + +export function listSavedRuns(storage: StorageLike): SavedRunSummary[] { + return readSaveRecords(storage) + .sort((left, right) => right.savedAt.localeCompare(left.savedAt)) + .map(toSummary); +} + +export function saveRun( + storage: StorageLike, + run: RunState, + options?: { + saveId?: string; + label?: string; + savedAt?: string; + }, +): SavedRunSummary { + const savedAt = options?.savedAt ?? new Date().toISOString(); + const id = options?.saveId ?? `save.${savedAt}`; + const label = options?.label ?? buildSaveLabel(run); + const record = savedRunRecordSchema.parse({ + id, + label, + savedAt, + run, + }); + const existing = readSaveRecords(storage).filter((entry) => entry.id !== id); + + existing.unshift(record); + writeSaveRecords(storage, existing); + + return toSummary(record); +} + +export function loadSavedRun(storage: StorageLike, saveId: string): RunState { + const record = readSaveRecords(storage).find((entry) => entry.id === saveId); + + if (!record) { + throw new Error(`Unknown save id: ${saveId}`); + } + + return record.run; +} + +export function deleteSavedRun(storage: StorageLike, saveId: string): SavedRunSummary[] { + const records = readSaveRecords(storage).filter((entry) => entry.id !== saveId); + + writeSaveRecords(storage, records); + + return records + .sort((left, right) => right.savedAt.localeCompare(left.savedAt)) + .map(toSummary); +} + +export function getBrowserStorage(): StorageLike | null { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + + return window.localStorage; +} diff --git a/src/rules/progression.test.ts b/src/rules/progression.test.ts new file mode 100644 index 0000000..a2089b8 --- /dev/null +++ b/src/rules/progression.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { + applyLevelProgression, + getLevelForXp, + getNextLevelXpThreshold, + getXpThresholdForLevel, +} from "./progression"; + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("level progression rules", () => { + it("uses linear xp thresholds for the current MVP ruleset", () => { + expect(getXpThresholdForLevel(1)).toBe(0); + expect(getXpThresholdForLevel(2)).toBe(8); + expect(getXpThresholdForLevel(3)).toBe(16); + expect(getNextLevelXpThreshold(1)).toBe(8); + expect(getLevelForXp(0)).toBe(1); + expect(getLevelForXp(8)).toBe(2); + expect(getLevelForXp(16)).toBe(3); + }); + + it("levels up immediately once xp crosses a threshold", () => { + const adventurer = createAdventurer(); + adventurer.xp = 8; + adventurer.hp.current = 7; + + const result = applyLevelProgression({ + content: sampleContentPack, + adventurer, + at: "2026-03-18T10:00:00.000Z", + }); + + expect(result.adventurer.level).toBe(2); + expect(result.adventurer.hp.max).toBe(12); + expect(result.adventurer.hp.current).toBe(9); + expect(result.adventurer.manoeuvreIds).toContain("manoeuvre.sweeping-cut"); + expect(result.levelUps).toEqual([ + expect.objectContaining({ + previousLevel: 1, + newLevel: 2, + hpGained: 2, + unlockedManoeuvreIds: ["manoeuvre.sweeping-cut"], + }), + ]); + }); + + it("leaves the adventurer unchanged when no threshold is crossed", () => { + const adventurer = createAdventurer(); + adventurer.xp = 7; + + const result = applyLevelProgression({ + content: sampleContentPack, + adventurer, + }); + + expect(result.adventurer.level).toBe(1); + expect(result.adventurer.hp.max).toBe(10); + expect(result.levelUps).toEqual([]); + }); +}); diff --git a/src/rules/progression.ts b/src/rules/progression.ts new file mode 100644 index 0000000..9fbac7e --- /dev/null +++ b/src/rules/progression.ts @@ -0,0 +1,122 @@ +import type { ContentPack } from "@/types/content"; +import type { AdventurerState, LevelUpState } from "@/types/state"; + +export const XP_PER_LEVEL = 8; +export const HP_GAIN_PER_LEVEL = 2; +export const MAX_ADVENTURER_LEVEL = 10; + +export type ApplyLevelProgressionOptions = { + content: ContentPack; + adventurer: AdventurerState; + at?: string; +}; + +export type LevelProgressionResult = { + adventurer: AdventurerState; + levelUps: LevelUpState[]; +}; + +export function getXpThresholdForLevel(level: number) { + if (level <= 1) { + return 0; + } + + return (level - 1) * XP_PER_LEVEL; +} + +export function getNextLevelXpThreshold(level: number) { + return getXpThresholdForLevel(level + 1); +} + +export function getLevelForXp(xp: number) { + if (xp < 0) { + return 1; + } + + return Math.min(MAX_ADVENTURER_LEVEL, Math.floor(xp / XP_PER_LEVEL) + 1); +} + +function getUnlockedWeaponManoeuvreIds(content: ContentPack, adventurer: AdventurerState, level: number) { + const weapon = content.weapons.find((entry) => entry.id === adventurer.weaponId); + + if (!weapon) { + throw new Error(`Unknown weapon id: ${adventurer.weaponId}`); + } + + return weapon.allowedManoeuvreIds.filter((manoeuvreId) => { + const manoeuvre = content.manoeuvres.find((entry) => entry.id === manoeuvreId); + + if (!manoeuvre) { + throw new Error(`Unknown manoeuvre id: ${manoeuvreId}`); + } + + return (manoeuvre.minimumLevel ?? 1) <= level; + }); +} + +export function applyLevelProgression( + options: ApplyLevelProgressionOptions, +): LevelProgressionResult { + const nextAdventurer: AdventurerState = { + ...options.adventurer, + hp: { ...options.adventurer.hp }, + stats: { ...options.adventurer.stats }, + favour: { ...options.adventurer.favour }, + statuses: options.adventurer.statuses.map((status) => ({ ...status })), + inventory: { + carried: options.adventurer.inventory.carried.map((entry) => ({ ...entry })), + equipped: options.adventurer.inventory.equipped.map((entry) => ({ ...entry })), + stored: options.adventurer.inventory.stored.map((entry) => ({ ...entry })), + currency: { ...options.adventurer.inventory.currency }, + rationCount: options.adventurer.inventory.rationCount, + lightSources: options.adventurer.inventory.lightSources.map((entry) => ({ ...entry })), + }, + progressionFlags: [...options.adventurer.progressionFlags], + manoeuvreIds: [...options.adventurer.manoeuvreIds], + }; + const targetLevel = getLevelForXp(nextAdventurer.xp); + const at = options.at ?? new Date().toISOString(); + const levelUps: LevelUpState[] = []; + + while (nextAdventurer.level < targetLevel) { + const previousLevel = nextAdventurer.level; + const newLevel = previousLevel + 1; + + nextAdventurer.level = newLevel; + nextAdventurer.hp.max += HP_GAIN_PER_LEVEL; + nextAdventurer.hp.current = Math.min( + nextAdventurer.hp.max, + nextAdventurer.hp.current + HP_GAIN_PER_LEVEL, + ); + + const unlockedManoeuvreIds = getUnlockedWeaponManoeuvreIds( + options.content, + nextAdventurer, + newLevel, + ).filter((manoeuvreId) => !nextAdventurer.manoeuvreIds.includes(manoeuvreId)); + + nextAdventurer.manoeuvreIds.push(...unlockedManoeuvreIds); + + const levelFlag = `level.reached.${newLevel}`; + if (!nextAdventurer.progressionFlags.includes(levelFlag)) { + nextAdventurer.progressionFlags.push(levelFlag); + } + + levelUps.push({ + previousLevel, + newLevel, + at, + hpGained: HP_GAIN_PER_LEVEL, + unlockedManoeuvreIds, + summary: + unlockedManoeuvreIds.length > 0 + ? `Reached level ${newLevel}, gained ${HP_GAIN_PER_LEVEL} max HP, and unlocked ${unlockedManoeuvreIds.length} manoeuvre${unlockedManoeuvreIds.length === 1 ? "" : "s"}.` + : `Reached level ${newLevel} and gained ${HP_GAIN_PER_LEVEL} max HP.`, + }); + } + + return { + adventurer: nextAdventurer, + levelUps, + }; +} diff --git a/src/rules/recovery.test.ts b/src/rules/recovery.test.ts new file mode 100644 index 0000000..f27f464 --- /dev/null +++ b/src/rules/recovery.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { createRunState, returnToTown } from "./runState"; +import { getConsumableCounts, restWithRation, usePotion, useScroll } from "./recovery"; + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("recovery and consumables", () => { + it("uses a healing potion and restores hp", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.hp.current = 6; + + const result = usePotion({ + content: sampleContentPack, + run, + definitionId: "potion.healing", + at: "2026-03-18T22:00:00.000Z", + }); + + expect(result.run.adventurerSnapshot.hp.current).toBe(9); + expect(getConsumableCounts(result.run).healingPotion).toBe(0); + expect(result.run.log.at(-1)?.text).toContain("recovered 3 HP"); + }); + + it("casts a healing scroll and consumes it on success", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.hp.current = 7; + + const result = useScroll({ + content: sampleContentPack, + run, + definitionId: "scroll.lesser-heal", + roller: () => 5, + at: "2026-03-18T22:05:00.000Z", + }); + + expect(result.run.adventurerSnapshot.hp.current).toBe(9); + expect(getConsumableCounts(result.run).lesserHealScroll).toBe(0); + expect(result.run.log.at(-1)?.text).toContain("roll 5"); + }); + + it("uses a ration in town to recover hp and reduce rations", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.hp.current = 5; + const inTown = returnToTown(run, "2026-03-18T22:10:00.000Z").run; + const result = restWithRation({ + content: sampleContentPack, + run: inTown, + definitionId: "item.ration", + at: "2026-03-18T22:12:00.000Z", + }); + + expect(result.run.adventurerSnapshot.hp.current).toBe(7); + expect(result.run.adventurerSnapshot.inventory.rationCount).toBe(2); + expect(getConsumableCounts(result.run).ration).toBe(2); + }); +}); diff --git a/src/rules/recovery.ts b/src/rules/recovery.ts new file mode 100644 index 0000000..3836bbe --- /dev/null +++ b/src/rules/recovery.ts @@ -0,0 +1,270 @@ +import { + findItemById, + findPotionById, + findScrollById, +} from "@/data/contentHelpers"; +import type { ContentPack, PotionDefinition } from "@/types/content"; +import type { InventoryEntry, RunState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +import type { DiceRoller } from "./dice"; + +export type UseRecoveryResourceOptions = { + content: ContentPack; + run: RunState; + definitionId: string; + roller?: DiceRoller; + at?: string; +}; + +export type RecoveryActionResult = { + run: RunState; + logEntries: LogEntry[]; +}; + +function cloneRun(run: RunState): RunState { + return { + ...run, + adventurerSnapshot: { + ...run.adventurerSnapshot, + hp: { ...run.adventurerSnapshot.hp }, + stats: { ...run.adventurerSnapshot.stats }, + favour: { ...run.adventurerSnapshot.favour }, + statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })), + inventory: { + ...run.adventurerSnapshot.inventory, + carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })), + equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })), + stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })), + currency: { ...run.adventurerSnapshot.inventory.currency }, + lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })), + }, + progressionFlags: [...run.adventurerSnapshot.progressionFlags], + manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds], + }, + townState: { + ...run.townState, + knownServices: [...run.townState.knownServices], + stash: run.townState.stash.map((entry) => ({ ...entry })), + pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...run.townState.serviceFlags], + }, + activeCombat: run.activeCombat + ? { + ...run.activeCombat, + player: { + ...run.activeCombat.player, + statuses: [...run.activeCombat.player.statuses], + traits: [...run.activeCombat.player.traits], + }, + enemies: run.activeCombat.enemies.map((enemy) => ({ + ...enemy, + statuses: [...enemy.statuses], + traits: [...enemy.traits], + })), + combatLog: run.activeCombat.combatLog.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + } + : undefined, + defeatedCreatureIds: [...run.defeatedCreatureIds], + log: run.log.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), + }; +} + +function findCarriedEntry(entries: InventoryEntry[], definitionId: string) { + return entries.find((entry) => entry.definitionId === definitionId); +} + +function consumeCarriedEntry(entries: InventoryEntry[], definitionId: string, quantity = 1) { + const existing = findCarriedEntry(entries, definitionId); + + if (!existing || existing.quantity < quantity) { + throw new Error(`No carried ${definitionId} is available to consume.`); + } + + existing.quantity -= quantity; + + if (existing.quantity === 0) { + const index = entries.indexOf(existing); + entries.splice(index, 1); + } +} + +function healAdventurer(run: RunState, amount: number) { + const current = run.adventurerSnapshot.hp.current; + const max = run.adventurerSnapshot.hp.max; + const healed = Math.max(0, Math.min(amount, max - current)); + + run.adventurerSnapshot.hp.current = current + healed; + + if (run.activeCombat) { + run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current; + } + + return healed; +} + +function createLogEntry( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "progression", + text, + relatedIds, + }; +} + +function canUsePotion(run: RunState, potion: PotionDefinition) { + if (run.activeCombat) { + return potion.useTiming === "combat" || potion.useTiming === "any"; + } + + if (run.phase === "town") { + return potion.useTiming === "town" || potion.useTiming === "any"; + } + + return potion.useTiming === "exploration" || potion.useTiming === "any"; +} + +function applyHealEffects(run: RunState, effects: { type: string; amount?: number }[]) { + return effects.reduce((total, effect) => { + if (effect.type !== "heal") { + return total; + } + + return total + healAdventurer(run, effect.amount ?? 0); + }, 0); +} + +export function usePotion( + options: UseRecoveryResourceOptions, +): RecoveryActionResult { + const run = cloneRun(options.run); + const potion = findPotionById(options.content, options.definitionId); + + if (!canUsePotion(run, potion)) { + throw new Error(`${potion.name} cannot be used in the current phase.`); + } + + consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, potion.id); + + const healed = applyHealEffects(run, potion.effects); + const at = options.at ?? new Date().toISOString(); + const phaseLabel = run.phase === "town" ? "in town" : run.activeCombat ? "during combat" : "while exploring"; + const logEntry = createLogEntry( + `recovery.potion.${potion.id}.${run.log.length + 1}`, + at, + `Used ${potion.name} ${phaseLabel} and recovered ${healed} HP.`, + [potion.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function useScroll( + options: UseRecoveryResourceOptions, +): RecoveryActionResult { + const run = cloneRun(options.run); + const scroll = findScrollById(options.content, options.definitionId); + const carriedEntry = findCarriedEntry(run.adventurerSnapshot.inventory.carried, scroll.id); + + if (!carriedEntry) { + throw new Error(`No carried ${scroll.name} is available to use.`); + } + + const at = options.at ?? new Date().toISOString(); + const roll = scroll.castCheck ? (options.roller ?? (() => 1))(6) : undefined; + const succeeded = + !scroll.castCheck || + ((scroll.castCheck.successMin === undefined || roll! >= scroll.castCheck.successMin) && + (scroll.castCheck.successMax === undefined || roll! <= scroll.castCheck.successMax)); + + consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, scroll.id); + + const healed = succeeded ? applyHealEffects(run, scroll.onSuccess) : 0; + const rollText = scroll.castCheck ? ` (roll ${roll})` : ""; + const outcomeText = succeeded + ? `Cast ${scroll.name}${rollText} and recovered ${healed} HP.` + : `Cast ${scroll.name}${rollText}, but the spell failed.`; + const logEntry = createLogEntry( + `recovery.scroll.${scroll.id}.${run.log.length + 1}`, + at, + outcomeText, + [scroll.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function restWithRation( + options: UseRecoveryResourceOptions, +): RecoveryActionResult { + const run = cloneRun(options.run); + + if (run.phase !== "town") { + throw new Error("Ration rest is only available while in town."); + } + + if (run.activeCombat) { + throw new Error("Cannot rest with a ration during active combat."); + } + + const ration = findItemById(options.content, "item.ration"); + + if (ration.itemType !== "ration") { + throw new Error("Configured ration item is invalid."); + } + + consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, ration.id); + run.adventurerSnapshot.inventory.rationCount = Math.max( + 0, + run.adventurerSnapshot.inventory.rationCount - 1, + ); + + const healed = healAdventurer(run, 2); + const at = options.at ?? new Date().toISOString(); + const logEntry = createLogEntry( + `recovery.ration-rest.${run.log.length + 1}`, + at, + `Shared a ration in town and recovered ${healed} HP.`, + [ration.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function getConsumableCounts(run: RunState) { + const carried = run.adventurerSnapshot.inventory.carried; + + return { + ration: findCarriedEntry(carried, "item.ration")?.quantity ?? 0, + healingPotion: findCarriedEntry(carried, "potion.healing")?.quantity ?? 0, + lesserHealScroll: findCarriedEntry(carried, "scroll.lesser-heal")?.quantity ?? 0, + }; +} diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts index 35f1583..322e191 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -191,9 +191,99 @@ describe("run state flow", () => { expect(result.run.adventurerSnapshot.xp).toBe(2); expect(result.run.xpGained).toBe(2); expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]); + expect(result.run.lastCombatOutcome?.result).toBe("victory"); + expect(result.run.lastCombatOutcome?.xpAwarded).toBe(2); expect(result.run.log.at(-1)?.text).toContain("Victory rewards"); }); + it("applies an immediate level-up when combat rewards cross the xp threshold", () => { + const adventurer = createAdventurer(); + adventurer.xp = 7; + + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer, + at: "2026-03-15T14:00:00.000Z", + }); + const levelState = run.dungeon.levels["1"]!; + const room = levelState.rooms["room.level1.start"]!; + + room.encounter = { + id: "encounter.start.guard", + sourceTableCode: "L1G", + creatureIds: ["creature.level1.guard"], + resultLabel: "Guard", + creatureNames: ["Guard"], + resolved: true, + }; + room.discovery.entered = true; + + const withCombat = startCombatInCurrentRoom({ + content: sampleContentPack, + run, + at: "2026-03-15T14:02:00.000Z", + }).run; + withCombat.activeCombat!.enemies[0]!.hpCurrent = 1; + + const result = resolveRunPlayerTurn({ + content: sampleContentPack, + run: withCombat, + manoeuvreId: "manoeuvre.guard-break", + targetEnemyId: withCombat.activeCombat!.enemies[0]!.id, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T14:03:00.000Z", + }); + + expect(result.run.adventurerSnapshot.level).toBe(2); + expect(result.run.adventurerSnapshot.hp.max).toBe(12); + expect(result.run.adventurerSnapshot.xp).toBe(9); + expect(result.run.adventurerSnapshot.manoeuvreIds).toContain("manoeuvre.sweeping-cut"); + expect(result.run.lastLevelUp?.newLevel).toBe(2); + expect(result.run.log.at(-1)?.text).toContain("Reached level 2"); + }); + + it("records a defeat outcome when the enemy drops the adventurer", () => { + 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"], + creatureNames: ["Warrior"], + resultLabel: "Warrior", + resolved: true, + }; + + const withCombat = startCombatInCurrentRoom({ + content: sampleContentPack, + run, + at: "2026-03-15T14:02:00.000Z", + }).run; + + withCombat.activeCombat!.actingSide = "enemy"; + withCombat.activeCombat!.player.hpCurrent = 1; + withCombat.adventurerSnapshot.hp.current = 1; + + const result = resolveRunEnemyTurn({ + content: sampleContentPack, + run: withCombat, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T14:03:00.000Z", + }); + + expect(result.run.status).toBe("failed"); + expect(result.run.lastCombatOutcome?.result).toBe("defeat"); + expect(result.run.lastCombatOutcome?.summary).toContain("defeated"); + expect(result.run.log.at(-1)?.text).toContain("defeated"); + }); + it("lists available traversable exits for the current room", () => { const run = createRunState({ content: sampleContentPack, diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 072a7de..0e9eaff 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -9,6 +9,8 @@ import type { LogEntry } from "@/types/rules"; import { findCreatureById } from "@/data/contentHelpers"; import { startCombatFromRoom } from "./combat"; +import { createInitialTownState } from "./townServices"; +import { applyLevelProgression } from "./progression"; import { resolveEnemyTurn, resolvePlayerAttack, @@ -179,6 +181,25 @@ function cloneRun(run: RunState): RunState { globalFlags: [...run.dungeon.globalFlags], }, activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined, + lastCombatOutcome: run.lastCombatOutcome + ? { + ...run.lastCombatOutcome, + enemyNames: [...run.lastCombatOutcome.enemyNames], + } + : undefined, + lastLevelUp: run.lastLevelUp + ? { + ...run.lastLevelUp, + unlockedManoeuvreIds: [...run.lastLevelUp.unlockedManoeuvreIds], + } + : undefined, + townState: { + ...run.townState, + knownServices: [...run.townState.knownServices], + stash: run.townState.stash.map((entry) => ({ ...entry })), + pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...run.townState.serviceFlags], + }, log: run.log.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, @@ -280,19 +301,56 @@ function applyCombatRewards( run.defeatedCreatureIds.push(...defeatedCreatureIds); run.xpGained += xpAwarded; run.adventurerSnapshot.xp += xpAwarded; + const progression = applyLevelProgression({ + content, + adventurer: run.adventurerSnapshot, + at, + }); + run.adventurerSnapshot = progression.adventurer; + run.lastLevelUp = progression.levelUps.at(-1); + run.lastCombatOutcome = { + result: "victory", + at, + summary: `Won combat against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")} and gained ${xpAwarded} XP.`, + enemyNames: completedCombat.enemies.map((enemy) => enemy.name), + xpAwarded, + }; - if (xpAwarded === 0) { - return [] as LogEntry[]; - } + const rewardLogs = + xpAwarded === 0 + ? [ + createRewardLog( + `${completedCombat.id}.victory`, + at, + `Combat victory secured against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")}.`, + [completedCombat.id], + ), + ] + : [ + createRewardLog( + `${completedCombat.id}.victory`, + at, + `Combat victory secured against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")}.`, + [completedCombat.id], + ), + createRewardLog( + `${completedCombat.id}.rewards`, + at, + `Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, + [completedCombat.id, ...defeatedCreatureIds], + ), + ]; - return [ + const levelLogs = progression.levelUps.map((levelUp, index) => createRewardLog( - `${completedCombat.id}.rewards`, + `${completedCombat.id}.level-up.${index + 1}`, at, - `Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, - [completedCombat.id, ...defeatedCreatureIds], + levelUp.summary, + [completedCombat.id, run.adventurerSnapshot.id, ...levelUp.unlockedManoeuvreIds], ), - ]; + ); + + return [...rewardLogs, ...levelLogs]; } export function createRunState(options: CreateRunOptions): RunState { @@ -320,6 +378,9 @@ export function createRunState(options: CreateRunOptions): RunState { globalFlags: [], }, adventurerSnapshot: options.adventurer, + lastCombatOutcome: undefined, + lastLevelUp: undefined, + townState: createInitialTownState(), defeatedCreatureIds: [], xpGained: 0, log: [], @@ -339,6 +400,7 @@ export function returnToTown( nextRun.phase = "town"; nextRun.lastTownAt = at; + nextRun.townState.visits += 1; const logEntry = createLogEntry( `run.return-to-town.${nextRun.log.length + 1}`, @@ -546,6 +608,7 @@ export function startCombatInCurrentRoom( levelState.rooms[roomId] = started.room; run.activeCombat = started.combat; + run.lastCombatOutcome = undefined; appendLogs(run, started.logEntries); return { @@ -635,6 +698,25 @@ export function resolveRunEnemyTurn( if (result.combatEnded) { run.status = "failed"; + run.lastCombatOutcome = { + result: "defeat", + at: options.at ?? new Date().toISOString(), + summary: `${run.adventurerSnapshot.name} was defeated by ${result.combat.enemies + .filter((enemy) => enemy.hpCurrent > 0) + .map((enemy) => enemy.name) + .join(", ")}.`, + enemyNames: result.combat.enemies + .filter((enemy) => enemy.hpCurrent > 0) + .map((enemy) => enemy.name), + }; + appendLogs(run, [ + createRewardLog( + `${result.combat.id}.defeat`, + options.at ?? new Date().toISOString(), + run.lastCombatOutcome.summary, + [result.combat.id, run.adventurerSnapshot.id], + ), + ]); } return { diff --git a/src/rules/townInventory.test.ts b/src/rules/townInventory.test.ts new file mode 100644 index 0000000..30a5201 --- /dev/null +++ b/src/rules/townInventory.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { createRunState, returnToTown } from "./runState"; +import { + grantDebugTreasure, + queueTreasureForSale, + sellPendingTreasure, + stashCarriedTreasure, + withdrawStashedTreasure, +} from "./townInventory"; + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +function createTownRun() { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + return returnToTown(run, "2026-03-18T21:00:00.000Z").run; +} + +describe("town inventory loop", () => { + it("stores carried treasure in the town stash", () => { + const inTown = createTownRun(); + inTown.adventurerSnapshot.inventory.carried.push({ + definitionId: "item.silver-chalice", + quantity: 1, + }); + + const result = stashCarriedTreasure({ + content: sampleContentPack, + run: inTown, + definitionId: "item.silver-chalice", + at: "2026-03-18T21:05:00.000Z", + }); + + expect(result.run.adventurerSnapshot.inventory.carried).not.toEqual( + expect.arrayContaining([expect.objectContaining({ definitionId: "item.silver-chalice" })]), + ); + expect(result.run.townState.stash).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.silver-chalice", + quantity: 1, + }), + ]), + ); + }); + + it("withdraws stashed treasure back to the pack", () => { + const inTown = createTownRun(); + inTown.townState.stash.push({ + definitionId: "item.garnet-ring", + quantity: 1, + }); + + const result = withdrawStashedTreasure({ + content: sampleContentPack, + run: inTown, + definitionId: "item.garnet-ring", + at: "2026-03-18T21:06:00.000Z", + }); + + expect(result.run.townState.stash).toHaveLength(0); + expect(result.run.adventurerSnapshot.inventory.carried).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.garnet-ring", + quantity: 1, + }), + ]), + ); + }); + + it("queues treasure for sale and converts it into gold", () => { + const inTown = createTownRun(); + const withTreasure = grantDebugTreasure({ + content: sampleContentPack, + run: inTown, + definitionId: "item.garnet-ring", + quantity: 2, + at: "2026-03-18T21:07:00.000Z", + }).run; + + const queued = queueTreasureForSale({ + content: sampleContentPack, + run: withTreasure, + definitionId: "item.garnet-ring", + quantity: 2, + source: "carried", + at: "2026-03-18T21:08:00.000Z", + }).run; + + const sold = sellPendingTreasure({ + content: sampleContentPack, + run: queued, + at: "2026-03-18T21:09:00.000Z", + }).run; + + expect(queued.townState.pendingSales).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.garnet-ring", + quantity: 2, + }), + ]), + ); + expect(sold.townState.pendingSales).toHaveLength(0); + expect(sold.adventurerSnapshot.inventory.currency.gold).toBe( + withTreasure.adventurerSnapshot.inventory.currency.gold + 24, + ); + expect(sold.log.at(-1)?.text).toContain("24 gold"); + }); +}); diff --git a/src/rules/townInventory.ts b/src/rules/townInventory.ts new file mode 100644 index 0000000..cd076bd --- /dev/null +++ b/src/rules/townInventory.ts @@ -0,0 +1,293 @@ +import { findItemById } from "@/data/contentHelpers"; +import type { ContentPack } from "@/types/content"; +import type { InventoryEntry, RunState, TownState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +export type TownInventoryResult = { + run: RunState; + logEntries: LogEntry[]; +}; + +export type TownInventoryActionOptions = { + content: ContentPack; + run: RunState; + definitionId: string; + quantity?: number; + at?: string; +}; + +export type SellPendingTreasureOptions = { + content: ContentPack; + run: RunState; + at?: string; +}; + +function cloneTownState(townState: TownState): TownState { + return { + ...townState, + knownServices: [...townState.knownServices], + stash: townState.stash.map((entry) => ({ ...entry })), + pendingSales: townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...townState.serviceFlags], + }; +} + +function cloneRun(run: RunState): RunState { + return { + ...run, + adventurerSnapshot: { + ...run.adventurerSnapshot, + hp: { ...run.adventurerSnapshot.hp }, + stats: { ...run.adventurerSnapshot.stats }, + favour: { ...run.adventurerSnapshot.favour }, + statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })), + inventory: { + ...run.adventurerSnapshot.inventory, + carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })), + equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })), + stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })), + currency: { ...run.adventurerSnapshot.inventory.currency }, + lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })), + }, + progressionFlags: [...run.adventurerSnapshot.progressionFlags], + manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds], + }, + townState: cloneTownState(run.townState), + defeatedCreatureIds: [...run.defeatedCreatureIds], + log: run.log.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), + }; +} + +function requireTownPhase(run: RunState) { + if (run.phase !== "town") { + throw new Error("Town inventory actions are only available while the run is in town."); + } +} + +function addEntry(entries: InventoryEntry[], definitionId: string, quantity: number) { + const existing = entries.find((entry) => entry.definitionId === definitionId); + + if (existing) { + existing.quantity += quantity; + return; + } + + entries.push({ + definitionId, + quantity, + }); +} + +function removeEntry(entries: InventoryEntry[], definitionId: string, quantity: number) { + const existing = entries.find((entry) => entry.definitionId === definitionId); + + if (!existing || existing.quantity < quantity) { + throw new Error(`Not enough ${definitionId} available for this action.`); + } + + existing.quantity -= quantity; + + if (existing.quantity === 0) { + const index = entries.indexOf(existing); + entries.splice(index, 1); + } +} + +function resolveQuantity(quantity?: number) { + if (!quantity) { + return 1; + } + + if (!Number.isInteger(quantity) || quantity <= 0) { + throw new Error(`Invalid quantity requested: ${quantity}`); + } + + return quantity; +} + +function requireTreasureItem(content: ContentPack, definitionId: string) { + const item = findItemById(content, definitionId); + + if (item.itemType !== "treasure") { + throw new Error(`${item.name} is not eligible for the town treasure loop.`); + } + + return item; +} + +function createTownLog( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "town", + text, + relatedIds, + }; +} + +export function stashCarriedTreasure( + options: TownInventoryActionOptions, +): TownInventoryResult { + const run = cloneRun(options.run); + + requireTownPhase(run); + + const item = requireTreasureItem(options.content, options.definitionId); + const quantity = resolveQuantity(options.quantity); + const at = options.at ?? new Date().toISOString(); + + removeEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity); + addEntry(run.townState.stash, item.id, quantity); + + const logEntry = createTownLog( + `town.stash.${item.id}.${run.log.length + 1}`, + at, + `Stored ${quantity} ${item.name}${quantity === 1 ? "" : "s"} in the town stash.`, + [item.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function withdrawStashedTreasure( + options: TownInventoryActionOptions, +): TownInventoryResult { + const run = cloneRun(options.run); + + requireTownPhase(run); + + const item = requireTreasureItem(options.content, options.definitionId); + const quantity = resolveQuantity(options.quantity); + const at = options.at ?? new Date().toISOString(); + + removeEntry(run.townState.stash, item.id, quantity); + addEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity); + + const logEntry = createTownLog( + `town.withdraw.${item.id}.${run.log.length + 1}`, + at, + `Withdrew ${quantity} ${item.name}${quantity === 1 ? "" : "s"} from the town stash.`, + [item.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function queueTreasureForSale( + options: TownInventoryActionOptions & { + source: "carried" | "stash"; + }, +): TownInventoryResult { + const run = cloneRun(options.run); + + requireTownPhase(run); + + const item = requireTreasureItem(options.content, options.definitionId); + const quantity = resolveQuantity(options.quantity); + const at = options.at ?? new Date().toISOString(); + const sourceEntries = + options.source === "carried" + ? run.adventurerSnapshot.inventory.carried + : run.townState.stash; + + removeEntry(sourceEntries, item.id, quantity); + addEntry(run.townState.pendingSales, item.id, quantity); + + const sourceLabel = options.source === "carried" ? "pack" : "stash"; + const logEntry = createTownLog( + `town.sale-queue.${item.id}.${run.log.length + 1}`, + at, + `Queued ${quantity} ${item.name}${quantity === 1 ? "" : "s"} from the ${sourceLabel} for sale.`, + [item.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function sellPendingTreasure( + options: SellPendingTreasureOptions, +): TownInventoryResult { + const run = cloneRun(options.run); + + requireTownPhase(run); + + if (run.townState.pendingSales.length === 0) { + throw new Error("There is no treasure queued for sale."); + } + + const at = options.at ?? new Date().toISOString(); + const totalGold = run.townState.pendingSales.reduce((total, entry) => { + const item = requireTreasureItem(options.content, entry.definitionId); + return total + (item.valueGp ?? 0) * entry.quantity; + }, 0); + const soldIds = run.townState.pendingSales.map((entry) => entry.definitionId); + + run.adventurerSnapshot.inventory.currency.gold += totalGold; + run.townState.pendingSales = []; + + const logEntry = createTownLog( + `town.sell-pending.${run.log.length + 1}`, + at, + `Sold queued treasure for ${totalGold} gold.`, + soldIds, + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} + +export function grantDebugTreasure( + options: TownInventoryActionOptions, +): TownInventoryResult { + const run = cloneRun(options.run); + + requireTownPhase(run); + + const item = requireTreasureItem(options.content, options.definitionId); + const quantity = resolveQuantity(options.quantity); + const at = options.at ?? new Date().toISOString(); + + addEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity); + + const logEntry = createTownLog( + `town.debug-grant.${item.id}.${run.log.length + 1}`, + at, + `Debug grant: added ${quantity} ${item.name}${quantity === 1 ? "" : "s"} to the pack.`, + [item.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} diff --git a/src/rules/townServices.test.ts b/src/rules/townServices.test.ts new file mode 100644 index 0000000..fc5f70a --- /dev/null +++ b/src/rules/townServices.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { createRunState, returnToTown } from "./runState"; +import { useTownService } from "./townServices"; + +function createAdventurer() { + return createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); +} + +describe("town services", () => { + it("heals the adventurer to full at the healer", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.hp.current = 3; + run.adventurerSnapshot.inventory.currency.gold = 3; + + const inTown = returnToTown(run, "2026-03-18T21:00:00.000Z").run; + const result = useTownService({ + content: sampleContentPack, + run: inTown, + serviceId: "service.healer", + at: "2026-03-18T21:05:00.000Z", + }); + + expect(result.run.adventurerSnapshot.hp.current).toBe( + result.run.adventurerSnapshot.hp.max, + ); + expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(1); + expect(result.run.log.at(-1)?.text).toContain("restored the party to full health"); + }); + + it("buys a ration at the market", () => { + const run = createRunState({ + content: sampleContentPack, + campaignId: "campaign.1", + adventurer: createAdventurer(), + }); + + run.adventurerSnapshot.inventory.currency.gold = 2; + + const inTown = returnToTown(run).run; + const result = useTownService({ + content: sampleContentPack, + run: inTown, + serviceId: "service.market", + }); + + expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(1); + expect(result.run.adventurerSnapshot.inventory.rationCount).toBe(4); + expect(result.run.adventurerSnapshot.inventory.carried).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + definitionId: "item.ration", + quantity: 4, + }), + ]), + ); + }); +}); diff --git a/src/rules/townServices.ts b/src/rules/townServices.ts new file mode 100644 index 0000000..42d73ec --- /dev/null +++ b/src/rules/townServices.ts @@ -0,0 +1,172 @@ +import type { ContentPack, TownServiceDefinition } from "@/types/content"; +import type { InventoryEntry, RunState, TownState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +export type UseTownServiceOptions = { + content: ContentPack; + run: RunState; + serviceId: string; + at?: string; +}; + +export type TownServiceResult = { + run: RunState; + logEntries: LogEntry[]; +}; + +function cloneTownState(townState: TownState): TownState { + return { + ...townState, + knownServices: [...townState.knownServices], + stash: townState.stash.map((entry) => ({ ...entry })), + pendingSales: townState.pendingSales.map((entry) => ({ ...entry })), + serviceFlags: [...townState.serviceFlags], + }; +} + +function cloneRun(run: RunState): RunState { + return { + ...run, + adventurerSnapshot: { + ...run.adventurerSnapshot, + hp: { ...run.adventurerSnapshot.hp }, + stats: { ...run.adventurerSnapshot.stats }, + favour: { ...run.adventurerSnapshot.favour }, + statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })), + inventory: { + ...run.adventurerSnapshot.inventory, + carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })), + equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })), + stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })), + currency: { ...run.adventurerSnapshot.inventory.currency }, + lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })), + }, + progressionFlags: [...run.adventurerSnapshot.progressionFlags], + manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds], + }, + townState: cloneTownState(run.townState), + defeatedCreatureIds: [...run.defeatedCreatureIds], + log: run.log.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), + }; +} + +function findTownService(content: ContentPack, serviceId: string): TownServiceDefinition { + const service = content.townServices.find((candidate) => candidate.id === serviceId); + + if (!service) { + throw new Error(`Unknown town service id: ${serviceId}`); + } + + return service; +} + +function parseCost(service: TownServiceDefinition) { + const rawRule = service.costRules?.[0]; + const amount = rawRule ? Number(rawRule.split(":")[1] ?? 0) : 0; + + return Number.isFinite(amount) ? amount : 0; +} + +function addCarriedEntry(entries: InventoryEntry[], definitionId: string, quantity = 1) { + const existing = entries.find((entry) => entry.definitionId === definitionId); + + if (existing) { + existing.quantity += quantity; + return; + } + + entries.push({ + definitionId, + quantity, + }); +} + +function createTownLog( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "town", + text, + relatedIds, + }; +} + +export function createInitialTownState(): TownState { + return { + visits: 0, + knownServices: ["service.market", "service.healer", "service.tavern"], + stash: [], + pendingSales: [], + serviceFlags: [], + }; +} + +export function useTownService(options: UseTownServiceOptions): TownServiceResult { + const run = cloneRun(options.run); + + if (run.phase !== "town") { + throw new Error("Town services are only available while the run is in town."); + } + + if (!run.townState.knownServices.includes(options.serviceId)) { + throw new Error(`Service ${options.serviceId} is not available in town.`); + } + + const service = findTownService(options.content, options.serviceId); + const at = options.at ?? new Date().toISOString(); + const cost = parseCost(service); + + if (run.adventurerSnapshot.inventory.currency.gold < cost) { + throw new Error(`Not enough gold to use ${service.name}.`); + } + + run.adventurerSnapshot.inventory.currency.gold -= cost; + run.townState.serviceFlags.push(`${service.id}.used`); + + let text = `${service.name} was used.`; + + switch (service.serviceType) { + case "healer": + run.adventurerSnapshot.hp.current = run.adventurerSnapshot.hp.max; + text = `${service.name} restored the party to full health for ${cost} gold.`; + break; + case "market": + addCarriedEntry(run.adventurerSnapshot.inventory.carried, "item.ration", 1); + run.adventurerSnapshot.inventory.rationCount += 1; + text = `${service.name} supplied 1 ration for ${cost} gold.`; + break; + case "tavern": + run.adventurerSnapshot.hp.current = Math.min( + run.adventurerSnapshot.hp.max, + run.adventurerSnapshot.hp.current + 2, + ); + text = `${service.name} provided a warm meal and 2 HP of recovery for ${cost} gold.`; + break; + default: + text = `${service.name} was visited for ${cost} gold.`; + break; + } + + const logEntry = createTownLog( + `town.service.${service.id}.${run.log.length + 1}`, + at, + text, + [service.id], + ); + + run.log.push(logEntry); + + return { + run, + logEntries: [logEntry], + }; +} diff --git a/src/schemas/content.ts b/src/schemas/content.ts index b4f9c48..8823556 100644 --- a/src/schemas/content.ts +++ b/src/schemas/content.ts @@ -47,6 +47,7 @@ export const manoeuvreDefinitionSchema = z.object({ id: z.string().min(1), name: z.string().min(1), weaponCategories: z.array(z.enum(["melee", "ranged"])), + minimumLevel: z.number().int().positive().optional(), shiftCost: z.number().int().optional(), disciplineModifier: z.number().int().optional(), precisionModifier: z.number().int().optional(), diff --git a/src/schemas/state.ts b/src/schemas/state.ts index 3fc0b04..e838d32 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -204,6 +204,23 @@ export const combatStateSchema = z.object({ combatLog: z.array(logEntrySchema), }); +export const combatOutcomeStateSchema = z.object({ + result: z.enum(["victory", "defeat"]), + at: z.string().min(1), + summary: z.string().min(1), + enemyNames: z.array(z.string()), + xpAwarded: z.number().int().nonnegative().optional(), +}); + +export const levelUpStateSchema = z.object({ + previousLevel: z.number().int().positive(), + newLevel: z.number().int().positive(), + at: z.string().min(1), + hpGained: z.number().int().nonnegative(), + unlockedManoeuvreIds: z.array(z.string()), + summary: z.string().min(1), +}); + export const runStateSchema = z.object({ id: z.string().min(1), campaignId: z.string().min(1), @@ -216,6 +233,9 @@ export const runStateSchema = z.object({ dungeon: dungeonStateSchema, adventurerSnapshot: adventurerStateSchema, activeCombat: combatStateSchema.optional(), + lastCombatOutcome: combatOutcomeStateSchema.optional(), + lastLevelUp: levelUpStateSchema.optional(), + townState: townStateSchema, defeatedCreatureIds: z.array(z.string()), xpGained: z.number().int().nonnegative(), log: z.array(logEntrySchema), diff --git a/src/styles.css b/src/styles.css index 02793a0..750a173 100644 --- a/src/styles.css +++ b/src/styles.css @@ -111,6 +111,27 @@ select { rgba(48, 22, 18, 0.92); } +.alert-banner-victory { + background: + linear-gradient(90deg, rgba(45, 93, 54, 0.92), rgba(26, 46, 29, 0.92)), + rgba(26, 46, 29, 0.92); + border-color: rgba(125, 219, 150, 0.28); +} + +.alert-banner-defeat { + background: + linear-gradient(90deg, rgba(113, 33, 33, 0.92), rgba(48, 18, 18, 0.92)), + rgba(48, 18, 18, 0.92); + border-color: rgba(232, 123, 123, 0.3); +} + +.alert-banner-level { + background: + linear-gradient(90deg, rgba(133, 87, 24, 0.92), rgba(59, 38, 14, 0.92)), + rgba(59, 38, 14, 0.92); + border-color: rgba(244, 205, 120, 0.32); +} + .alert-kicker { display: block; color: #ffbf78; @@ -156,6 +177,10 @@ select { grid-column: span 12; } +.panel-saves { + grid-column: span 8; +} + .panel-town-hub { grid-column: span 8; } @@ -183,7 +208,7 @@ select { .stat-strip { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.75rem; } @@ -228,6 +253,80 @@ select { margin-top: 1rem; } +.town-services { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.recovery-panel { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(255, 231, 196, 0.12); +} + +.recovery-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.recovery-card { + padding: 1rem; + border: 1px solid rgba(255, 231, 196, 0.1); + background: rgba(255, 245, 223, 0.04); +} + +.recovery-card strong { + display: block; + margin-top: 0.25rem; + color: #fff2d6; + font-size: 1.05rem; +} + +.town-ledger { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(255, 231, 196, 0.12); +} + +.town-ledger-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.town-ledger-card { + padding: 1rem; + border: 1px solid rgba(255, 231, 196, 0.1); + background: rgba(255, 245, 223, 0.04); +} + +.town-ledger-card h3 { + margin: 0 0 0.75rem; + color: #fff2d6; + font-size: 1.05rem; +} + +.town-service-card { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 1rem; + border: 1px solid rgba(255, 231, 196, 0.1); + background: rgba(255, 245, 223, 0.04); +} + +.town-service-card strong { + display: block; + margin-top: 0.25rem; + color: #fff2d6; + font-size: 1.05rem; +} + .room-title { margin: 0 0 0.35rem; font-size: 1.5rem; @@ -249,6 +348,26 @@ select { margin-top: 1rem; } +.treasure-row { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 0.75rem 0; + border-top: 1px solid rgba(255, 231, 196, 0.08); +} + +.treasure-row:first-of-type { + border-top: 0; + padding-top: 0; +} + +.treasure-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + .button { border: 1px solid rgba(255, 217, 163, 0.24); background: rgba(255, 245, 223, 0.04); @@ -287,9 +406,23 @@ select { margin-top: 1rem; } +.combat-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.combat-feed { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + .move-list, .mini-map, -.enemy-list { +.enemy-list, +.save-list { display: grid; gap: 0.75rem; } @@ -301,7 +434,8 @@ select { .move-card, .map-node, -.enemy-card { +.enemy-card, +.save-card { width: 100%; text-align: left; padding: 0.95rem; @@ -347,13 +481,28 @@ select { } .move-card em, -.enemy-card span { +.enemy-card span, +.save-card span { display: block; margin-top: 0.3rem; font-style: normal; color: rgba(244, 239, 227, 0.7); } +.save-card strong { + display: block; + margin-top: 0.3rem; + font-size: 1rem; + color: #fff2d6; +} + +.save-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + flex-wrap: wrap; +} + .map-node-active { border-color: rgba(243, 186, 115, 0.55); background: rgba(243, 186, 115, 0.12); @@ -405,7 +554,23 @@ select { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .combat-summary-grid, .town-summary-grid { grid-template-columns: 1fr; } + + .town-service-card { + flex-direction: column; + align-items: stretch; + } + + .recovery-grid, + .town-ledger-grid { + grid-template-columns: 1fr; + } + + .treasure-row { + flex-direction: column; + align-items: stretch; + } } diff --git a/src/types/content.ts b/src/types/content.ts index a8d715c..691fa56 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -52,6 +52,7 @@ export type ManoeuvreDefinition = { id: string; name: string; weaponCategories: WeaponCategory[]; + minimumLevel?: number; shiftCost?: number; disciplineModifier?: number; precisionModifier?: number; diff --git a/src/types/state.ts b/src/types/state.ts index d5e128d..4c26405 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -205,6 +205,23 @@ export type CombatState = { combatLog: LogEntry[]; }; +export type CombatOutcomeState = { + result: "victory" | "defeat"; + at: string; + summary: string; + enemyNames: string[]; + xpAwarded?: number; +}; + +export type LevelUpState = { + previousLevel: number; + newLevel: number; + at: string; + hpGained: number; + unlockedManoeuvreIds: string[]; + summary: string; +}; + export type RunState = { id: string; campaignId: string; @@ -217,6 +234,9 @@ export type RunState = { dungeon: DungeonState; adventurerSnapshot: AdventurerState; activeCombat?: CombatState; + lastCombatOutcome?: CombatOutcomeState; + lastLevelUp?: LevelUpState; + townState: TownState; defeatedCreatureIds: string[]; xpGained: number; log: LogEntry[];