2 Commits

11 changed files with 512 additions and 197 deletions

View File

@@ -9,8 +9,6 @@ import {
isCurrentRoomCombatReady,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
startCombatInCurrentRoom,
travelCurrentExit,
} from "@/rules/runState";
@@ -56,7 +54,6 @@ function App() {
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
const availableMoves = getAvailableMoves(run);
const combatReadyEncounter = isCurrentRoomCombatReady(run);
const inTown = run.phase === "town";
const handleReset = () => {
setRun(createDemoRun());
@@ -99,14 +96,6 @@ function App() {
);
};
const handleReturnToTown = () => {
setRun((previous) => returnToTown(previous).run);
};
const handleResumeDungeon = () => {
setRun((previous) => resumeDungeon(previous).run);
};
return (
<main className="app-shell">
<section className="hero">
@@ -123,21 +112,14 @@ function App() {
<button className="button button-primary" onClick={handleReset}>
Reset Demo Run
</button>
<button
className="button"
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
disabled={Boolean(run.activeCombat)}
>
{inTown ? "Resume Dungeon" : "Return To Town"}
</button>
<div className="status-chip">
<span>Run Phase</span>
<strong>{run.phase}</strong>
<span>Run Status</span>
<strong>{run.status}</strong>
</div>
</div>
</section>
{combatReadyEncounter && !run.activeCombat && !inTown ? (
{combatReadyEncounter && !run.activeCombat ? (
<section className="alert-banner">
<div>
<span className="alert-kicker">Encounter Ready</span>
@@ -191,49 +173,29 @@ function App() {
.
</p>
<p className="supporting-text">
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
Run rewards: {run.xpGained} XP, {run.goldGained} gold,{" "}
{run.defeatedCreatureIds.length} foes defeated.
</p>
<p className="supporting-text">
{inTown
? `The party is currently in town${run.lastTownAt ? ` as of ${new Date(run.lastTownAt).toLocaleString()}` : ""}.`
: "The party is still delving below ground."}
Carried gold: {run.adventurerSnapshot.inventory.currency.gold}. Looted items:{" "}
{run.lootedItems.length === 0
? "none yet"
: run.lootedItems
.map((entry) => {
const item = sampleContentPack.items.find(
(candidate) => candidate.id === entry.definitionId,
);
return entry.quantity > 1
? `${entry.quantity}x ${item?.name ?? entry.definitionId}`
: item?.name ?? entry.definitionId;
})
.join(", ")}
.
</p>
</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>
</article>
) : (
<>
<article className="panel">
<article className="panel">
<div className="panel-header">
<h2>Current Room</h2>
<span>Level {run.currentLevel}</span>
@@ -374,8 +336,6 @@ function App() {
</p>
)}
</article>
</>
)}
<article className="panel panel-log">
<div className="panel-header">

View File

@@ -4,6 +4,7 @@ import { lookupTable } from "@/rules/tables";
import {
findCreatureByName,
findItemById,
findRoomTemplateForLookup,
findTableByCode,
} from "./contentHelpers";
@@ -57,4 +58,11 @@ describe("level 1 content helpers", () => {
expect(creature.id).toBe("creature.level1.guard");
expect(creature.hp).toBeGreaterThan(0);
});
it("finds a loot item definition by id", () => {
const item = findItemById(sampleContentPack, "item.silver-clasp");
expect(item.name).toBe("Silver Clasp");
expect(item.itemType).toBe("treasure");
});
});

View File

@@ -1,6 +1,7 @@
import type {
ContentPack,
CreatureDefinition,
ItemDefinition,
RoomTemplate,
TableDefinition,
} from "@/types/content";
@@ -74,3 +75,13 @@ export function findCreatureById(
return creature;
}
export function findItemById(content: ContentPack, itemId: string): ItemDefinition {
const item = content.items.find((entry) => entry.id === itemId);
if (!item) {
throw new Error(`Unknown item id: ${itemId}`);
}
return item;
}

View File

@@ -13,6 +13,79 @@ const samplePack = {
],
tables: [
...level1EncounterTables,
{
id: "table.level1.humanoid-loot",
code: "L1HL",
name: "Level 1 Humanoid Loot",
category: "loot",
level: 1,
page: 122,
diceKind: "d6",
entries: [
{
key: "1-2",
min: 1,
max: 2,
label: "Scavenged coins",
effects: [{ type: "gain-gold", amount: 1, target: "self" }],
},
{
key: "3-4",
min: 3,
max: 4,
label: "Bone Charm",
references: [{ type: "item", id: "item.bone-charm" }],
},
{
key: "5",
exact: 5,
label: "Silver Clasp and coins",
references: [{ type: "item", id: "item.silver-clasp" }],
effects: [{ type: "gain-gold", amount: 2, target: "self" }],
},
{
key: "6",
exact: 6,
label: "Key Ring and coin purse",
references: [{ type: "item", id: "item.keeper-keyring" }],
effects: [{ type: "gain-gold", amount: 3, target: "self" }],
},
],
notes: ["Starter loot table for martial and humanoid encounters."],
mvp: true,
},
{
id: "table.level1.beast-loot",
code: "L1BL",
name: "Level 1 Beast Loot",
category: "loot",
level: 1,
page: 122,
diceKind: "d6",
entries: [
{
key: "1-4",
min: 1,
max: 4,
label: "Nothing useful",
},
{
key: "5",
exact: 5,
label: "Trophy Fang",
references: [{ type: "item", id: "item.trophy-fang" }],
},
{
key: "6",
exact: 6,
label: "Trophy Fang and stray coin",
references: [{ type: "item", id: "item.trophy-fang" }],
effects: [{ type: "gain-gold", amount: 1, target: "self" }],
},
],
notes: ["Starter loot table for basic animal encounters."],
mvp: true,
},
{
id: "table.weapon-manoeuvres-1",
code: "WMT1",
@@ -123,6 +196,42 @@ const samplePack = {
consumable: false,
mvp: true,
},
{
id: "item.bone-charm",
name: "Bone Charm",
itemType: "treasure",
stackable: false,
consumable: false,
valueGp: 2,
mvp: true,
},
{
id: "item.silver-clasp",
name: "Silver Clasp",
itemType: "treasure",
stackable: false,
consumable: false,
valueGp: 4,
mvp: true,
},
{
id: "item.keeper-keyring",
name: "Keeper Keyring",
itemType: "treasure",
stackable: false,
consumable: false,
valueGp: 5,
mvp: true,
},
{
id: "item.trophy-fang",
name: "Trophy Fang",
itemType: "treasure",
stackable: true,
consumable: false,
valueGp: 1,
mvp: true,
},
],
potions: [
{
@@ -163,6 +272,7 @@ const samplePack = {
numberAppearing: "1-2",
},
xpReward: 1,
lootTableCodes: ["L1BL"],
sourcePage: 102,
traits: ["level-1", "sample"],
mvp: true,
@@ -182,6 +292,7 @@ const samplePack = {
armour: 1,
},
xpReward: 2,
lootTableCodes: ["L1HL"],
sourcePage: 102,
traits: ["level-1", "martial"],
mvp: true,
@@ -201,6 +312,7 @@ const samplePack = {
armour: 1,
},
xpReward: 3,
lootTableCodes: ["L1HL"],
sourcePage: 102,
traits: ["level-1", "martial"],
mvp: true,
@@ -217,6 +329,7 @@ const samplePack = {
damage: 1,
},
xpReward: 1,
lootTableCodes: ["L1HL"],
sourcePage: 102,
traits: ["level-1", "martial"],
mvp: true,
@@ -233,6 +346,7 @@ const samplePack = {
damage: 1,
},
xpReward: 1,
lootTableCodes: ["L1BL"],
sourcePage: 102,
traits: ["level-1", "beast"],
mvp: true,
@@ -249,6 +363,7 @@ const samplePack = {
damage: 1,
},
xpReward: 2,
lootTableCodes: ["L1BL"],
sourcePage: 102,
traits: ["level-1", "beast"],
mvp: true,

91
src/rules/loot.test.ts Normal file
View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import { startCombatFromRoom } from "./combat";
import { resolveCombatLoot } from "./loot";
function createSequenceRoller(values: number[]) {
let index = 0;
return () => {
const next = values[index];
index += 1;
return next;
};
}
function createAdventurer() {
return createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
armourId: "armour.leather-vest",
scrollId: "scroll.lesser-heal",
});
}
describe("loot resolution", () => {
it("awards loot from defeated creatures into carried inventory", () => {
const room = {
id: "room.level1.start",
level: 1,
position: { x: 0, y: 0 },
dimensions: { width: 4, height: 4 },
roomClass: "start" as const,
exits: [],
discovery: {
generated: true,
entered: true,
cleared: false,
searched: false,
},
encounter: {
id: "room.level1.start.encounter",
sourceTableCode: "L1G",
creatureIds: ["room.level1.start.creature.1"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
},
objects: [],
notes: ["Entry Chamber"],
flags: [],
};
const started = startCombatFromRoom({
content: sampleContentPack,
adventurer: createAdventurer(),
room,
at: "2026-03-15T14:02:00.000Z",
});
started.combat.enemies[0]!.hpCurrent = 0;
const loot = resolveCombatLoot({
content: sampleContentPack,
combat: started.combat,
inventory: createAdventurer().inventory,
roller: createSequenceRoller([5]),
at: "2026-03-15T14:03:00.000Z",
});
expect(loot.goldAwarded).toBe(2);
expect(loot.itemsAwarded).toEqual([
{
definitionId: "item.silver-clasp",
quantity: 1,
},
]);
expect(loot.inventory.currency.gold).toBe(2);
expect(loot.inventory.carried).toEqual(
expect.arrayContaining([
expect.objectContaining({
definitionId: "item.silver-clasp",
quantity: 1,
}),
]),
);
expect(loot.logEntries.some((entry) => entry.type === "loot")).toBe(true);
});
});

172
src/rules/loot.ts Normal file
View File

@@ -0,0 +1,172 @@
import { findCreatureById, findItemById, findTableByCode } from "@/data/contentHelpers";
import type { ContentPack } from "@/types/content";
import type { CombatState, InventoryEntry, InventoryState } from "@/types/state";
import type { LogEntry } from "@/types/rules";
import type { DiceRoller } from "./dice";
import { lookupTable } from "./tables";
export type ResolveCombatLootOptions = {
content: ContentPack;
combat: CombatState;
inventory: InventoryState;
roller?: DiceRoller;
at: string;
};
export type ResolveCombatLootResult = {
inventory: InventoryState;
itemsAwarded: InventoryEntry[];
goldAwarded: number;
logEntries: LogEntry[];
};
function cloneInventory(inventory: InventoryState): InventoryState {
return {
...inventory,
carried: inventory.carried.map((entry) => ({ ...entry })),
equipped: inventory.equipped.map((entry) => ({ ...entry })),
stored: inventory.stored.map((entry) => ({ ...entry })),
currency: { ...inventory.currency },
lightSources: inventory.lightSources.map((entry) => ({ ...entry })),
};
}
function createInventoryEntry(definitionId: string, quantity = 1): InventoryEntry {
return {
definitionId,
quantity,
};
}
function mergeInventoryEntry(
content: ContentPack,
entries: InventoryEntry[],
definitionId: string,
quantity: number,
) {
const item = findItemById(content, definitionId);
if (item.stackable) {
const existing = entries.find((entry) => entry.definitionId === definitionId);
if (existing) {
existing.quantity += quantity;
return;
}
}
entries.push(createInventoryEntry(definitionId, quantity));
}
function createLootLog(
id: string,
at: string,
text: string,
relatedIds: string[],
): LogEntry {
return {
id,
at,
type: "loot",
text,
relatedIds,
};
}
function summarizeLoot(
goldAwarded: number,
itemsAwarded: InventoryEntry[],
content: ContentPack,
) {
const parts: string[] = [];
if (goldAwarded > 0) {
parts.push(`${goldAwarded} gold`);
}
for (const item of itemsAwarded) {
const itemName = findItemById(content, item.definitionId).name;
parts.push(item.quantity > 1 ? `${item.quantity}x ${itemName}` : itemName);
}
return parts.length > 0 ? parts.join(", ") : "nothing useful";
}
export function resolveCombatLoot(
options: ResolveCombatLootOptions,
): ResolveCombatLootResult {
const inventory = cloneInventory(options.inventory);
const itemsAwarded: InventoryEntry[] = [];
const logEntries: LogEntry[] = [];
let goldAwarded = 0;
const defeatedCreatures = options.combat.enemies.filter(
(enemy) => enemy.hpCurrent === 0 && enemy.sourceDefinitionId,
);
for (const enemy of defeatedCreatures) {
const creature = findCreatureById(options.content, enemy.sourceDefinitionId!);
const lootTableCodes = creature.lootTableCodes ?? [];
for (const tableCode of lootTableCodes) {
const table = findTableByCode(options.content, tableCode);
const lookup = lookupTable(table, { roller: options.roller });
const rollTotal = lookup.roll.modifiedTotal ?? lookup.roll.total;
logEntries.push({
id: `${options.combat.id}.${creature.id}.${tableCode}.roll`,
at: options.at,
type: "roll",
text: `Rolled loot ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${tableCode} for ${rollTotal}: ${lookup.entry.label}.`,
relatedIds: [options.combat.id, creature.id, tableCode],
});
let creatureGold = 0;
const creatureItems: InventoryEntry[] = [];
for (const effect of lookup.entry.effects ?? []) {
if (effect.type === "gain-gold" && effect.amount) {
creatureGold += effect.amount;
}
if (effect.type === "add-item" && effect.referenceId) {
mergeInventoryEntry(options.content, inventory.carried, effect.referenceId, effect.amount ?? 1);
mergeInventoryEntry(options.content, itemsAwarded, effect.referenceId, effect.amount ?? 1);
mergeInventoryEntry(options.content, creatureItems, effect.referenceId, effect.amount ?? 1);
}
}
for (const reference of lookup.entry.references ?? []) {
if (reference.type !== "item") {
continue;
}
mergeInventoryEntry(options.content, inventory.carried, reference.id, 1);
mergeInventoryEntry(options.content, itemsAwarded, reference.id, 1);
mergeInventoryEntry(options.content, creatureItems, reference.id, 1);
}
if (creatureGold > 0) {
inventory.currency.gold += creatureGold;
goldAwarded += creatureGold;
}
logEntries.push(
createLootLog(
`${options.combat.id}.${creature.id}.${tableCode}.loot`,
options.at,
`${creature.name} yielded ${summarizeLoot(creatureGold, creatureItems, options.content)}.`,
[options.combat.id, creature.id, tableCode],
),
);
}
}
return {
inventory,
itemsAwarded,
goldAwarded,
logEntries,
};
}

View File

@@ -10,8 +10,6 @@ import {
isCurrentRoomCombatReady,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
startCombatInCurrentRoom,
travelCurrentExit,
} from "./runState";
@@ -46,7 +44,6 @@ describe("run state flow", () => {
expect(run.currentLevel).toBe(1);
expect(run.currentRoomId).toBe("room.level1.start");
expect(run.phase).toBe("dungeon");
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
});
@@ -179,7 +176,7 @@ describe("run state flow", () => {
run: withCombat,
manoeuvreId: "manoeuvre.guard-break",
targetEnemyId: withCombat.activeCombat!.enemies[0]!.id,
roller: createSequenceRoller([6, 6]),
roller: createSequenceRoller([6, 6, 5]),
at: "2026-03-15T14:03:00.000Z",
});
@@ -190,7 +187,23 @@ describe("run state flow", () => {
);
expect(result.run.adventurerSnapshot.xp).toBe(2);
expect(result.run.xpGained).toBe(2);
expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(2);
expect(result.run.goldGained).toBe(2);
expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]);
expect(result.run.lootedItems).toEqual([
{
definitionId: "item.silver-clasp",
quantity: 1,
},
]);
expect(result.run.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([
expect.objectContaining({
definitionId: "item.silver-clasp",
quantity: 1,
}),
]),
);
expect(result.run.log.at(-1)?.text).toContain("Victory rewards");
});
@@ -257,7 +270,22 @@ describe("run state flow", () => {
expect(isCurrentRoomCombatReady(run)).toBe(true);
});
it("returns to town and later resumes the dungeon", () => {
it("lists available traversable exits for the current room", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
expect(getAvailableMoves(run)).toEqual([
expect.objectContaining({
direction: "north",
generated: false,
}),
]);
});
it("travels through an unresolved exit, generates a room, and enters it", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
@@ -265,13 +293,22 @@ describe("run state flow", () => {
at: "2026-03-15T14:00:00.000Z",
});
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
const result = travelCurrentExit({
content: sampleContentPack,
run,
exitDirection: "north",
roller: createSequenceRoller([1, 1]),
at: "2026-03-15T14:05:00.000Z",
});
expect(inTown.phase).toBe("town");
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
expect(getAvailableMoves(inTown)).toEqual([]);
expect(resumed.phase).toBe("dungeon");
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
expect(result.run.currentRoomId).toBe("room.level1.room.002");
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
"room.level1.start",
"room.level1.room.002",
]);
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
true,
);
expect(result.run.log[0]?.text).toContain("Travelled north");
});
});

