✨Feature: add town systems, saves, recovery, and level progression
This commit is contained in:
@@ -72,7 +72,11 @@ export function createStartingAdventurer(
|
||||
throw new Error(`Scroll ${selectedScroll.id} is not a legal starting option.`);
|
||||
}
|
||||
|
||||
const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds;
|
||||
const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds.filter((manoeuvreId) => {
|
||||
const manoeuvre = requireDefinition(content.manoeuvres, manoeuvreId, "manoeuvre");
|
||||
|
||||
return (manoeuvre.minimumLevel ?? 1) <= 1;
|
||||
});
|
||||
|
||||
if (allowedManoeuvreIds.length === 0) {
|
||||
throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -191,9 +191,99 @@ describe("run state flow", () => {
|
||||
expect(result.run.adventurerSnapshot.xp).toBe(2);
|
||||
expect(result.run.xpGained).toBe(2);
|
||||
expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]);
|
||||
expect(result.run.lastCombatOutcome?.result).toBe("victory");
|
||||
expect(result.run.lastCombatOutcome?.xpAwarded).toBe(2);
|
||||
expect(result.run.log.at(-1)?.text).toContain("Victory rewards");
|
||||
});
|
||||
|
||||
it("applies an immediate level-up when combat rewards cross the xp threshold", () => {
|
||||
const adventurer = createAdventurer();
|
||||
adventurer.xp = 7;
|
||||
|
||||
const run = createRunState({
|
||||
content: sampleContentPack,
|
||||
campaignId: "campaign.1",
|
||||
adventurer,
|
||||
at: "2026-03-15T14:00:00.000Z",
|
||||
});
|
||||
const levelState = run.dungeon.levels["1"]!;
|
||||
const room = levelState.rooms["room.level1.start"]!;
|
||||
|
||||
room.encounter = {
|
||||
id: "encounter.start.guard",
|
||||
sourceTableCode: "L1G",
|
||||
creatureIds: ["creature.level1.guard"],
|
||||
resultLabel: "Guard",
|
||||
creatureNames: ["Guard"],
|
||||
resolved: true,
|
||||
};
|
||||
room.discovery.entered = true;
|
||||
|
||||
const withCombat = startCombatInCurrentRoom({
|
||||
content: sampleContentPack,
|
||||
run,
|
||||
at: "2026-03-15T14:02:00.000Z",
|
||||
}).run;
|
||||
withCombat.activeCombat!.enemies[0]!.hpCurrent = 1;
|
||||
|
||||
const result = resolveRunPlayerTurn({
|
||||
content: sampleContentPack,
|
||||
run: withCombat,
|
||||
manoeuvreId: "manoeuvre.guard-break",
|
||||
targetEnemyId: withCombat.activeCombat!.enemies[0]!.id,
|
||||
roller: createSequenceRoller([6, 6]),
|
||||
at: "2026-03-15T14:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.run.adventurerSnapshot.level).toBe(2);
|
||||
expect(result.run.adventurerSnapshot.hp.max).toBe(12);
|
||||
expect(result.run.adventurerSnapshot.xp).toBe(9);
|
||||
expect(result.run.adventurerSnapshot.manoeuvreIds).toContain("manoeuvre.sweeping-cut");
|
||||
expect(result.run.lastLevelUp?.newLevel).toBe(2);
|
||||
expect(result.run.log.at(-1)?.text).toContain("Reached level 2");
|
||||
});
|
||||
|
||||
it("records a defeat outcome when the enemy drops the adventurer", () => {
|
||||
const run = createRunState({
|
||||
content: sampleContentPack,
|
||||
campaignId: "campaign.1",
|
||||
adventurer: createAdventurer(),
|
||||
at: "2026-03-15T14:00:00.000Z",
|
||||
});
|
||||
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
|
||||
|
||||
room.encounter = {
|
||||
id: `${room.id}.encounter`,
|
||||
sourceTableCode: "L1G",
|
||||
creatureIds: ["a"],
|
||||
creatureNames: ["Warrior"],
|
||||
resultLabel: "Warrior",
|
||||
resolved: true,
|
||||
};
|
||||
|
||||
const withCombat = startCombatInCurrentRoom({
|
||||
content: sampleContentPack,
|
||||
run,
|
||||
at: "2026-03-15T14:02:00.000Z",
|
||||
}).run;
|
||||
|
||||
withCombat.activeCombat!.actingSide = "enemy";
|
||||
withCombat.activeCombat!.player.hpCurrent = 1;
|
||||
withCombat.adventurerSnapshot.hp.current = 1;
|
||||
|
||||
const result = resolveRunEnemyTurn({
|
||||
content: sampleContentPack,
|
||||
run: withCombat,
|
||||
roller: createSequenceRoller([6, 6]),
|
||||
at: "2026-03-15T14:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.run.status).toBe("failed");
|
||||
expect(result.run.lastCombatOutcome?.result).toBe("defeat");
|
||||
expect(result.run.lastCombatOutcome?.summary).toContain("defeated");
|
||||
expect(result.run.log.at(-1)?.text).toContain("defeated");
|
||||
});
|
||||
|
||||
it("lists available traversable exits for the current room", () => {
|
||||
const run = createRunState({
|
||||
content: sampleContentPack,
|
||||
|
||||
+90
-8
@@ -9,6 +9,8 @@ import type { LogEntry } from "@/types/rules";
|
||||
import { findCreatureById } from "@/data/contentHelpers";
|
||||
|
||||
import { startCombatFromRoom } from "./combat";
|
||||
import { createInitialTownState } from "./townServices";
|
||||
import { applyLevelProgression } from "./progression";
|
||||
import {
|
||||
resolveEnemyTurn,
|
||||
resolvePlayerAttack,
|
||||
@@ -179,6 +181,25 @@ function cloneRun(run: RunState): RunState {
|
||||
globalFlags: [...run.dungeon.globalFlags],
|
||||
},
|
||||
activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined,
|
||||
lastCombatOutcome: run.lastCombatOutcome
|
||||
? {
|
||||
...run.lastCombatOutcome,
|
||||
enemyNames: [...run.lastCombatOutcome.enemyNames],
|
||||
}
|
||||
: undefined,
|
||||
lastLevelUp: run.lastLevelUp
|
||||
? {
|
||||
...run.lastLevelUp,
|
||||
unlockedManoeuvreIds: [...run.lastLevelUp.unlockedManoeuvreIds],
|
||||
}
|
||||
: undefined,
|
||||
townState: {
|
||||
...run.townState,
|
||||
knownServices: [...run.townState.knownServices],
|
||||
stash: run.townState.stash.map((entry) => ({ ...entry })),
|
||||
pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })),
|
||||
serviceFlags: [...run.townState.serviceFlags],
|
||||
},
|
||||
log: run.log.map((entry) => ({
|
||||
...entry,
|
||||
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||
@@ -280,19 +301,56 @@ function applyCombatRewards(
|
||||
run.defeatedCreatureIds.push(...defeatedCreatureIds);
|
||||
run.xpGained += xpAwarded;
|
||||
run.adventurerSnapshot.xp += xpAwarded;
|
||||
const progression = applyLevelProgression({
|
||||
content,
|
||||
adventurer: run.adventurerSnapshot,
|
||||
at,
|
||||
});
|
||||
run.adventurerSnapshot = progression.adventurer;
|
||||
run.lastLevelUp = progression.levelUps.at(-1);
|
||||
run.lastCombatOutcome = {
|
||||
result: "victory",
|
||||
at,
|
||||
summary: `Won combat against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")} and gained ${xpAwarded} XP.`,
|
||||
enemyNames: completedCombat.enemies.map((enemy) => enemy.name),
|
||||
xpAwarded,
|
||||
};
|
||||
|
||||
if (xpAwarded === 0) {
|
||||
return [] as LogEntry[];
|
||||
}
|
||||
const rewardLogs =
|
||||
xpAwarded === 0
|
||||
? [
|
||||
createRewardLog(
|
||||
`${completedCombat.id}.victory`,
|
||||
at,
|
||||
`Combat victory secured against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")}.`,
|
||||
[completedCombat.id],
|
||||
),
|
||||
]
|
||||
: [
|
||||
createRewardLog(
|
||||
`${completedCombat.id}.victory`,
|
||||
at,
|
||||
`Combat victory secured against ${completedCombat.enemies.map((enemy) => enemy.name).join(", ")}.`,
|
||||
[completedCombat.id],
|
||||
),
|
||||
createRewardLog(
|
||||
`${completedCombat.id}.rewards`,
|
||||
at,
|
||||
`Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`,
|
||||
[completedCombat.id, ...defeatedCreatureIds],
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
const levelLogs = progression.levelUps.map((levelUp, index) =>
|
||||
createRewardLog(
|
||||
`${completedCombat.id}.rewards`,
|
||||
`${completedCombat.id}.level-up.${index + 1}`,
|
||||
at,
|
||||
`Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`,
|
||||
[completedCombat.id, ...defeatedCreatureIds],
|
||||
levelUp.summary,
|
||||
[completedCombat.id, run.adventurerSnapshot.id, ...levelUp.unlockedManoeuvreIds],
|
||||
),
|
||||
];
|
||||
);
|
||||
|
||||
return [...rewardLogs, ...levelLogs];
|
||||
}
|
||||
|
||||
export function createRunState(options: CreateRunOptions): RunState {
|
||||
@@ -320,6 +378,9 @@ export function createRunState(options: CreateRunOptions): RunState {
|
||||
globalFlags: [],
|
||||
},
|
||||
adventurerSnapshot: options.adventurer,
|
||||
lastCombatOutcome: undefined,
|
||||
lastLevelUp: undefined,
|
||||
townState: createInitialTownState(),
|
||||
defeatedCreatureIds: [],
|
||||
xpGained: 0,
|
||||
log: [],
|
||||
@@ -339,6 +400,7 @@ export function returnToTown(
|
||||
|
||||
nextRun.phase = "town";
|
||||
nextRun.lastTownAt = at;
|
||||
nextRun.townState.visits += 1;
|
||||
|
||||
const logEntry = createLogEntry(
|
||||
`run.return-to-town.${nextRun.log.length + 1}`,
|
||||
@@ -546,6 +608,7 @@ export function startCombatInCurrentRoom(
|
||||
|
||||
levelState.rooms[roomId] = started.room;
|
||||
run.activeCombat = started.combat;
|
||||
run.lastCombatOutcome = undefined;
|
||||
appendLogs(run, started.logEntries);
|
||||
|
||||
return {
|
||||
@@ -635,6 +698,25 @@ export function resolveRunEnemyTurn(
|
||||
|
||||
if (result.combatEnded) {
|
||||
run.status = "failed";
|
||||
run.lastCombatOutcome = {
|
||||
result: "defeat",
|
||||
at: options.at ?? new Date().toISOString(),
|
||||
summary: `${run.adventurerSnapshot.name} was defeated by ${result.combat.enemies
|
||||
.filter((enemy) => enemy.hpCurrent > 0)
|
||||
.map((enemy) => enemy.name)
|
||||
.join(", ")}.`,
|
||||
enemyNames: result.combat.enemies
|
||||
.filter((enemy) => enemy.hpCurrent > 0)
|
||||
.map((enemy) => enemy.name),
|
||||
};
|
||||
appendLogs(run, [
|
||||
createRewardLog(
|
||||
`${result.combat.id}.defeat`,
|
||||
options.at ?? new Date().toISOString(),
|
||||
run.lastCombatOutcome.summary,
|
||||
[result.combat.id, run.adventurerSnapshot.id],
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user