Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <kindred> <class> --name="Pip Quickfoot" --player=Sam --out=PCs/Pip.md
Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions completions/_dw
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)"}
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,9 @@ Rolling & procedures
morale <ML> [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 <name> <dur> | status | check-every <n> | end

Lookups (read your ./data; see README)
npc <kindred> Random NPC: name + persona
Expand Down Expand Up @@ -49,6 +53,7 @@ const commands: Record<string, (a: string[]) => void> = {
react: cmdReact,
morale: cmdMorale,
wander: cmdWander,
turn: cmdTurn,
npc: cmdNpc,
mon: cmdMon,
hex: cmdHex,
Expand Down
136 changes: 136 additions & 0 deletions src/commands/turn.ts
Original file line number Diff line number Diff line change
@@ -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 <name> <duration> track a light or spell (6, 30m, 1h)
dw turn status current turn and tracked durations
dw turn check-every <n> 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 <name> <duration> (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> (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);
}
}
111 changes: 111 additions & 0 deletions src/turn.ts
Original file line number Diff line number Diff line change
@@ -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, "..");
Comment thread
ptaranat marked this conversation as resolved.

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 };
}
Loading
Loading