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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <kindred> <class> --name="Pip Quickfoot" --player=Sam --out=PCs/Pip.md
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions completions/_dw
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +22,8 @@ Rolling & procedures
roll <expr> Roll dice, e.g. dw roll 3d6+2 (default 1d20)
react [mod] Reaction roll (2d6) with interpretation
morale <ML> [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),
Expand Down Expand Up @@ -52,6 +55,7 @@ const commands: Record<string, (a: string[]) => void> = {
roll: cmdRoll,
react: cmdReact,
morale: cmdMorale,
init: cmdInit,
wander: cmdWander,
turn: cmdTurn,
npc: cmdNpc,
Expand Down
74 changes: 74 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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<number, Side[]>();
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 <n>]");
process.exit(1);
}
for (const line of initLines(sides, rounds)) console.log(line);
}
71 changes: 71 additions & 0 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
Loading