diff --git a/README.md b/README.md index 5572ae4..23a223c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ mid-session: dice procedures, NPC improv, and stat-block or hex lookups. dw react # 2d6 reaction roll, interpreted dw morale 8 # morale check vs ML 8 dw wander sample-wood # wandering-monster check + encounter roll +dw turn # advance a dungeon turn: clock, lights, spells dw npc thornling # random NPC: name + persona dw mon bramble # monster stat block (fuzzy match) dw hex 0101 # keyed hex entry @@ -129,10 +130,27 @@ committed fallback). The layout and JSON shapes live in [`data.sample/README.md`](data.sample/README.md) and are typed in [`src/schema.ts`](src/schema.ts). +## Dungeon turns + +`dw turn` keeps the dungeon clock: a turn is 10 minutes. Name whatever you want +tracked (a torch, a spell) and give its duration in turns, minutes, or hours. + +```sh +dw turn track torch 1h # track anything by name (durations: 6, 30m, 1h) +dw turn # advance one turn; warns on expiries + wandering checks +dw turn 3 # advance three turns +dw turn status # elapsed time and everything tracked +dw turn check-every 3 # wandering-check cadence (default every 2 turns) +dw turn end # end the session and clear state +``` + +State survives between invocations in `dw-session.json` (gitignored, next to the +install). A missing or unreadable state file starts a fresh session with a note. + ## Status -Working: `roll`, `react`, `morale`, `wander`, `npc`, `mon`, `hex`, `build`, -`search`, `spell`, `new`. +Working: `roll`, `react`, `morale`, `wander`, `turn`, `npc`, `mon`, `hex`, +`build`, `search`, `spell`, `new`. ```sh dw new --name="Pip Quickfoot" --player=Sam --out=PCs/Pip.md @@ -169,8 +187,7 @@ non-standard format (e.g. Sample Keep) resolve by name even without a keyed wilderness entry. Roadmap: kindred/class trait names into `dw new`; reflow two-column monster pages -for fuller Hoard/special coverage; `turn`, a dungeon-turn tracker for light and -spell durations; `treasure`; `init`. +for fuller Hoard/special coverage; `treasure`; `init`. ## License diff --git a/completions/_dw b/completions/_dw index 30fe209..0fdc39d 100644 --- a/completions/_dw +++ b/completions/_dw @@ -13,6 +13,7 @@ _dw() { 'react:Reaction roll (2d6)' 'morale:Morale check vs ML' 'wander:Wandering-monster check' + 'turn:Dungeon-turn tracker' 'npc:Random NPC from a kindred' 'mon:Monster stat block' 'hex:Keyed hex entry' @@ -36,6 +37,15 @@ _dw() { hex) _values 'hex' ${(f)"$(dw list hexes 2>/dev/null | awk '{print $1}')"} ;; npc) _values 'kindred' ${(f)"$(dw list kindreds 2>/dev/null)"} ;; list) _values 'category' monsters spells hexes kindreds classes ;; + turn) + if (( CURRENT == 3 )); then + _values 'turn subcommand' \ + 'track:track a light or spell duration' \ + 'status:current turn and tracked durations' \ + 'check-every:wandering-check cadence' \ + 'end:end the session' + fi + ;; new) if (( CURRENT == 3 )); then _values 'kindred' random ${(f)"$(dw list kindreds 2>/dev/null)"} diff --git a/src/cli.ts b/src/cli.ts index a0722f8..63fbd0f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { cmdReact } from "./commands/react.ts"; import { cmdRoll } from "./commands/roll.ts"; import { cmdSearch } from "./commands/search.ts"; import { cmdSpell } from "./commands/spell.ts"; +import { cmdTurn } from "./commands/turn.ts"; import { cmdWander } from "./commands/wander.ts"; const HELP = `dw: Dolmenwood GM tools @@ -22,6 +23,9 @@ Rolling & procedures morale [mod] Morale check (2d6 vs morale score) wander [region] Wandering-monster check; rolls the encounter if it hits --chance=N in-6 chance (default 1) + turn [n] Dungeon-turn tracker: advance n turns (10 min each), + tick tracked durations, remind of wandering checks + track | status | check-every | end Lookups (read your ./data; see README) npc Random NPC: name + persona @@ -49,6 +53,7 @@ const commands: Record void> = { react: cmdReact, morale: cmdMorale, wander: cmdWander, + turn: cmdTurn, npc: cmdNpc, mon: cmdMon, hex: cmdHex, diff --git a/src/commands/turn.ts b/src/commands/turn.ts new file mode 100644 index 0000000..0d0c07d --- /dev/null +++ b/src/commands/turn.ts @@ -0,0 +1,136 @@ +import { + advance, + clearState, + formatElapsed, + loadState, + parseTurns, + saveState, + statePath, +} from "../turn.ts"; +import type { TurnState } from "../turn.ts"; + +const USAGE = `usage: dw turn [n] advance n turns (default 1) + dw turn track track a light or spell (6, 30m, 1h) + dw turn status current turn and tracked durations + dw turn check-every wandering-check cadence (default 2) + dw turn end end the session, clear state`; + +function load(): TurnState { + const { state, note } = loadState(); + if (note) console.error(`dw: ${note}`); + return state; +} + +function header(state: TurnState): string { + return `Turn ${state.turn} (${formatElapsed(state.turn)} elapsed)`; +} + +function cmdStatus(): void { + const state = load(); + console.log(header(state)); + const next = state.turn + state.checkEvery - (state.turn % state.checkEvery); + console.log(`Wandering check every ${state.checkEvery} turns; next due turn ${next}`); + if (state.tracked.length === 0) { + console.log("Nothing tracked."); + return; + } + const width = Math.max(...state.tracked.map((t) => t.name.length)); + for (const t of state.tracked) { + const s = t.remaining === 1 ? "" : "s"; + console.log( + ` ${t.name.padEnd(width)} ${t.remaining} turn${s} left (expires turn ${state.turn + t.remaining})`, + ); + } +} + +function cmdTrack(rest: string[]): void { + const [name, duration] = rest; + if (!name || !duration) { + console.error("usage: dw turn track (e.g. dw turn track torch 1h)"); + process.exit(1); + } + const turns = parseTurns(duration); + const state = load(); + state.tracked = state.tracked.filter((t) => t.name !== name); + state.tracked.push({ name, remaining: turns }); + saveState(state); + const s = turns === 1 ? "" : "s"; + console.log( + `Tracking ${name}: ${turns} turn${s} (${formatElapsed(turns)}), expires turn ${state.turn + turns}`, + ); +} + +// Digits only: turn state persists, so "1h" or "3x" must not slip through as 1 or 3. +function parseCount(arg: string | undefined): number { + return /^\d+$/.test(arg ?? "") ? parseInt(arg!, 10) : NaN; +} + +function cmdCheckEvery(rest: string[]): void { + const n = parseCount(rest[0]); + if (Number.isNaN(n) || n < 1) { + console.error("usage: dw turn check-every (n >= 1)"); + process.exit(1); + } + const state = load(); + state.checkEvery = n; + saveState(state); + console.log(`Wandering check every ${n} turn${n === 1 ? "" : "s"}`); +} + +function cmdEnd(): void { + const { state } = loadState(); + if (state.turn === 0 && state.tracked.length === 0) { + console.log("No session in progress."); + } else { + console.log(`Session ended: ${state.turn} turns (${formatElapsed(state.turn)})`); + } + clearState(); +} + +function cmdAdvance(arg: string | undefined): void { + const n = arg === undefined ? 1 : parseCount(arg); + if (Number.isNaN(n) || n < 1) { + console.error(USAGE); + process.exit(1); + } + const state = load(); + const result = advance(state, n); + saveState(state); + console.log(header(state)); + for (const e of result.expired) { + const when = e.turn === state.turn ? "now" : `turn ${e.turn}`; + console.log(` ✗ ${e.name} expired ${when}`); + } + for (const name of result.expiringNext) { + console.log(` ! ${name} expires next turn`); + } + if (result.checksDue.length === 1) { + console.log(` Wandering check due (every ${state.checkEvery} turns; dw wander)`); + } else if (result.checksDue.length > 1) { + console.log( + ` ${result.checksDue.length} wandering checks due: turns ${result.checksDue.join(", ")} (dw wander)`, + ); + } +} + +export function cmdTurn(args: string[]): void { + const [sub, ...rest] = args; + switch (sub) { + case "status": + return cmdStatus(); + case "track": + return cmdTrack(rest); + case "check-every": + return cmdCheckEvery(rest); + case "end": + return cmdEnd(); + case "help": + case "--help": + case "-h": + console.log(USAGE); + console.log(`\nState survives between invocations in ${statePath()}`); + return; + default: + return cmdAdvance(sub); + } +} diff --git a/src/turn.ts b/src/turn.ts new file mode 100644 index 0000000..fc37277 --- /dev/null +++ b/src/turn.ts @@ -0,0 +1,111 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// Anchored to the repo root (like pdf/config.ts) so the session follows the +// install, not the invocation directory. Gitignored as dw-session.json. +const REPO_ROOT = join(import.meta.dir, ".."); + +export const TURN_MINUTES = 10; +export const DEFAULT_CHECK_EVERY = 2; + +export interface Tracked { + name: string; + remaining: number; +} + +export interface TurnState { + turn: number; + checkEvery: number; + tracked: Tracked[]; +} + +export function statePath(): string { + const env = process.env.DW_SESSION?.trim(); + return env || join(REPO_ROOT, "dw-session.json"); +} + +export function freshState(): TurnState { + return { turn: 0, checkEvery: DEFAULT_CHECK_EVERY, tracked: [] }; +} + +export function loadState(): { state: TurnState; note?: string } { + const path = statePath(); + if (!existsSync(path)) return { state: freshState() }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")); + if ( + !Number.isInteger(raw.turn) || + raw.turn < 0 || + !Number.isInteger(raw.checkEvery) || + raw.checkEvery < 1 || + !Array.isArray(raw.tracked) || + raw.tracked.some( + (t: Tracked) => + typeof t?.name !== "string" || !Number.isInteger(t?.remaining) || t.remaining < 1, + ) + ) { + throw new Error("bad shape"); + } + return { state: { turn: raw.turn, checkEvery: raw.checkEvery, tracked: raw.tracked } }; + } catch { + return { state: freshState(), note: `Turn state at ${path} was unreadable; starting fresh.` }; + } +} + +export function saveState(state: TurnState): void { + const path = statePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2) + "\n"); +} + +export function clearState(): void { + rmSync(statePath(), { force: true }); +} + +/** Parse a duration into turns: "6" or "6t" turns, "30m" minutes, "1h" hours. */ +export function parseTurns(spec: string): number { + const m = /^(\d+)\s*(t|m|min|h|hr)?$/i.exec(spec.trim()); + if (!m) throw new Error(`Can't parse duration "${spec}" (turns like 6, or 30m, 1h)`); + const n = parseInt(m[1], 10); + const unit = (m[2] ?? "t").toLowerCase(); + const turns = + unit === "h" || unit === "hr" + ? n * (60 / TURN_MINUTES) + : unit === "m" || unit === "min" + ? Math.ceil(n / TURN_MINUTES) + : n; + if (turns < 1) + throw new Error(`Duration "${spec}" is shorter than a turn (${TURN_MINUTES} minutes)`); + return turns; +} + +export function formatElapsed(turns: number): string { + const minutes = turns * TURN_MINUTES; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + return m === 0 ? `${h}h` : `${h}h${m}m`; +} + +export interface AdvanceResult { + expired: { name: string; turn: number }[]; + expiringNext: string[]; + checksDue: number[]; +} + +/** Advance n turns in place, ticking durations and noting due wandering checks. */ +export function advance(state: TurnState, n: number): AdvanceResult { + const expired: { name: string; turn: number }[] = []; + const checksDue: number[] = []; + for (let i = 0; i < n; i++) { + state.turn += 1; + for (const t of state.tracked) { + t.remaining -= 1; + if (t.remaining === 0) expired.push({ name: t.name, turn: state.turn }); + } + state.tracked = state.tracked.filter((t) => t.remaining > 0); + if (state.turn % state.checkEvery === 0) checksDue.push(state.turn); + } + const expiringNext = state.tracked.filter((t) => t.remaining === 1).map((t) => t.name); + return { expired, expiringNext, checksDue }; +} diff --git a/test/turn.test.ts b/test/turn.test.ts new file mode 100644 index 0000000..3b1f022 --- /dev/null +++ b/test/turn.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + advance, + clearState, + formatElapsed, + freshState, + loadState, + parseTurns, + saveState, + statePath, +} from "../src/turn.ts"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "dw-turn-")); + process.env.DW_SESSION = join(dir, "dw-session.json"); +}); + +afterEach(() => { + delete process.env.DW_SESSION; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("parseTurns", () => { + test("bare numbers and t suffix are turns", () => { + expect(parseTurns("6")).toBe(6); + expect(parseTurns("1t")).toBe(1); + expect(parseTurns("12t")).toBe(12); + }); + + test("hours convert at 6 turns per hour", () => { + expect(parseTurns("1h")).toBe(6); + expect(parseTurns("2h")).toBe(12); + expect(parseTurns("1hr")).toBe(6); + }); + + test("minutes convert at 10 per turn, rounding up", () => { + expect(parseTurns("30m")).toBe(3); + expect(parseTurns("25m")).toBe(3); + expect(parseTurns("10min")).toBe(1); + expect(parseTurns("5m")).toBe(1); + }); + + test("rejects garbage and zero durations", () => { + expect(() => parseTurns("soon")).toThrow(); + expect(() => parseTurns("")).toThrow(); + expect(() => parseTurns("0")).toThrow(); + expect(() => parseTurns("0m")).toThrow(); + }); +}); + +describe("formatElapsed", () => { + test("formats minutes and hours", () => { + expect(formatElapsed(0)).toBe("0m"); + expect(formatElapsed(3)).toBe("30m"); + expect(formatElapsed(6)).toBe("1h"); + expect(formatElapsed(10)).toBe("1h40m"); + }); +}); + +describe("advance", () => { + test("advances the turn counter", () => { + const state = freshState(); + advance(state, 1); + expect(state.turn).toBe(1); + advance(state, 3); + expect(state.turn).toBe(4); + }); + + test("ticks durations, warning at one turn left and on expiry", () => { + const state = freshState(); + state.tracked = [{ name: "torch", remaining: 2 }]; + + let r = advance(state, 1); + expect(r.expired).toEqual([]); + expect(r.expiringNext).toEqual(["torch"]); + + r = advance(state, 1); + expect(r.expired).toEqual([{ name: "torch", turn: 2 }]); + expect(r.expiringNext).toEqual([]); + expect(state.tracked).toEqual([]); + }); + + test("reports mid-span expiries with their turn", () => { + const state = freshState(); + state.tracked = [ + { name: "candle", remaining: 1 }, + { name: "lantern", remaining: 5 }, + ]; + const r = advance(state, 4); + expect(r.expired).toEqual([{ name: "candle", turn: 1 }]); + expect(r.expiringNext).toEqual(["lantern"]); + expect(state.tracked).toEqual([{ name: "lantern", remaining: 1 }]); + }); + + test("flags wandering checks on the cadence", () => { + const state = freshState(); + expect(advance(state, 4).checksDue).toEqual([2, 4]); + + const every3 = { ...freshState(), checkEvery: 3 }; + expect(advance(every3, 7).checksDue).toEqual([3, 6]); + + const everyTurn = { ...freshState(), checkEvery: 1 }; + expect(advance(everyTurn, 2).checksDue).toEqual([1, 2]); + }); +}); + +describe("state persistence", () => { + test("round-trips through the state file", () => { + const state = freshState(); + state.tracked = [{ name: "light spell", remaining: 6 }]; + advance(state, 2); + state.checkEvery = 3; + saveState(state); + + const { state: loaded, note } = loadState(); + expect(note).toBeUndefined(); + expect(loaded).toEqual(state); + }); + + test("missing file starts fresh without a note", () => { + const { state, note } = loadState(); + expect(note).toBeUndefined(); + expect(state).toEqual(freshState()); + }); + + test("corrupt file starts fresh with a note", () => { + writeFileSync(statePath(), "{not json"); + const { state, note } = loadState(); + expect(state).toEqual(freshState()); + expect(note).toContain("starting fresh"); + }); + + test("wrong-shaped file starts fresh with a note", () => { + writeFileSync(statePath(), JSON.stringify({ turn: "three", tracked: {} })); + const { state, note } = loadState(); + expect(state).toEqual(freshState()); + expect(note).toContain("starting fresh"); + }); + + test("out-of-range values start fresh with a note", () => { + for (const bad of [ + { turn: -1, checkEvery: 2, tracked: [] }, + { turn: 1.5, checkEvery: 2, tracked: [] }, + { turn: 0, checkEvery: 0, tracked: [] }, + { turn: 0, checkEvery: 2, tracked: [{ name: "torch", remaining: 0 }] }, + { turn: 0, checkEvery: NaN, tracked: [] }, + ]) { + writeFileSync(statePath(), JSON.stringify(bad)); + const { state, note } = loadState(); + expect(state).toEqual(freshState()); + expect(note).toContain("starting fresh"); + } + }); + + test("clearState removes the file", () => { + saveState(freshState()); + clearState(); + const { state } = loadState(); + expect(state.turn).toBe(0); + expect(() => clearState()).not.toThrow(); + }); +});