From 02177701d764c73946268d5136a02d76873220ac Mon Sep 17 00:00:00 2001 From: Panat Taranat Date: Fri, 28 Aug 2026 09:38:06 -0400 Subject: [PATCH] feat: add init, side-based initiative --- README.md | 12 +++++-- completions/_dw | 1 + src/cli.ts | 4 +++ src/commands/init.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++ test/init.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/commands/init.ts create mode 100644 test/init.test.ts diff --git a/README.md b/README.md index 23a223c..5eacff7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,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 init # side-based initiative, 1d6 per side, ties rerolled 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 @@ -149,8 +150,13 @@ install). A missing or unreadable state file starts a fresh session with a note. ## Status -Working: `roll`, `react`, `morale`, `wander`, `turn`, `npc`, `mon`, `hex`, -`build`, `search`, `spell`, `new`. +Working: `roll`, `react`, `morale`, `init`, `wander`, `turn`, `npc`, `mon`, +`hex`, `build`, `search`, `spell`, `new`. + +`dw init` rolls side-based initiative: 1d6 per side (default Party vs Enemies, +or name your own with `dw init goblins party wolves`), highest acts first, ties +rerolled until broken. `-r`/`--rounds N` rolls several rounds at once, since +initiative is rerolled every round. Nothing is tracked; it rolls and prints. ```sh dw new --name="Pip Quickfoot" --player=Sam --out=PCs/Pip.md @@ -187,7 +193,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; `treasure`; `init`. +for fuller Hoard/special coverage; `treasure`. ## License diff --git a/completions/_dw b/completions/_dw index 0fdc39d..1491112 100644 --- a/completions/_dw +++ b/completions/_dw @@ -12,6 +12,7 @@ _dw() { 'roll:Roll dice (e.g. 3d6+2)' 'react:Reaction roll (2d6)' 'morale:Morale check vs ML' + 'init:Side-based initiative (1d6 per side)' 'wander:Wandering-monster check' 'turn:Dungeon-turn tracker' 'npc:Random NPC from a kindred' diff --git a/src/cli.ts b/src/cli.ts index 63fbd0f..76403ea 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { cmdBuild } from "./commands/build.ts"; import { cmdHex } from "./commands/hex.ts"; +import { cmdInit } from "./commands/init.ts"; import { cmdList } from "./commands/list.ts"; import { cmdMon } from "./commands/mon.ts"; import { cmdMorale } from "./commands/morale.ts"; @@ -21,6 +22,8 @@ Rolling & procedures roll Roll dice, e.g. dw roll 3d6+2 (default 1d20) react [mod] Reaction roll (2d6) with interpretation morale [mod] Morale check (2d6 vs morale score) + init [sides…] Side-based initiative (1d6 per side; default Party vs Enemies) + -r/--rounds N roll N rounds at once 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), @@ -52,6 +55,7 @@ const commands: Record void> = { roll: cmdRoll, react: cmdReact, morale: cmdMorale, + init: cmdInit, wander: cmdWander, turn: cmdTurn, npc: cmdNpc, diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..9fbe538 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,74 @@ +import { rollDie } from "../dice.ts"; + +type Die = (sides: number) => number; + +export interface Side { + name: string; + rolls: number[]; +} + +function orderByLastRoll(group: Side[], die: Die): Side[] { + const byRoll = new Map(); + for (const s of group) { + const last = s.rolls[s.rolls.length - 1]; + const tied = byRoll.get(last); + if (tied) tied.push(s); + else byRoll.set(last, [s]); + } + const out: Side[] = []; + for (const [, tied] of [...byRoll].toSorted((a, b) => b[0] - a[0])) { + if (tied.length > 1) { + for (const s of tied) s.rolls.push(die(6)); + out.push(...orderByLastRoll(tied, die)); + } else { + out.push(tied[0]); + } + } + return out; +} + +export function rollInitiative( + names: string[], + die: Die = rollDie, +): { sides: Side[]; order: Side[] } { + const sides = names.map((name) => ({ name, rolls: [die(6)] })); + return { sides, order: orderByLastRoll([...sides], die) }; +} + +const show = (s: Side): string => `${s.name} ${s.rolls.join("→")}`; + +export function initLines(names: string[], rounds: number, die: Die = rollDie): string[] { + const lines: string[] = []; + for (let round = 1; round <= rounds; round++) { + const { sides, order } = rollInitiative(names, die); + const rolls = sides.map(show).join(", "); + const acting = + names.length === 2 ? `${order[0].name} first` : order.map((s) => s.name).join(", "); + if (rounds === 1) { + const tie = sides.some((s) => s.rolls.length > 1); + lines.push(`Initiative: ${rolls}${tie ? " (tie rerolled)" : ""}`); + lines.push(names.length === 2 ? ` ${order[0].name} acts first` : ` Order: ${acting}`); + } else { + lines.push(`Round ${round}: ${rolls} ${acting}`); + } + } + return lines; +} + +export function cmdInit(args: string[]): void { + const names: string[] = []; + let rounds = 1; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "-r" || a === "--rounds") rounds = parseInt(args[++i] ?? "", 10); + else if (a.startsWith("--rounds=")) rounds = parseInt(a.split("=")[1], 10); + else if (a.startsWith("-")) rounds = NaN; + else names.push(a); + } + const sides = names.length ? names : ["Party", "Enemies"]; + if (sides.length < 2 || !Number.isInteger(rounds) || rounds < 1) { + console.error("usage: dw init [side side ...] [-r/--rounds ]"); + process.exit(1); + } + for (const line of initLines(sides, rounds)) console.log(line); +} diff --git a/test/init.test.ts b/test/init.test.ts new file mode 100644 index 0000000..99bb603 --- /dev/null +++ b/test/init.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { initLines, rollInitiative } from "../src/commands/init.ts"; + +const seq = (...rolls: number[]) => { + let i = 0; + return () => { + if (i >= rolls.length) throw new Error("roll script exhausted"); + return rolls[i++]; + }; +}; + +test("highest roll acts first", () => { + const { sides, order } = rollInitiative(["Party", "Enemies"], seq(2, 5)); + expect(sides.map((s) => s.rolls)).toEqual([[2], [5]]); + expect(order.map((s) => s.name)).toEqual(["Enemies", "Party"]); +}); + +test("a tie is rerolled until broken", () => { + const { sides, order } = rollInitiative(["Party", "Enemies"], seq(3, 3, 4, 4, 2, 6)); + expect(sides.map((s) => s.rolls)).toEqual([ + [3, 4, 2], + [3, 4, 6], + ]); + expect(order[0].name).toBe("Enemies"); +}); + +test("a tie below the top is also broken", () => { + const { order } = rollInitiative(["a", "b", "c"], seq(6, 4, 4, 1, 5)); + expect(order.map((s) => s.name)).toEqual(["a", "c", "b"]); +}); + +test("every side appears exactly once in the order", () => { + for (let i = 0; i < 200; i++) { + const { order } = rollInitiative(["a", "b", "c", "d"]); + expect(order.map((s) => s.name).toSorted()).toEqual(["a", "b", "c", "d"]); + for (const s of order) { + for (const r of s.rolls) { + expect(r).toBeGreaterThanOrEqual(1); + expect(r).toBeLessThanOrEqual(6); + } + } + } +}); + +test("single round, two sides", () => { + expect(initLines(["Party", "Enemies"], 1, seq(4, 2))).toEqual([ + "Initiative: Party 4, Enemies 2", + " Party acts first", + ]); +}); + +test("single round shows the tie", () => { + expect(initLines(["Party", "Enemies"], 1, seq(3, 3, 5, 1))).toEqual([ + "Initiative: Party 3→5, Enemies 3→1 (tie rerolled)", + " Party acts first", + ]); +}); + +test("more than two sides prints the full order", () => { + expect(initLines(["a", "b", "c"], 1, seq(2, 6, 4))).toEqual([ + "Initiative: a 2, b 6, c 4", + " Order: b, c, a", + ]); +}); + +test("rounds flag rerolls each round", () => { + expect(initLines(["Party", "Enemies"], 2, seq(4, 2, 1, 6))).toEqual([ + "Round 1: Party 4, Enemies 2 Party first", + "Round 2: Party 1, Enemies 6 Enemies first", + ]); +});