Merge branch 'feature/level-progression'
This commit is contained in:
569
src/App.tsx
569
src/App.tsx
@@ -2,6 +2,14 @@ import React from "react";
|
|||||||
|
|
||||||
import { sampleContentPack } from "@/data/sampleContentPack";
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
import { createStartingAdventurer } from "@/rules/character";
|
import { createStartingAdventurer } from "@/rules/character";
|
||||||
|
import {
|
||||||
|
deleteSavedRun,
|
||||||
|
getBrowserStorage,
|
||||||
|
listSavedRuns,
|
||||||
|
loadSavedRun,
|
||||||
|
saveRun,
|
||||||
|
type SavedRunSummary,
|
||||||
|
} from "@/rules/persistence";
|
||||||
import {
|
import {
|
||||||
createRunState,
|
createRunState,
|
||||||
enterCurrentRoom,
|
enterCurrentRoom,
|
||||||
@@ -9,9 +17,26 @@ import {
|
|||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
|
resumeDungeon,
|
||||||
|
returnToTown,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
} from "@/rules/runState";
|
} 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";
|
import type { RunState } from "@/types/state";
|
||||||
|
|
||||||
function createDemoRun() {
|
function createDemoRun() {
|
||||||
@@ -48,17 +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() {
|
function App() {
|
||||||
const [run, setRun] = React.useState<RunState>(() => createDemoRun());
|
const [run, setRun] = React.useState<RunState>(() => createDemoRun());
|
||||||
|
const [savedRuns, setSavedRuns] = React.useState<SavedRunSummary[]>([]);
|
||||||
const currentLevel = run.dungeon.levels[run.currentLevel];
|
const currentLevel = run.dungeon.levels[run.currentLevel];
|
||||||
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
|
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
|
||||||
const availableMoves = getAvailableMoves(run);
|
const availableMoves = getAvailableMoves(run);
|
||||||
const combatReadyEncounter = isCurrentRoomCombatReady(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 = () => {
|
const handleReset = () => {
|
||||||
setRun(createDemoRun());
|
setRun(createDemoRun());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshSavedRuns = React.useCallback(() => {
|
||||||
|
const storage = getBrowserStorage();
|
||||||
|
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavedRuns(listSavedRuns(storage));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEnterRoom = () => {
|
const handleEnterRoom = () => {
|
||||||
setRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run);
|
setRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run);
|
||||||
};
|
};
|
||||||
@@ -96,6 +194,137 @@ function App() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReturnToTown = () => {
|
||||||
|
setRun((previous) => returnToTown(previous).run);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeDungeon = () => {
|
||||||
|
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 (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
@@ -112,14 +341,24 @@ function App() {
|
|||||||
<button className="button button-primary" onClick={handleReset}>
|
<button className="button button-primary" onClick={handleReset}>
|
||||||
Reset Demo Run
|
Reset Demo Run
|
||||||
</button>
|
</button>
|
||||||
|
<button className="button" onClick={handleSaveRun}>
|
||||||
|
Save Run
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
|
||||||
|
disabled={Boolean(run.activeCombat)}
|
||||||
|
>
|
||||||
|
{inTown ? "Resume Dungeon" : "Return To Town"}
|
||||||
|
</button>
|
||||||
<div className="status-chip">
|
<div className="status-chip">
|
||||||
<span>Run Status</span>
|
<span>Run Phase</span>
|
||||||
<strong>{run.status}</strong>
|
<strong>{run.phase}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{combatReadyEncounter && !run.activeCombat ? (
|
{combatReadyEncounter && !run.activeCombat && !inTown ? (
|
||||||
<section className="alert-banner">
|
<section className="alert-banner">
|
||||||
<div>
|
<div>
|
||||||
<span className="alert-kicker">Encounter Ready</span>
|
<span className="alert-kicker">Encounter Ready</span>
|
||||||
@@ -135,6 +374,38 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{run.lastCombatOutcome ? (
|
||||||
|
<section
|
||||||
|
className={`alert-banner ${run.lastCombatOutcome.result === "victory" ? "alert-banner-victory" : "alert-banner-defeat"}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="alert-kicker">
|
||||||
|
{run.lastCombatOutcome.result === "victory" ? "Last Victory" : "Last Defeat"}
|
||||||
|
</span>
|
||||||
|
<strong>{run.lastCombatOutcome.summary}</strong>
|
||||||
|
<p>
|
||||||
|
{run.lastCombatOutcome.enemyNames.length > 0
|
||||||
|
? `Opposition: ${run.lastCombatOutcome.enemyNames.join(", ")}.`
|
||||||
|
: "No enemy details recorded."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{run.lastLevelUp ? (
|
||||||
|
<section className="alert-banner alert-banner-level">
|
||||||
|
<div>
|
||||||
|
<span className="alert-kicker">Latest Level Up</span>
|
||||||
|
<strong>{run.lastLevelUp.summary}</strong>
|
||||||
|
<p>
|
||||||
|
{run.lastLevelUp.unlockedManoeuvreIds.length > 0
|
||||||
|
? `Unlocked: ${run.lastLevelUp.unlockedManoeuvreIds.map(getManoeuvreName).join(", ")}.`
|
||||||
|
: "No additional manoeuvres unlocked on that level."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="dashboard-grid">
|
<section className="dashboard-grid">
|
||||||
<article className="panel panel-highlight">
|
<article className="panel panel-highlight">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -164,6 +435,14 @@ function App() {
|
|||||||
<span>XP</span>
|
<span>XP</span>
|
||||||
<strong>{run.adventurerSnapshot.xp}</strong>
|
<strong>{run.adventurerSnapshot.xp}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Next Level</span>
|
||||||
|
<strong>
|
||||||
|
{nextLevelXpThreshold === undefined
|
||||||
|
? "Max"
|
||||||
|
: `${xpToNextLevel} to go`}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="supporting-text">
|
<p className="supporting-text">
|
||||||
{run.adventurerSnapshot.name} is equipped with a{" "}
|
{run.adventurerSnapshot.name} is equipped with a{" "}
|
||||||
@@ -175,8 +454,258 @@ function App() {
|
|||||||
<p className="supporting-text">
|
<p className="supporting-text">
|
||||||
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
|
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="supporting-text">
|
||||||
|
{nextLevelXpThreshold === undefined
|
||||||
|
? `${run.adventurerSnapshot.name} has reached the current maximum supported level.`
|
||||||
|
: `Next level at ${nextLevelXpThreshold} XP. Current manoeuvres: ${run.adventurerSnapshot.manoeuvreIds.map(getManoeuvreName).join(", ")}.`}
|
||||||
|
</p>
|
||||||
|
<p className="supporting-text">
|
||||||
|
{inTown
|
||||||
|
? `The party is currently in town${run.lastTownAt ? ` as of ${new Date(run.lastTownAt).toLocaleString()}` : ""}.`
|
||||||
|
: "The party is still delving below ground."}
|
||||||
|
</p>
|
||||||
|
<div className="recovery-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Recovery Kit</h2>
|
||||||
|
<span>{inTown ? "Town-ready" : "Field-ready"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="recovery-grid">
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Potion</span>
|
||||||
|
<strong>Healing Potion</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Restore 3 HP. Carried: {consumableCounts.healingPotion}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={handleUsePotion}
|
||||||
|
disabled={consumableCounts.healingPotion === 0}
|
||||||
|
>
|
||||||
|
Use Potion
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Scroll</span>
|
||||||
|
<strong>Lesser Heal</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Restore 2 HP on a successful cast. Carried: {consumableCounts.lesserHealScroll}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={handleUseScroll}
|
||||||
|
disabled={consumableCounts.lesserHealScroll === 0}
|
||||||
|
>
|
||||||
|
Cast Scroll
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Ration</span>
|
||||||
|
<strong>Town Rest</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Spend 1 ration in town to recover 2 HP. Carried: {consumableCounts.ration}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={handleRationRest}
|
||||||
|
disabled={!inTown || consumableCounts.ration === 0}
|
||||||
|
>
|
||||||
|
Eat And Rest
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article className="panel panel-saves">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Save Archive</h2>
|
||||||
|
<span>{savedRuns.length} saves</span>
|
||||||
|
</div>
|
||||||
|
{savedRuns.length === 0 ? (
|
||||||
|
<p className="supporting-text">No saved runs yet. Save the current run to persist progress.</p>
|
||||||
|
) : (
|
||||||
|
<div className="save-list">
|
||||||
|
{savedRuns.map((save) => (
|
||||||
|
<article key={save.id} className="save-card">
|
||||||
|
<div>
|
||||||
|
<span className="encounter-label">{save.phase}</span>
|
||||||
|
<strong>{save.label}</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Saved {new Date(save.savedAt).toLocaleString()} · Level {save.currentLevel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="save-actions">
|
||||||
|
<button className="button" onClick={() => handleLoadRun(save.id)}>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button className="button" onClick={() => handleDeleteSave(save.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{inTown ? (
|
||||||
|
<article className="panel panel-town-hub">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Town Hub</h2>
|
||||||
|
<span>Between Delves</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="room-title">Safe Harbor</h3>
|
||||||
|
<p className="supporting-text">
|
||||||
|
You are out of the dungeon. Review the current expedition, catch your breath, and
|
||||||
|
then resume the delve from the same level when you are ready.
|
||||||
|
</p>
|
||||||
|
<div className="town-summary-grid">
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Current Gold</span>
|
||||||
|
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Rooms Found</span>
|
||||||
|
<strong>{currentLevel?.discoveredRoomOrder.length ?? 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Foes Defeated</span>
|
||||||
|
<strong>{run.defeatedCreatureIds.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button className="button button-primary" onClick={handleResumeDungeon}>
|
||||||
|
Resume Delve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="town-services">
|
||||||
|
{knownServices.map((service) => (
|
||||||
|
<article key={service.id} className="town-service-card">
|
||||||
|
<div>
|
||||||
|
<span className="encounter-label">{service.serviceType}</span>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<p className="supporting-text">{getTownServiceDescription(service.id)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseTownService(service.id)}
|
||||||
|
>
|
||||||
|
Use Service
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="town-ledger">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Treasure Ledger</h2>
|
||||||
|
<span>{pendingSaleValue} gp pending</span>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleGrantTreasure("item.silver-chalice")}
|
||||||
|
>
|
||||||
|
Debug: Add Chalice
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleGrantTreasure("item.garnet-ring")}
|
||||||
|
>
|
||||||
|
Debug: Add Ring
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button button-primary"
|
||||||
|
onClick={handleSellPending}
|
||||||
|
disabled={pendingSales.length === 0}
|
||||||
|
>
|
||||||
|
Sell Queued Treasure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="town-ledger-grid">
|
||||||
|
<article className="town-ledger-card">
|
||||||
|
<h3>Pack Treasure</h3>
|
||||||
|
{carriedTreasure.length === 0 ? (
|
||||||
|
<p className="supporting-text">No treasure currently in the pack.</p>
|
||||||
|
) : (
|
||||||
|
carriedTreasure.map((entry) => (
|
||||||
|
<div key={`carried-${entry.definitionId}`} className="treasure-row">
|
||||||
|
<div>
|
||||||
|
<strong>{getItemName(entry.definitionId)}</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="treasure-actions">
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleStashTreasure(entry.definitionId)}
|
||||||
|
>
|
||||||
|
Stash
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleQueueTreasure(entry.definitionId, "carried")}
|
||||||
|
>
|
||||||
|
Queue Sale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="town-ledger-card">
|
||||||
|
<h3>Town Stash</h3>
|
||||||
|
{stashedTreasure.length === 0 ? (
|
||||||
|
<p className="supporting-text">No treasure stored in town.</p>
|
||||||
|
) : (
|
||||||
|
stashedTreasure.map((entry) => (
|
||||||
|
<div key={`stash-${entry.definitionId}`} className="treasure-row">
|
||||||
|
<div>
|
||||||
|
<strong>{getItemName(entry.definitionId)}</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Qty {entry.quantity} · {getItemValue(entry.definitionId)} gp each
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="treasure-actions">
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleWithdrawTreasure(entry.definitionId)}
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleQueueTreasure(entry.definitionId, "stash")}
|
||||||
|
>
|
||||||
|
Queue Sale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="town-ledger-card">
|
||||||
|
<h3>Queued Sales</h3>
|
||||||
|
{pendingSales.length === 0 ? (
|
||||||
|
<p className="supporting-text">Nothing is queued for sale yet.</p>
|
||||||
|
) : (
|
||||||
|
pendingSales.map((entry) => (
|
||||||
|
<div key={`sale-${entry.definitionId}`} className="treasure-row">
|
||||||
|
<div>
|
||||||
|
<strong>{getItemName(entry.definitionId)}</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Qty {entry.quantity} · {getItemValue(entry.definitionId) * entry.quantity} gp total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Current Room</h2>
|
<h2>Current Room</h2>
|
||||||
@@ -265,6 +794,24 @@ function App() {
|
|||||||
<span>Acting Side</span>
|
<span>Acting Side</span>
|
||||||
<strong>{run.activeCombat.actingSide}</strong>
|
<strong>{run.activeCombat.actingSide}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="combat-summary-grid">
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Player HP</span>
|
||||||
|
<strong>
|
||||||
|
{run.activeCombat.player.hpCurrent}/{run.activeCombat.player.hpMax}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Enemies Standing</span>
|
||||||
|
<strong>
|
||||||
|
{run.activeCombat.enemies.filter((enemy) => enemy.hpCurrent > 0).length}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Last Roll</span>
|
||||||
|
<strong>{run.activeCombat.lastRoll?.total ?? "-"}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="enemy-list">
|
<div className="enemy-list">
|
||||||
{run.activeCombat.enemies.map((enemy) => (
|
{run.activeCombat.enemies.map((enemy) => (
|
||||||
<div key={enemy.id} className="enemy-card">
|
<div key={enemy.id} className="enemy-card">
|
||||||
@@ -273,6 +820,7 @@ function App() {
|
|||||||
<span>
|
<span>
|
||||||
HP {enemy.hpCurrent}/{enemy.hpMax}
|
HP {enemy.hpCurrent}/{enemy.hpMax}
|
||||||
</span>
|
</span>
|
||||||
|
<span>Target {getCombatTargetNumber(enemy.armourValue ?? 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="enemy-actions">
|
<div className="enemy-actions">
|
||||||
<button
|
<button
|
||||||
@@ -310,14 +858,25 @@ function App() {
|
|||||||
Resolve Enemy Turn
|
Resolve Enemy Turn
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="combat-feed">
|
||||||
|
{latestCombatLogs.map((entry) => (
|
||||||
|
<article key={entry.id} className="log-entry">
|
||||||
|
<span>{entry.type}</span>
|
||||||
|
<p>{entry.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="supporting-text">
|
<p className="supporting-text">
|
||||||
No active combat. Travel until a room reveals a hostile encounter,
|
{run.lastCombatOutcome
|
||||||
then engage it from the banner or room panel.
|
? "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."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<article className="panel panel-log">
|
<article className="panel panel-log">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { lookupTable } from "@/rules/tables";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
findCreatureByName,
|
findCreatureByName,
|
||||||
|
findItemById,
|
||||||
|
findPotionById,
|
||||||
findRoomTemplateForLookup,
|
findRoomTemplateForLookup,
|
||||||
|
findScrollById,
|
||||||
findTableByCode,
|
findTableByCode,
|
||||||
} from "./contentHelpers";
|
} from "./contentHelpers";
|
||||||
import { sampleContentPack } from "./sampleContentPack";
|
import { sampleContentPack } from "./sampleContentPack";
|
||||||
@@ -57,4 +60,16 @@ describe("level 1 content helpers", () => {
|
|||||||
expect(creature.id).toBe("creature.level1.guard");
|
expect(creature.id).toBe("creature.level1.guard");
|
||||||
expect(creature.hp).toBeGreaterThan(0);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
ContentPack,
|
ContentPack,
|
||||||
CreatureDefinition,
|
CreatureDefinition,
|
||||||
|
ItemDefinition,
|
||||||
|
PotionDefinition,
|
||||||
RoomTemplate,
|
RoomTemplate,
|
||||||
|
ScrollDefinition,
|
||||||
TableDefinition,
|
TableDefinition,
|
||||||
} from "@/types/content";
|
} from "@/types/content";
|
||||||
import type { TableLookupResult } from "@/rules/tables";
|
import type { TableLookupResult } from "@/rules/tables";
|
||||||
@@ -74,3 +77,33 @@ export function findCreatureById(
|
|||||||
|
|
||||||
return creature;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ const samplePack = {
|
|||||||
category: "melee",
|
category: "melee",
|
||||||
handedness: "one-handed",
|
handedness: "one-handed",
|
||||||
baseDamage: 1,
|
baseDamage: 1,
|
||||||
allowedManoeuvreIds: ["manoeuvre.exact-strike", "manoeuvre.guard-break"],
|
allowedManoeuvreIds: [
|
||||||
|
"manoeuvre.exact-strike",
|
||||||
|
"manoeuvre.guard-break",
|
||||||
|
"manoeuvre.sweeping-cut",
|
||||||
|
],
|
||||||
tags: ["starter"],
|
tags: ["starter"],
|
||||||
startingOption: true,
|
startingOption: true,
|
||||||
},
|
},
|
||||||
@@ -63,6 +67,16 @@ const samplePack = {
|
|||||||
effectText: "Trades shift for a stronger hit.",
|
effectText: "Trades shift for a stronger hit.",
|
||||||
mvp: true,
|
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: [
|
armour: [
|
||||||
{
|
{
|
||||||
@@ -123,6 +137,24 @@ const samplePack = {
|
|||||||
consumable: false,
|
consumable: false,
|
||||||
mvp: true,
|
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: [
|
potions: [
|
||||||
{
|
{
|
||||||
@@ -279,6 +311,21 @@ const samplePack = {
|
|||||||
id: "service.market",
|
id: "service.market",
|
||||||
name: "Market",
|
name: "Market",
|
||||||
serviceType: "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,
|
mvp: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -72,7 +72,11 @@ export function createStartingAdventurer(
|
|||||||
throw new Error(`Scroll ${selectedScroll.id} is not a legal starting option.`);
|
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) {
|
if (allowedManoeuvreIds.length === 0) {
|
||||||
throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`);
|
throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`);
|
||||||
|
|||||||
102
src/rules/persistence.test.ts
Normal file
102
src/rules/persistence.test.ts
Normal file
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
130
src/rules/persistence.ts
Normal file
130
src/rules/persistence.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
71
src/rules/progression.test.ts
Normal file
71
src/rules/progression.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
src/rules/progression.ts
Normal file
122
src/rules/progression.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/rules/recovery.test.ts
Normal file
82
src/rules/recovery.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
270
src/rules/recovery.ts
Normal file
270
src/rules/recovery.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
|
resumeDungeon,
|
||||||
|
returnToTown,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
} from "./runState";
|
} from "./runState";
|
||||||
@@ -44,6 +46,7 @@ describe("run state flow", () => {
|
|||||||
|
|
||||||
expect(run.currentLevel).toBe(1);
|
expect(run.currentLevel).toBe(1);
|
||||||
expect(run.currentRoomId).toBe("room.level1.start");
|
expect(run.currentRoomId).toBe("room.level1.start");
|
||||||
|
expect(run.phase).toBe("dungeon");
|
||||||
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +191,99 @@ describe("run state flow", () => {
|
|||||||
expect(result.run.adventurerSnapshot.xp).toBe(2);
|
expect(result.run.adventurerSnapshot.xp).toBe(2);
|
||||||
expect(result.run.xpGained).toBe(2);
|
expect(result.run.xpGained).toBe(2);
|
||||||
expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]);
|
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");
|
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", () => {
|
it("lists available traversable exits for the current room", () => {
|
||||||
const run = createRunState({
|
const run = createRunState({
|
||||||
content: sampleContentPack,
|
content: sampleContentPack,
|
||||||
@@ -254,22 +347,7 @@ describe("run state flow", () => {
|
|||||||
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists available traversable exits for the current room", () => {
|
it("returns to town and later resumes the dungeon", () => {
|
||||||
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({
|
const run = createRunState({
|
||||||
content: sampleContentPack,
|
content: sampleContentPack,
|
||||||
campaignId: "campaign.1",
|
campaignId: "campaign.1",
|
||||||
@@ -277,22 +355,13 @@ describe("run state flow", () => {
|
|||||||
at: "2026-03-15T14:00:00.000Z",
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = travelCurrentExit({
|
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
|
||||||
content: sampleContentPack,
|
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
|
||||||
run,
|
|
||||||
exitDirection: "north",
|
|
||||||
roller: createSequenceRoller([1, 1]),
|
|
||||||
at: "2026-03-15T14:05:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.run.currentRoomId).toBe("room.level1.room.002");
|
expect(inTown.phase).toBe("town");
|
||||||
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
|
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
|
||||||
"room.level1.start",
|
expect(getAvailableMoves(inTown)).toEqual([]);
|
||||||
"room.level1.room.002",
|
expect(resumed.phase).toBe("dungeon");
|
||||||
]);
|
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
|
||||||
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(result.run.log[0]?.text).toContain("Travelled north");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { LogEntry } from "@/types/rules";
|
|||||||
import { findCreatureById } from "@/data/contentHelpers";
|
import { findCreatureById } from "@/data/contentHelpers";
|
||||||
|
|
||||||
import { startCombatFromRoom } from "./combat";
|
import { startCombatFromRoom } from "./combat";
|
||||||
|
import { createInitialTownState } from "./townServices";
|
||||||
|
import { applyLevelProgression } from "./progression";
|
||||||
import {
|
import {
|
||||||
resolveEnemyTurn,
|
resolveEnemyTurn,
|
||||||
resolvePlayerAttack,
|
resolvePlayerAttack,
|
||||||
@@ -82,6 +84,22 @@ export type RunTransitionResult = {
|
|||||||
logEntries: LogEntry[];
|
logEntries: LogEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createLogEntry(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
type: LogEntry["type"],
|
||||||
|
text: string,
|
||||||
|
relatedIds?: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cloneCombat(combat: CombatState): CombatState {
|
function cloneCombat(combat: CombatState): CombatState {
|
||||||
return {
|
return {
|
||||||
...combat,
|
...combat,
|
||||||
@@ -163,6 +181,25 @@ function cloneRun(run: RunState): RunState {
|
|||||||
globalFlags: [...run.dungeon.globalFlags],
|
globalFlags: [...run.dungeon.globalFlags],
|
||||||
},
|
},
|
||||||
activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined,
|
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) => ({
|
log: run.log.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||||
@@ -264,12 +301,38 @@ function applyCombatRewards(
|
|||||||
run.defeatedCreatureIds.push(...defeatedCreatureIds);
|
run.defeatedCreatureIds.push(...defeatedCreatureIds);
|
||||||
run.xpGained += xpAwarded;
|
run.xpGained += xpAwarded;
|
||||||
run.adventurerSnapshot.xp += 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) {
|
const rewardLogs =
|
||||||
return [] as LogEntry[];
|
xpAwarded === 0
|
||||||
}
|
? [
|
||||||
|
createRewardLog(
|
||||||
return [
|
`${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(
|
createRewardLog(
|
||||||
`${completedCombat.id}.rewards`,
|
`${completedCombat.id}.rewards`,
|
||||||
at,
|
at,
|
||||||
@@ -277,6 +340,17 @@ function applyCombatRewards(
|
|||||||
[completedCombat.id, ...defeatedCreatureIds],
|
[completedCombat.id, ...defeatedCreatureIds],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const levelLogs = progression.levelUps.map((levelUp, index) =>
|
||||||
|
createRewardLog(
|
||||||
|
`${completedCombat.id}.level-up.${index + 1}`,
|
||||||
|
at,
|
||||||
|
levelUp.summary,
|
||||||
|
[completedCombat.id, run.adventurerSnapshot.id, ...levelUp.unlockedManoeuvreIds],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...rewardLogs, ...levelLogs];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRunState(options: CreateRunOptions): RunState {
|
export function createRunState(options: CreateRunOptions): RunState {
|
||||||
@@ -290,6 +364,7 @@ export function createRunState(options: CreateRunOptions): RunState {
|
|||||||
id: options.runId ?? "run.active",
|
id: options.runId ?? "run.active",
|
||||||
campaignId: options.campaignId,
|
campaignId: options.campaignId,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
phase: "dungeon",
|
||||||
startedAt: at,
|
startedAt: at,
|
||||||
currentLevel: 1,
|
currentLevel: 1,
|
||||||
currentRoomId: "room.level1.start",
|
currentRoomId: "room.level1.start",
|
||||||
@@ -303,6 +378,9 @@ export function createRunState(options: CreateRunOptions): RunState {
|
|||||||
globalFlags: [],
|
globalFlags: [],
|
||||||
},
|
},
|
||||||
adventurerSnapshot: options.adventurer,
|
adventurerSnapshot: options.adventurer,
|
||||||
|
lastCombatOutcome: undefined,
|
||||||
|
lastLevelUp: undefined,
|
||||||
|
townState: createInitialTownState(),
|
||||||
defeatedCreatureIds: [],
|
defeatedCreatureIds: [],
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
log: [],
|
log: [],
|
||||||
@@ -310,10 +388,69 @@ export function createRunState(options: CreateRunOptions): RunState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function returnToTown(
|
||||||
|
run: RunState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): RunTransitionResult {
|
||||||
|
const nextRun = cloneRun(run);
|
||||||
|
|
||||||
|
if (nextRun.activeCombat) {
|
||||||
|
throw new Error("Cannot return to town during active combat.");
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRun.phase = "town";
|
||||||
|
nextRun.lastTownAt = at;
|
||||||
|
nextRun.townState.visits += 1;
|
||||||
|
|
||||||
|
const logEntry = createLogEntry(
|
||||||
|
`run.return-to-town.${nextRun.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
"town",
|
||||||
|
`Returned to town from level ${nextRun.currentLevel}.`,
|
||||||
|
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
appendLogs(nextRun, [logEntry]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: nextRun,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeDungeon(
|
||||||
|
run: RunState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): RunTransitionResult {
|
||||||
|
const nextRun = cloneRun(run);
|
||||||
|
|
||||||
|
nextRun.phase = "dungeon";
|
||||||
|
|
||||||
|
const logEntry = createLogEntry(
|
||||||
|
`run.resume-dungeon.${nextRun.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
"room",
|
||||||
|
`Left town and resumed the dungeon delve on level ${nextRun.currentLevel}.`,
|
||||||
|
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
appendLogs(nextRun, [logEntry]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: nextRun,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function enterCurrentRoom(
|
export function enterCurrentRoom(
|
||||||
options: EnterCurrentRoomOptions,
|
options: EnterCurrentRoomOptions,
|
||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot enter rooms while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
const levelState = requireCurrentLevel(run);
|
const levelState = requireCurrentLevel(run);
|
||||||
const roomId = requireCurrentRoomId(run);
|
const roomId = requireCurrentRoomId(run);
|
||||||
const entry = enterRoom({
|
const entry = enterRoom({
|
||||||
@@ -334,6 +471,10 @@ export function enterCurrentRoom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAvailableMoves(run: RunState): AvailableMove[] {
|
export function getAvailableMoves(run: RunState): AvailableMove[] {
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const room = requireCurrentRoom(run);
|
const room = requireCurrentRoom(run);
|
||||||
|
|
||||||
return room.exits
|
return room.exits
|
||||||
@@ -348,6 +489,10 @@ export function getAvailableMoves(run: RunState): AvailableMove[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCurrentRoomCombatReady(run: RunState) {
|
export function isCurrentRoomCombatReady(run: RunState) {
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const room = requireCurrentRoom(run);
|
const room = requireCurrentRoom(run);
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -362,6 +507,10 @@ export function travelCurrentExit(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot travel while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (run.activeCombat) {
|
if (run.activeCombat) {
|
||||||
throw new Error("Cannot travel while combat is active.");
|
throw new Error("Cannot travel while combat is active.");
|
||||||
}
|
}
|
||||||
@@ -437,6 +586,11 @@ export function startCombatInCurrentRoom(
|
|||||||
options: StartCurrentCombatOptions,
|
options: StartCurrentCombatOptions,
|
||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot start combat while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
const levelState = requireCurrentLevel(run);
|
const levelState = requireCurrentLevel(run);
|
||||||
const roomId = requireCurrentRoomId(run);
|
const roomId = requireCurrentRoomId(run);
|
||||||
const room = levelState.rooms[roomId];
|
const room = levelState.rooms[roomId];
|
||||||
@@ -454,6 +608,7 @@ export function startCombatInCurrentRoom(
|
|||||||
|
|
||||||
levelState.rooms[roomId] = started.room;
|
levelState.rooms[roomId] = started.room;
|
||||||
run.activeCombat = started.combat;
|
run.activeCombat = started.combat;
|
||||||
|
run.lastCombatOutcome = undefined;
|
||||||
appendLogs(run, started.logEntries);
|
appendLogs(run, started.logEntries);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -467,6 +622,10 @@ export function resolveRunPlayerTurn(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot resolve combat while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!run.activeCombat) {
|
if (!run.activeCombat) {
|
||||||
throw new Error("Run does not have an active combat.");
|
throw new Error("Run does not have an active combat.");
|
||||||
}
|
}
|
||||||
@@ -517,6 +676,10 @@ export function resolveRunEnemyTurn(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot resolve combat while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!run.activeCombat) {
|
if (!run.activeCombat) {
|
||||||
throw new Error("Run does not have an active combat.");
|
throw new Error("Run does not have an active combat.");
|
||||||
}
|
}
|
||||||
@@ -535,6 +698,25 @@ export function resolveRunEnemyTurn(
|
|||||||
|
|
||||||
if (result.combatEnded) {
|
if (result.combatEnded) {
|
||||||
run.status = "failed";
|
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 {
|
return {
|
||||||
|
|||||||
126
src/rules/townInventory.test.ts
Normal file
126
src/rules/townInventory.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
293
src/rules/townInventory.ts
Normal file
293
src/rules/townInventory.ts
Normal file
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
71
src/rules/townServices.test.ts
Normal file
71
src/rules/townServices.test.ts
Normal file
@@ -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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
172
src/rules/townServices.ts
Normal file
172
src/rules/townServices.ts
Normal file
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export const manoeuvreDefinitionSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
weaponCategories: z.array(z.enum(["melee", "ranged"])),
|
weaponCategories: z.array(z.enum(["melee", "ranged"])),
|
||||||
|
minimumLevel: z.number().int().positive().optional(),
|
||||||
shiftCost: z.number().int().optional(),
|
shiftCost: z.number().int().optional(),
|
||||||
disciplineModifier: z.number().int().optional(),
|
disciplineModifier: z.number().int().optional(),
|
||||||
precisionModifier: z.number().int().optional(),
|
precisionModifier: z.number().int().optional(),
|
||||||
|
|||||||
@@ -204,16 +204,38 @@ export const combatStateSchema = z.object({
|
|||||||
combatLog: z.array(logEntrySchema),
|
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({
|
export const runStateSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
campaignId: z.string().min(1),
|
campaignId: z.string().min(1),
|
||||||
status: z.enum(["active", "paused", "completed", "failed"]),
|
status: z.enum(["active", "paused", "completed", "failed"]),
|
||||||
|
phase: z.enum(["dungeon", "town"]),
|
||||||
startedAt: z.string().min(1),
|
startedAt: z.string().min(1),
|
||||||
|
lastTownAt: z.string().optional(),
|
||||||
currentLevel: z.number().int().positive(),
|
currentLevel: z.number().int().positive(),
|
||||||
currentRoomId: z.string().optional(),
|
currentRoomId: z.string().optional(),
|
||||||
dungeon: dungeonStateSchema,
|
dungeon: dungeonStateSchema,
|
||||||
adventurerSnapshot: adventurerStateSchema,
|
adventurerSnapshot: adventurerStateSchema,
|
||||||
activeCombat: combatStateSchema.optional(),
|
activeCombat: combatStateSchema.optional(),
|
||||||
|
lastCombatOutcome: combatOutcomeStateSchema.optional(),
|
||||||
|
lastLevelUp: levelUpStateSchema.optional(),
|
||||||
|
townState: townStateSchema,
|
||||||
defeatedCreatureIds: z.array(z.string()),
|
defeatedCreatureIds: z.array(z.string()),
|
||||||
xpGained: z.number().int().nonnegative(),
|
xpGained: z.number().int().nonnegative(),
|
||||||
log: z.array(logEntrySchema),
|
log: z.array(logEntrySchema),
|
||||||
|
|||||||
188
src/styles.css
188
src/styles.css
@@ -111,6 +111,27 @@ select {
|
|||||||
rgba(48, 22, 18, 0.92);
|
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 {
|
.alert-kicker {
|
||||||
display: block;
|
display: block;
|
||||||
color: #ffbf78;
|
color: #ffbf78;
|
||||||
@@ -156,6 +177,14 @@ select {
|
|||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-saves {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-town-hub {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -179,7 +208,7 @@ select {
|
|||||||
|
|
||||||
.stat-strip {
|
.stat-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +246,87 @@ select {
|
|||||||
color: rgba(244, 239, 227, 0.76);
|
color: rgba(244, 239, 227, 0.76);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.town-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.room-title {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -238,6 +348,26 @@ select {
|
|||||||
margin-top: 1rem;
|
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 {
|
.button {
|
||||||
border: 1px solid rgba(255, 217, 163, 0.24);
|
border: 1px solid rgba(255, 217, 163, 0.24);
|
||||||
background: rgba(255, 245, 223, 0.04);
|
background: rgba(255, 245, 223, 0.04);
|
||||||
@@ -276,9 +406,23 @@ select {
|
|||||||
margin-top: 1rem;
|
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,
|
.move-list,
|
||||||
.mini-map,
|
.mini-map,
|
||||||
.enemy-list {
|
.enemy-list,
|
||||||
|
.save-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -290,7 +434,8 @@ select {
|
|||||||
|
|
||||||
.move-card,
|
.move-card,
|
||||||
.map-node,
|
.map-node,
|
||||||
.enemy-card {
|
.enemy-card,
|
||||||
|
.save-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.95rem;
|
padding: 0.95rem;
|
||||||
@@ -336,13 +481,28 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.move-card em,
|
.move-card em,
|
||||||
.enemy-card span {
|
.enemy-card span,
|
||||||
|
.save-card span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: rgba(244, 239, 227, 0.7);
|
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 {
|
.map-node-active {
|
||||||
border-color: rgba(243, 186, 115, 0.55);
|
border-color: rgba(243, 186, 115, 0.55);
|
||||||
background: rgba(243, 186, 115, 0.12);
|
background: rgba(243, 186, 115, 0.12);
|
||||||
@@ -393,4 +553,24 @@ select {
|
|||||||
.stat-strip {
|
.stat-strip {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type ManoeuvreDefinition = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
weaponCategories: WeaponCategory[];
|
weaponCategories: WeaponCategory[];
|
||||||
|
minimumLevel?: number;
|
||||||
shiftCost?: number;
|
shiftCost?: number;
|
||||||
disciplineModifier?: number;
|
disciplineModifier?: number;
|
||||||
precisionModifier?: number;
|
precisionModifier?: number;
|
||||||
|
|||||||
@@ -205,16 +205,38 @@ export type CombatState = {
|
|||||||
combatLog: LogEntry[];
|
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 = {
|
export type RunState = {
|
||||||
id: string;
|
id: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
status: "active" | "paused" | "completed" | "failed";
|
status: "active" | "paused" | "completed" | "failed";
|
||||||
|
phase: "dungeon" | "town";
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
lastTownAt?: string;
|
||||||
currentLevel: number;
|
currentLevel: number;
|
||||||
currentRoomId?: string;
|
currentRoomId?: string;
|
||||||
dungeon: DungeonState;
|
dungeon: DungeonState;
|
||||||
adventurerSnapshot: AdventurerState;
|
adventurerSnapshot: AdventurerState;
|
||||||
activeCombat?: CombatState;
|
activeCombat?: CombatState;
|
||||||
|
lastCombatOutcome?: CombatOutcomeState;
|
||||||
|
lastLevelUp?: LevelUpState;
|
||||||
|
townState: TownState;
|
||||||
defeatedCreatureIds: string[];
|
defeatedCreatureIds: string[];
|
||||||
xpGained: number;
|
xpGained: number;
|
||||||
log: LogEntry[];
|
log: LogEntry[];
|
||||||
|
|||||||
Reference in New Issue
Block a user