View File

@@ -9,6 +9,7 @@ import type { LogEntry } from "@/types/rules";
import { findCreatureById } from "@/data/contentHelpers";
import { startCombatFromRoom } from "./combat";
import { resolveCombatLoot } from "./loot";
import {
resolveEnemyTurn,
resolvePlayerAttack,
@@ -82,22 +83,6 @@ export type RunTransitionResult = {
logEntries: LogEntry[];
};
function createLogEntry(
id: string,
at: string,
type: LogEntry["type"],
text: string,
relatedIds?: string[],
): LogEntry {
return {
id,
at,
type,
text,
relatedIds,
};
}
function cloneCombat(combat: CombatState): CombatState {
return {
...combat,
@@ -179,6 +164,10 @@ function cloneRun(run: RunState): RunState {
globalFlags: [...run.dungeon.globalFlags],
},
activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined,
defeatedCreatureIds: [...run.defeatedCreatureIds],
xpGained: run.xpGained,
goldGained: run.goldGained,
lootedItems: run.lootedItems.map((entry) => ({ ...entry })),
log: run.log.map((entry) => ({
...entry,
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
@@ -268,6 +257,7 @@ function applyCombatRewards(
content: ContentPack,
run: RunState,
completedCombat: CombatState,
roller: DiceRoller | undefined,
at: string,
) {
const defeatedCreatureIds = completedCombat.enemies
@@ -281,18 +271,45 @@ function applyCombatRewards(
run.xpGained += xpAwarded;
run.adventurerSnapshot.xp += xpAwarded;
if (xpAwarded === 0) {
return [] as LogEntry[];
const lootResult = resolveCombatLoot({
content,
combat: completedCombat,
inventory: run.adventurerSnapshot.inventory,
roller,
at,
});
run.adventurerSnapshot.inventory = lootResult.inventory;
run.goldGained += lootResult.goldAwarded;
for (const item of lootResult.itemsAwarded) {
const existing = run.lootedItems.find(
(entry) => entry.definitionId === item.definitionId,
);
if (existing) {
existing.quantity += item.quantity;
continue;
}
run.lootedItems.push({ ...item });
}
const rewardLogs = [...lootResult.logEntries];
if (xpAwarded === 0 && lootResult.goldAwarded === 0 && lootResult.itemsAwarded.length === 0) {
return rewardLogs;
}
return [
rewardLogs.push(
createRewardLog(
`${completedCombat.id}.rewards`,
at,
`Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`,
`Victory rewards: gained ${xpAwarded} XP, ${lootResult.goldAwarded} gold, and ${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0)} loot item${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0) === 1 ? "" : "s"} from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`,
[completedCombat.id, ...defeatedCreatureIds],
),
];
);
return rewardLogs;
}
export function createRunState(options: CreateRunOptions): RunState {
@@ -306,7 +323,6 @@ export function createRunState(options: CreateRunOptions): RunState {
id: options.runId ?? "run.active",
campaignId: options.campaignId,
status: "active",
phase: "dungeon",
startedAt: at,
currentLevel: 1,
currentRoomId: "room.level1.start",
@@ -322,73 +338,17 @@ export function createRunState(options: CreateRunOptions): RunState {
adventurerSnapshot: options.adventurer,
defeatedCreatureIds: [],
xpGained: 0,
goldGained: 0,
lootedItems: [],
log: [],
pendingEffects: [],
};
}
export function returnToTown(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
if (nextRun.activeCombat) {
throw new Error("Cannot return to town during active combat.");
}
nextRun.phase = "town";
nextRun.lastTownAt = at;
const logEntry = createLogEntry(
`run.return-to-town.${nextRun.log.length + 1}`,
at,
"town",
`Returned to town from level ${nextRun.currentLevel}.`,
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
);
appendLogs(nextRun, [logEntry]);
return {
run: nextRun,
logEntries: [logEntry],
};
}
export function resumeDungeon(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
nextRun.phase = "dungeon";
const logEntry = createLogEntry(
`run.resume-dungeon.${nextRun.log.length + 1}`,
at,
"room",
`Left town and resumed the dungeon delve on level ${nextRun.currentLevel}.`,
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
);
appendLogs(nextRun, [logEntry]);
return {
run: nextRun,
logEntries: [logEntry],
};
}
export function enterCurrentRoom(
options: EnterCurrentRoomOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot enter rooms while the run is in town.");
}
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const entry = enterRoom({
@@ -409,10 +369,6 @@ export function enterCurrentRoom(
}
export function getAvailableMoves(run: RunState): AvailableMove[] {
if (run.phase !== "dungeon") {
return [];
}
const room = requireCurrentRoom(run);
return room.exits
@@ -427,10 +383,6 @@ export function getAvailableMoves(run: RunState): AvailableMove[] {
}
export function isCurrentRoomCombatReady(run: RunState) {
if (run.phase !== "dungeon") {
return false;
}
const room = requireCurrentRoom(run);
return Boolean(
@@ -445,10 +397,6 @@ export function travelCurrentExit(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot travel while the run is in town.");
}
if (run.activeCombat) {
throw new Error("Cannot travel while combat is active.");
}
@@ -524,11 +472,6 @@ export function startCombatInCurrentRoom(
options: StartCurrentCombatOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot start combat while the run is in town.");
}
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
@@ -559,10 +502,6 @@ export function resolveRunPlayerTurn(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot resolve combat while the run is in town.");
}
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}
@@ -590,6 +529,7 @@ export function resolveRunPlayerTurn(
options.content,
run,
completedCombat,
options.roller,
options.at ?? new Date().toISOString(),
);
@@ -613,10 +553,6 @@ export function resolveRunEnemyTurn(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot resolve combat while the run is in town.");
}
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}

View File

@@ -208,9 +208,7 @@ export const runStateSchema = z.object({
id: z.string().min(1),
campaignId: z.string().min(1),
status: z.enum(["active", "paused", "completed", "failed"]),
phase: z.enum(["dungeon", "town"]),
startedAt: z.string().min(1),
lastTownAt: z.string().optional(),
currentLevel: z.number().int().positive(),
currentRoomId: z.string().optional(),
dungeon: dungeonStateSchema,
@@ -218,6 +216,8 @@ export const runStateSchema = z.object({
activeCombat: combatStateSchema.optional(),
defeatedCreatureIds: z.array(z.string()),
xpGained: z.number().int().nonnegative(),
goldGained: z.number().int().nonnegative(),
lootedItems: z.array(inventoryEntrySchema),
log: z.array(logEntrySchema),
pendingEffects: z.array(ruleEffectSchema),
});

View File

@@ -156,10 +156,6 @@ select {
grid-column: span 12;
}
.panel-town-hub {
grid-column: span 8;
}
.panel-header {
display: flex;
justify-content: space-between;
@@ -221,13 +217,6 @@ select {
color: rgba(244, 239, 227, 0.76);
}
.town-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.room-title {
margin: 0 0 0.35rem;
font-size: 1.5rem;
@@ -404,8 +393,4 @@ select {
.stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.town-summary-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -209,9 +209,7 @@ export type RunState = {
id: string;
campaignId: string;
status: "active" | "paused" | "completed" | "failed";
phase: "dungeon" | "town";
startedAt: string;
lastTownAt?: string;
currentLevel: number;
currentRoomId?: string;
dungeon: DungeonState;
@@ -219,6 +217,8 @@ export type RunState = {
activeCombat?: CombatState;
defeatedCreatureIds: string[];
xpGained: number;
goldGained: number;
lootedItems: InventoryEntry[];
log: LogEntry[];
pendingEffects: RuleEffect[];
};