terminal-ludo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ <div align="center">
2
+
3
+ # 🎲 terminal-ludo
4
+
5
+ **Classic Ludo, right in your terminal.**
6
+ Fully offline · zero dependencies · play against AI or hotseat with friends.
7
+
8
+ [![npm version](https://img.shields.io/npm/v/terminal-ludo.svg)](https://www.npmjs.com/package/terminal-ludo)
9
+ [![node](https://img.shields.io/badge/node-%E2%89%A514-brightgreen.svg)](https://nodejs.org)
10
+ [![dependencies](https://img.shields.io/badge/dependencies-0-blue.svg)](package.json)
11
+ [![license](https://img.shields.io/badge/license-MIT-informational.svg)](LICENSE)
12
+
13
+ </div>
14
+
15
+ ```
16
+ npx terminal-ludo
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Contents
22
+
23
+ - [Preview](#preview)
24
+ - [Features](#features)
25
+ - [Install](#install)
26
+ - [How to play](#how-to-play)
27
+ - [Rules](#rules)
28
+ - [Command-line options](#command-line-options)
29
+ - [Saving & resuming](#saving--resuming)
30
+ - [Board legend](#board-legend)
31
+ - [Project structure](#project-structure)
32
+ - [Development](#development)
33
+ - [Contributing](#contributing)
34
+ - [License](#license)
35
+
36
+ ## Preview
37
+
38
+ The board is a colour 15×15 grid. Tokens on the track render as filled coloured
39
+ pills so they are easy to spot; tokens still in the yard sit in their corner.
40
+
41
+ ```
42
+ · · ·
43
+ ○ ○ · ◈ ○ ○
44
+ Y ·
45
+ R R R · G G
46
+ · ·
47
+ · ·
48
+ · 2 · · · · · · · ✦ · ·
49
+ · ★ ·
50
+ · · ✦ · · · · · · · 2 ·
51
+ · ·
52
+ ○ B · · Y ○
53
+ · ·
54
+ B ○ · 2 ○ Y
55
+ ◈ ·
56
+ · · ·
57
+ ```
58
+
59
+ > Actual output is in full colour — red, green, yellow and blue.
60
+
61
+ ## Features
62
+
63
+ - 🎯 **Full classic rules** — 4 tokens per player, capture, safe squares, home columns, exact-roll finish.
64
+ - 🤖 **Play vs. AI** — 0–4 humans; the rest are computer players.
65
+ - 🎚️ **Three difficulty levels** — `easy`, `normal`, `hard`.
66
+ - 🎨 **Choose your colours** — pick which colours play and which are human.
67
+ - 🎲 **Animated dice roll**.
68
+ - ⌨️ **Single-keypress controls** — press Enter to roll, no typing or Enter-to-confirm.
69
+ - 💾 **Save & resume** — quit anytime, pick up later.
70
+ - 👀 **`--watch` demo** — sit back and watch four AIs battle.
71
+ - 👥 **Hotseat multiplayer** on one machine.
72
+ - 🖥️ **Any ANSI terminal** — honours `NO_COLOR`.
73
+ - 📦 **Zero runtime dependencies** — works anywhere Node ≥ 14 runs, no internet needed.
74
+
75
+ ## Install
76
+
77
+ Run instantly without installing:
78
+
79
+ ```sh
80
+ npx terminal-ludo
81
+ ```
82
+
83
+ Or install globally for a permanent `ludo` command:
84
+
85
+ ```sh
86
+ npm install -g terminal-ludo
87
+ ludo
88
+ ```
89
+
90
+ **Requirements:** Node.js ≥ 14 and a terminal that supports ANSI colour (virtually all do).
91
+
92
+ ## How to play
93
+
94
+ Run `ludo` and answer the prompts:
95
+
96
+ 1. **How many players** (2–4).
97
+ 2. **Which colours play** — only asked when fewer than 4 are in the game.
98
+ 3. **Which colours are human** — a comma-separated list; the rest are AI. Enter `none` for an all-CPU game.
99
+
100
+ Colours can be given by **name** (`red`), **initial** (`r`), or **number** (`1`).
101
+ For example `red, blue`, `r b`, and `1 4` all pick red and blue.
102
+
103
+ ### Controls
104
+
105
+ On your turn everything is a **single keypress** — no typing, no Enter-to-confirm:
106
+
107
+ | Key | Action |
108
+ |-------------|------------------------------------------|
109
+ | `Enter` | Roll the dice |
110
+ | `1`–`4` | Move that token (when you have a choice) |
111
+ | any key | Continue after a turn |
112
+ | `s` | Save the game and quit |
113
+ | `q` | Quit without saving |
114
+
115
+ ## Rules
116
+
117
+ - You need a **6** to move a token out of your yard onto the board.
118
+ - Rolling a **6** — or **capturing** an opponent, or sending a token **home** — earns an **extra turn**.
119
+ - Rolling three **6s** in a row forfeits the turn.
120
+ - Landing on an opponent sends it back to its yard — **except** on a **safe square** (`✦`) or a coloured **start** (`◈`).
121
+ - Tokens must reach home on an **exact** roll; you cannot overshoot.
122
+ - First player to get **all four tokens home** wins. Play continues for the remaining places.
123
+
124
+ ## Command-line options
125
+
126
+ ```
127
+ ludo [options]
128
+
129
+ -h, --help Show help
130
+ -w, --watch Watch four AI players battle it out (no input)
131
+ -d, --difficulty <level> AI level: easy | normal | hard (default: normal)
132
+ -l, --load <file> Resume a previously saved game
133
+ ```
134
+
135
+ Examples:
136
+
137
+ ```sh
138
+ ludo # interactive game
139
+ ludo -d hard # play against a tougher AI
140
+ ludo --watch -d hard # demo: four hard AIs play themselves
141
+ ludo --load ludo-save.json # resume a saved game
142
+ ```
143
+
144
+ ## Saving & resuming
145
+
146
+ - Press **`s`** at the roll prompt to save to `ludo-save.json` and quit.
147
+ - Press **`q`** to quit without saving.
148
+ - Pressing **Ctrl-C** mid-game autosaves to `ludo-autosave.json`.
149
+ - Resume any save with `ludo --load <file>`.
150
+
151
+ ## Board legend
152
+
153
+ | Symbol | Meaning |
154
+ |-----------------------|------------------------------------------------|
155
+ | `◈` | A colour's start square (safe) |
156
+ | `✦` | Star / safe square |
157
+ | `·` | Regular track cell |
158
+ | `★` | Centre goal |
159
+ | filled `R` `G` `Y` `B` pill | A token on the board (red, green, yellow, blue) |
160
+ | number | Count when several tokens share a cell |
161
+ | `○` in a yard | An empty base slot (that token is out on the board) |
162
+
163
+ ## Project structure
164
+
165
+ ```
166
+ terminal-ludo/
167
+ ├── bin/
168
+ │ └── ludo.js # CLI entry point (the `ludo` command)
169
+ ├── src/
170
+ │ ├── constants.js # board geometry: track, home columns, safe squares
171
+ │ ├── game.js # pure, testable rules engine + save/load
172
+ │ ├── ai.js # heuristic AI with difficulty levels
173
+ │ ├── render.js # ANSI board renderer
174
+ │ └── cli.js # interactive loop, input handling, animation
175
+ └── test/
176
+ └── game.test.js # rule-engine tests (no framework, no deps)
177
+ ```
178
+
179
+ The rules live in `src/game.js` as a dependency-free, side-effect-free engine, so
180
+ they can be tested and reused independently of the terminal front-end.
181
+
182
+ ## Development
183
+
184
+ ```sh
185
+ git clone https://github.com/rohan4naik/CLI-Ludo.git
186
+ cd CLI-Ludo
187
+ npm test # run the rule-engine tests
188
+ npm start # run the game
189
+ ```
190
+
191
+ There are no build steps and no dependencies to install.
192
+
193
+ ## Contributing
194
+
195
+ Issues and pull requests are welcome at
196
+ [github.com/rohan4naik/CLI-Ludo](https://github.com/rohan4naik/CLI-Ludo).
197
+ Please run `npm test` before opening a PR.
198
+
199
+ ## License
200
+
201
+ [MIT](LICENSE)
package/bin/ludo.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { main } = require('../src/cli');
5
+
6
+ main().catch((err) => {
7
+ console.error(err);
8
+ process.exit(1);
9
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "terminal-ludo",
3
+ "version": "1.0.0",
4
+ "description": "Classic Ludo board game for your terminal. Offline, zero dependencies, play against AI or hotseat with friends.",
5
+ "keywords": [
6
+ "ludo",
7
+ "game",
8
+ "board-game",
9
+ "terminal",
10
+ "cli",
11
+ "tui",
12
+ "offline",
13
+ "ascii",
14
+ "dice"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "rohan4naik",
18
+ "type": "commonjs",
19
+ "bin": {
20
+ "ludo": "bin/ludo.js",
21
+ "terminal-ludo": "bin/ludo.js"
22
+ },
23
+ "main": "src/game.js",
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "start": "node bin/ludo.js",
32
+ "test": "node test/game.test.js"
33
+ },
34
+ "engines": {
35
+ "node": ">=14"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/rohan4naik/CLI-Ludo.git"
40
+ },
41
+ "homepage": "https://github.com/rohan4naik/CLI-Ludo#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/rohan4naik/CLI-Ludo/issues"
44
+ }
45
+ }
package/src/ai.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const { SAFE_INDICES, GOAL_OFFSET, TRACK_LEN } = require('./constants');
4
+
5
+ // Heuristic AI with three difficulty levels.
6
+ // easy - mostly random, ignores threats
7
+ // normal - full heuristic (default)
8
+ // hard - full heuristic with sharper threat/aggression weighting
9
+ function chooseMove(game, color, dice, moves, difficulty = 'normal') {
10
+ if (moves.length === 0) return null;
11
+ if (moves.length === 1) return moves[0];
12
+
13
+ // Easy players blunder often: 65% of the time they move at random.
14
+ if (difficulty === 'easy' && Math.random() < 0.65) {
15
+ return moves[Math.floor(Math.random() * moves.length)];
16
+ }
17
+
18
+ let best = moves[0];
19
+ let bestScore = -Infinity;
20
+ for (const move of moves) {
21
+ const score = scoreMove(game, color, dice, move, difficulty);
22
+ if (score > bestScore) {
23
+ bestScore = score;
24
+ best = move;
25
+ }
26
+ }
27
+ return best;
28
+ }
29
+
30
+ function scoreMove(game, color, dice, move, difficulty = 'normal') {
31
+ const hard = difficulty === 'hard';
32
+ let score = 0;
33
+
34
+ // Capturing an opponent is almost always great (more so on hard).
35
+ const captures = game.capturesFor(color, move.to);
36
+ score += captures.length * (hard ? 1300 : 1000);
37
+
38
+ // Getting a token all the way home.
39
+ if (move.to === GOAL_OFFSET) score += 800;
40
+
41
+ // Bringing a fresh token out of the yard keeps options open.
42
+ if (move.from < 0) score += 400;
43
+
44
+ // Reaching the safety of the home column.
45
+ if (move.to >= 51 && move.to < GOAL_OFFSET) score += 200;
46
+
47
+ // Landing on a safe square.
48
+ const abs = game.absIndex(color, move.to);
49
+ if (abs !== null && SAFE_INDICES.has(abs)) score += 120;
50
+
51
+ // Prefer progressing tokens that are already advanced.
52
+ score += move.to * 3;
53
+
54
+ // Avoid parking a lone token where an opponent one dice-roll behind could
55
+ // capture it next turn (only matters on unsafe track squares). Hard players
56
+ // weigh this danger more heavily.
57
+ if (abs !== null && !SAFE_INDICES.has(abs) && move.to < GOAL_OFFSET) {
58
+ if (isThreatened(game, color, abs)) score -= hard ? 260 : 150;
59
+ }
60
+
61
+ return score;
62
+ }
63
+
64
+ // Is absolute square `abs` reachable by an opponent token within 1..6 steps?
65
+ function isThreatened(game, color, abs) {
66
+ for (const other of game.colors) {
67
+ if (other === color) continue;
68
+ for (const off of game.tokens[other]) {
69
+ if (off < 0 || off > 50) continue;
70
+ const oAbs = game.absIndex(other, off);
71
+ let d = abs - oAbs;
72
+ if (d < 0) d += TRACK_LEN;
73
+ if (d >= 1 && d <= 6) return true;
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+
79
+ module.exports = { chooseMove };
package/src/cli.js ADDED
@@ -0,0 +1,467 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { Game } = require('./game');
7
+ const { chooseMove } = require('./ai');
8
+ const { render, status, paint, FG, LETTER, BOLD, DIM } = require('./render');
9
+ const { PLAYERS } = require('./constants');
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Keypress-based input.
13
+ //
14
+ // We read raw keypresses rather than lines so that prompts like "press Enter
15
+ // to roll" react to a single key with no visible text field / cursor. A tiny
16
+ // line editor (readLine) is layered on top for the few prompts that genuinely
17
+ // need typed text (player counts, colour lists).
18
+ // ---------------------------------------------------------------------------
19
+ const stdin = process.stdin;
20
+ const stdout = process.stdout;
21
+ const IS_TTY = !!stdin.isTTY;
22
+
23
+ readline.emitKeypressEvents(stdin);
24
+ if (IS_TTY) {
25
+ try { stdin.setRawMode(true); } catch (_) { /* not a raw-capable TTY */ }
26
+ }
27
+ stdin.resume();
28
+
29
+ const keyBuffer = [];
30
+ const keyWaiters = [];
31
+ let inputClosed = false;
32
+
33
+ stdin.on('keypress', (str, key) => {
34
+ if (key && key.ctrl && key.name === 'c') { onInterrupt(); return; }
35
+ const ev = { str: str || '', key: key || {} };
36
+ const w = keyWaiters.shift();
37
+ if (w) w(ev);
38
+ else keyBuffer.push(ev);
39
+ });
40
+ stdin.on('end', () => {
41
+ inputClosed = true;
42
+ while (keyWaiters.length) keyWaiters.shift()({ str: '', key: { name: 'return' }, eof: true });
43
+ });
44
+
45
+ function nextKey() {
46
+ return new Promise((res) => {
47
+ if (keyBuffer.length) return res(keyBuffer.shift());
48
+ if (inputClosed) return res({ str: '', key: { name: 'return' }, eof: true });
49
+ keyWaiters.push(res);
50
+ });
51
+ }
52
+
53
+ function isEnter(ev) {
54
+ const n = ev.key.name;
55
+ return ev.eof || n === 'return' || n === 'enter' || ev.str === '\r' || ev.str === '\n';
56
+ }
57
+
58
+ // Wait for a single keypress (optionally after printing a prompt).
59
+ async function readKey(prompt) {
60
+ if (prompt) stdout.write(prompt);
61
+ return nextKey();
62
+ }
63
+
64
+ // Read a line of text, echoing characters when interactive.
65
+ async function readLine(prompt) {
66
+ if (prompt) stdout.write(prompt);
67
+ let buf = '';
68
+ while (true) {
69
+ const ev = await nextKey();
70
+ if (isEnter(ev)) {
71
+ if (IS_TTY) stdout.write('\n');
72
+ return buf;
73
+ }
74
+ if (ev.key.name === 'backspace') {
75
+ if (buf.length) {
76
+ buf = buf.slice(0, -1);
77
+ if (IS_TTY) stdout.write('\b \b');
78
+ }
79
+ continue;
80
+ }
81
+ const ch = ev.str;
82
+ if (ch && ch >= ' ') {
83
+ buf += ch;
84
+ if (IS_TTY) stdout.write(ch);
85
+ }
86
+ }
87
+ }
88
+ const ask = readLine; // typed prompts (counts, colours)
89
+
90
+ function cleanup() {
91
+ if (IS_TTY) {
92
+ try { stdin.setRawMode(false); } catch (_) { /* ignore */ }
93
+ }
94
+ stdin.pause();
95
+ }
96
+ // Safety net: never leave the terminal stuck in raw mode, even on a crash.
97
+ process.on('exit', () => {
98
+ if (IS_TTY) {
99
+ try { stdin.setRawMode(false); } catch (_) { /* ignore */ }
100
+ }
101
+ });
102
+
103
+ const FAST = !!process.env.LUDO_FAST;
104
+ const sleep = (ms) => new Promise((res) => setTimeout(res, FAST ? 0 : ms));
105
+
106
+ function rollDice() {
107
+ return 1 + Math.floor(Math.random() * 6);
108
+ }
109
+
110
+ const DICE_FACES = ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
111
+ const DEFAULT_SAVE = path.join(process.cwd(), 'ludo-save.json');
112
+
113
+ let currentGame = null; // for autosave on Ctrl-C
114
+
115
+ function clear() {
116
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
117
+ }
118
+
119
+ function title() {
120
+ return paint(BOLD, ' L U D O ') + paint(DIM, ' — terminal edition');
121
+ }
122
+
123
+ function draw(game, message) {
124
+ clear();
125
+ console.log(title());
126
+ console.log();
127
+ console.log(render(game));
128
+ console.log();
129
+ console.log(status(game));
130
+ if (game.humans.size > 0) {
131
+ console.log(paint(DIM, " (at the roll prompt: 's' saves & quits, 'q' quits)"));
132
+ }
133
+ console.log();
134
+ if (message) console.log(message);
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Dice-roll animation (only for a human's own roll; AI just pauses briefly).
139
+ // ---------------------------------------------------------------------------
140
+ async function animateDice(finalDice) {
141
+ if (FAST) return;
142
+ const frames = 9;
143
+ for (let i = 0; i < frames; i++) {
144
+ const f = 1 + Math.floor(Math.random() * 6);
145
+ process.stdout.write('\r 🎲 rolling… ' + paint(BOLD, DICE_FACES[f - 1]) + ' ');
146
+ await sleep(35 + i * 14);
147
+ }
148
+ process.stdout.write(
149
+ '\r 🎲 rolled ' + paint(BOLD, DICE_FACES[finalDice - 1] + ' ' + finalDice) + ' \n'
150
+ );
151
+ await sleep(220);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Save / load
156
+ // ---------------------------------------------------------------------------
157
+ function saveGame(game, file) {
158
+ fs.writeFileSync(file, JSON.stringify(game.serialize(), null, 2));
159
+ }
160
+
161
+ function loadGame(file) {
162
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
163
+ return Game.deserialize(data);
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Turn flow
168
+ // ---------------------------------------------------------------------------
169
+ async function doRoll(game, color) {
170
+ if (game.isHuman(color)) {
171
+ const prompt =
172
+ paint(FG[color] + BOLD, color) +
173
+ ' — ' + paint(BOLD, 'press Enter') + ' to roll ' +
174
+ paint(DIM, '(s = save · q = quit)') + ' ';
175
+ while (true) {
176
+ const ev = await readKey(prompt);
177
+ const ch = (ev.str || '').toLowerCase();
178
+ if (ch === 's') {
179
+ saveGame(game, DEFAULT_SAVE);
180
+ clear();
181
+ console.log(paint(BOLD, 'Game saved to ') + DEFAULT_SAVE);
182
+ console.log('Resume with: ' + paint(BOLD, `ludo --load "${DEFAULT_SAVE}"`));
183
+ cleanup();
184
+ process.exit(0);
185
+ }
186
+ if (ch === 'q') {
187
+ clear();
188
+ console.log(paint(DIM, 'Thanks for playing!'));
189
+ cleanup();
190
+ process.exit(0);
191
+ }
192
+ // Enter or Space rolls; any other key is ignored (keeps the prompt).
193
+ if (isEnter(ev) || ev.key.name === 'space') break;
194
+ }
195
+ const dice = rollDice();
196
+ await animateDice(dice);
197
+ game.lastDice = dice;
198
+ return dice;
199
+ }
200
+ // AI
201
+ await sleep(650);
202
+ const dice = rollDice();
203
+ game.lastDice = dice;
204
+ return dice;
205
+ }
206
+
207
+ async function pickMove(game, color, dice, moves) {
208
+ if (!game.isHuman(color)) {
209
+ return chooseMove(game, color, dice, moves, game.difficulty);
210
+ }
211
+ if (moves.length === 1) {
212
+ await sleep(150);
213
+ return moves[0];
214
+ }
215
+ const lines = moves.map((m, i) => ` ${paint(BOLD, String(i + 1))}) ${describeMove(game, color, m)}`);
216
+ console.log('Choose a token to move ' + paint(DIM, `(press 1-${moves.length})`) + ':');
217
+ console.log(lines.join('\n'));
218
+ while (true) {
219
+ const ev = await readKey('> ');
220
+ if (ev.eof) return chooseMove(game, color, dice, moves, game.difficulty);
221
+ const idx = parseInt(ev.str, 10) - 1;
222
+ if (Number.isInteger(idx) && idx >= 0 && idx < moves.length) {
223
+ stdout.write(String(idx + 1) + '\n');
224
+ return moves[idx];
225
+ }
226
+ // ignore other keys and keep waiting
227
+ }
228
+ }
229
+
230
+ function describeMove(game, color, m) {
231
+ let where;
232
+ if (m.from < 0) where = 'leave the yard → start';
233
+ else if (m.to === 56) where = `token ${m.token + 1} → HOME`;
234
+ else if (m.to >= 51) where = `token ${m.token + 1} → home column`;
235
+ else where = `token ${m.token + 1}: ${m.from} → ${m.to}`;
236
+ const caps = game.capturesFor(color, m.to);
237
+ if (caps.length) where += paint(BOLD, ` (captures ${caps.map((x) => LETTER[x.color]).join(',')}!)`);
238
+ return where;
239
+ }
240
+
241
+ async function playTurn(game) {
242
+ const color = game.current;
243
+ draw(game, `${paint(FG[color] + BOLD, color)}'s turn.`);
244
+
245
+ const dice = await doRoll(game, color);
246
+ game.sixStreak = dice === 6 ? game.sixStreak + 1 : 0;
247
+
248
+ const faceMsg = `${paint(FG[color] + BOLD, color)} rolled ${paint(BOLD, DICE_FACES[dice - 1] + ' ' + dice)}.`;
249
+
250
+ if (game.sixStreak >= 3) {
251
+ draw(game, faceMsg + ' ' + paint(DIM, 'Three sixes — turn forfeited!'));
252
+ await pause(game, color);
253
+ game.nextTurn();
254
+ return;
255
+ }
256
+
257
+ const moves = game.legalMoves(color, dice);
258
+ if (moves.length === 0) {
259
+ const extra = dice === 6 ? ' Rolling again (6).' : '';
260
+ draw(game, faceMsg + ' No legal move.' + extra);
261
+ await pause(game, color);
262
+ if (dice !== 6) game.nextTurn();
263
+ return;
264
+ }
265
+
266
+ draw(game, faceMsg);
267
+ const move = await pickMove(game, color, dice, moves);
268
+ const result = game.applyMove(color, move);
269
+
270
+ let msg = faceMsg + ' ' + describeMove(game, color, move);
271
+ if (result.captured.length) {
272
+ msg += '\n' + paint(BOLD, `Captured ${result.captured.map((x) => x.color).join(', ')}! Sent back to yard.`);
273
+ }
274
+ if (result.home) msg += '\n' + paint(BOLD, `A ${color} token reached home!`);
275
+ if (game.hasFinished(color) && game.finishers[game.finishers.length - 1] === color) {
276
+ msg += '\n' + paint(FG[color] + BOLD, `${color.toUpperCase()} has all tokens home — finished #${game.finishers.length}!`);
277
+ }
278
+
279
+ draw(game, msg);
280
+ await pause(game, color);
281
+
282
+ if (result.extraTurn && !game.hasFinished(color)) return; // same player again
283
+ game.nextTurn();
284
+ }
285
+
286
+ async function pause(game, color) {
287
+ if (game.isHuman(color)) {
288
+ await readKey(paint(DIM, 'Press any key to continue…'));
289
+ if (IS_TTY) stdout.write('\n');
290
+ } else {
291
+ await sleep(game.watch ? 700 : 850);
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Setup / arg parsing
297
+ // ---------------------------------------------------------------------------
298
+ function parseArgs(argv) {
299
+ const opts = { watch: false, difficulty: 'normal', load: null, help: false };
300
+ for (let i = 0; i < argv.length; i++) {
301
+ const a = argv[i];
302
+ if (a === '--watch' || a === '-w') opts.watch = true;
303
+ else if (a === '--help' || a === '-h') opts.help = true;
304
+ else if (a === '--load' || a === '-l') opts.load = argv[++i];
305
+ else if (a.startsWith('--load=')) opts.load = a.slice(7);
306
+ else if (a === '--difficulty' || a === '-d') opts.difficulty = argv[++i];
307
+ else if (a.startsWith('--difficulty=')) opts.difficulty = a.slice(13);
308
+ }
309
+ if (!['easy', 'normal', 'hard'].includes(opts.difficulty)) opts.difficulty = 'normal';
310
+ return opts;
311
+ }
312
+
313
+ function printHelp() {
314
+ console.log(`${title()}
315
+
316
+ Usage: ludo [options]
317
+
318
+ Options:
319
+ -h, --help Show this help
320
+ -w, --watch Watch four AI players battle it out (no input)
321
+ -d, --difficulty <level> AI level: easy | normal | hard (default: normal)
322
+ -l, --load <file> Resume a previously saved game
323
+
324
+ In-game (on the roll prompt):
325
+ Enter roll the dice
326
+ s save the game and quit
327
+ q quit without saving
328
+
329
+ Examples:
330
+ ludo Interactive game (asks player counts)
331
+ ludo -d hard Interactive game vs. hard AI
332
+ ludo --watch -d hard Demo: four hard AIs play themselves
333
+ ludo --load ludo-save.json Resume a saved game
334
+ `);
335
+ }
336
+
337
+ // Resolve one token (name, initial, or 1-based index) to a colour in `pool`.
338
+ function resolveColor(token, pool) {
339
+ const t = token.trim().toLowerCase();
340
+ if (!t) return null;
341
+ const byName = pool.find((c) => c === t);
342
+ if (byName) return byName;
343
+ const byInitial = pool.find((c) => LETTER[c].toLowerCase() === t);
344
+ if (byInitial) return byInitial;
345
+ const idx = parseInt(t, 10) - 1;
346
+ if (Number.isInteger(idx) && pool[idx]) return pool[idx];
347
+ return null;
348
+ }
349
+
350
+ // Parse a comma/space separated list of colours from `pool`, de-duplicated.
351
+ function parseColorList(input, pool) {
352
+ const out = [];
353
+ for (const tok of input.split(/[,\s]+/)) {
354
+ const c = resolveColor(tok, pool);
355
+ if (c && !out.includes(c)) out.push(c);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ function colorMenu(pool) {
361
+ return pool.map((c, i) => `${i + 1}) ` + paint(FG[c] + BOLD, c)).join(' ');
362
+ }
363
+
364
+ async function setup(opts) {
365
+ clear();
366
+ console.log(title());
367
+ console.log();
368
+ console.log('Colours: ' + PLAYERS.map((c) => paint(FG[c] + BOLD, c)).join(', '));
369
+ console.log(paint(DIM, `AI difficulty: ${opts.difficulty}`));
370
+ console.log();
371
+
372
+ const nRaw = (await ask('How many players (2-4)? [4] ')).trim();
373
+ let n = parseInt(nRaw, 10);
374
+ if (!Number.isInteger(n) || n < 2 || n > 4) n = 4;
375
+
376
+ // Pick which colours are in the game (only matters when fewer than 4).
377
+ let colors = PLAYERS.slice(0, n);
378
+ if (n < 4) {
379
+ console.log('\nChoose ' + n + ' colours: ' + colorMenu(PLAYERS));
380
+ const pick = parseColorList(await ask(`Which colours play? [${colors.join(', ')}] `), PLAYERS);
381
+ if (pick.length >= 2) colors = pick.slice(0, n);
382
+ }
383
+
384
+ // Pick which of those colours are controlled by a human.
385
+ console.log('\nIn play: ' + colorMenu(colors));
386
+ const humansRaw = await ask(
387
+ `Which colours are human? (comma list, or 'none') [${colors[0]}] `
388
+ );
389
+ let humans;
390
+ if (humansRaw.trim().toLowerCase() === 'none') humans = [];
391
+ else {
392
+ humans = parseColorList(humansRaw, colors);
393
+ if (humansRaw.trim() === '') humans = [colors[0]]; // default: first colour
394
+ }
395
+
396
+ const game = new Game(colors);
397
+ for (const c of humans) game.humans.add(c);
398
+ game.difficulty = opts.difficulty;
399
+ return game;
400
+ }
401
+
402
+ async function main() {
403
+ const opts = parseArgs(process.argv.slice(2));
404
+ if (opts.help) {
405
+ printHelp();
406
+ cleanup();
407
+ return;
408
+ }
409
+
410
+ let game;
411
+ if (opts.load) {
412
+ try {
413
+ game = loadGame(opts.load);
414
+ if (game.difficulty === undefined) game.difficulty = opts.difficulty;
415
+ } catch (err) {
416
+ console.error(paint(BOLD, 'Could not load save file: ') + err.message);
417
+ cleanup();
418
+ process.exitCode = 1;
419
+ return;
420
+ }
421
+ } else if (opts.watch) {
422
+ game = new Game(PLAYERS.slice());
423
+ game.difficulty = opts.difficulty;
424
+ game.watch = true; // all AI, no humans
425
+ } else {
426
+ game = await setup(opts);
427
+ }
428
+ currentGame = game;
429
+
430
+ while (!game.gameOver) {
431
+ await playTurn(game);
432
+ }
433
+
434
+ const podium = game.finishers.slice();
435
+ for (const c of game.colors) if (!podium.includes(c)) podium.push(c);
436
+
437
+ draw(game, '');
438
+ console.log(paint(BOLD, ' 🏆 Game over!'));
439
+ console.log();
440
+ podium.forEach((c, i) => {
441
+ const medal = ['🥇', '🥈', '🥉', ' '][i] || ' ';
442
+ console.log(` ${medal} ${i + 1}. ${paint(FG[c] + BOLD, c)} (${game.homeCount(c)}/4 home)`);
443
+ });
444
+ console.log();
445
+ cleanup();
446
+ }
447
+
448
+ // Ctrl-C: in raw mode the OS won't send SIGINT, so the keypress handler calls
449
+ // this directly. We also register it on SIGINT for non-raw / piped runs.
450
+ function onInterrupt() {
451
+ cleanup();
452
+ if (currentGame && !currentGame.gameOver && currentGame.humans.size > 0) {
453
+ try {
454
+ const auto = path.join(process.cwd(), 'ludo-autosave.json');
455
+ saveGame(currentGame, auto);
456
+ console.log('\n' + paint(DIM, 'Autosaved to ' + auto + ' — resume with ludo --load "' + auto + '"'));
457
+ } catch (_) {
458
+ console.log('\n' + paint(DIM, 'Thanks for playing!'));
459
+ }
460
+ } else {
461
+ console.log('\n' + paint(DIM, 'Thanks for playing!'));
462
+ }
463
+ process.exit(0);
464
+ }
465
+ process.on('SIGINT', onInterrupt);
466
+
467
+ module.exports = { main };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Board geometry for a classic 15x15 Ludo board.
5
+ //
6
+ // The shared main track is a 52-cell loop. Coordinates are [row, col] on the
7
+ // 15x15 grid. Index 0 is Red's starting square; the loop runs clockwise.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const TRACK = [
11
+ [6, 1], [6, 2], [6, 3], [6, 4], [6, 5], // 0-4 red arm (right)
12
+ [5, 6], [4, 6], [3, 6], [2, 6], [1, 6], [0, 6], // 5-10 up
13
+ [0, 7], // 11 top turn
14
+ [0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], // 12-17 down
15
+ [6, 9], [6, 10], [6, 11], [6, 12], [6, 13], [6, 14], // 18-23 green arm (right)
16
+ [7, 14], // 24 right turn
17
+ [8, 14], [8, 13], [8, 12], [8, 11], [8, 10], [8, 9], // 25-30 back
18
+ [9, 8], [10, 8], [11, 8], [12, 8], [13, 8], [14, 8], // 31-36 down
19
+ [14, 7], // 37 bottom turn
20
+ [14, 6], [13, 6], [12, 6], [11, 6], [10, 6], [9, 6], // 38-43 up
21
+ [8, 5], [8, 4], [8, 3], [8, 2], [8, 1], [8, 0], // 44-49 left arm
22
+ [7, 0], // 50 left turn
23
+ [6, 0], // 51 back to start
24
+ ];
25
+
26
+ // Home columns (the coloured lane leading to the centre). 6 cells each; the
27
+ // last cell is the goal — a token reaching it is "home".
28
+ const HOME_COLUMNS = {
29
+ red: [[7, 1], [7, 2], [7, 3], [7, 4], [7, 5], [7, 6]],
30
+ green: [[1, 7], [2, 7], [3, 7], [4, 7], [5, 7], [6, 7]],
31
+ yellow: [[7, 13], [7, 12], [7, 11], [7, 10], [7, 9], [7, 8]],
32
+ blue: [[13, 7], [12, 7], [11, 7], [10, 7], [9, 7], [8, 7]],
33
+ };
34
+
35
+ // Where each colour enters the shared track (index into TRACK).
36
+ const START_INDEX = { red: 0, green: 13, yellow: 26, blue: 39 };
37
+
38
+ // Full journey length per token: 51 shared-track steps (offset 0..50) plus a
39
+ // 6-cell home column (offset 51..56). Offset 56 == home / goal.
40
+ const HOME_ENTRY_OFFSET = 51; // first offset inside the home column
41
+ const GOAL_OFFSET = 56; // exact offset that counts as "home"
42
+ const TRACK_LEN = TRACK.length; // 52
43
+
44
+ // Safe squares: the four coloured starts and the four star squares. Tokens on
45
+ // these cannot be captured.
46
+ const SAFE_INDICES = new Set([0, 8, 13, 21, 26, 34, 39, 47]);
47
+
48
+ const PLAYERS = ['red', 'green', 'yellow', 'blue'];
49
+
50
+ // The 6x6 yard corners and the 2x2 slots where based tokens sit.
51
+ const YARDS = {
52
+ red: { origin: [0, 0], slots: [[1, 1], [1, 3], [3, 1], [3, 3]] },
53
+ green: { origin: [0, 9], slots: [[1, 10], [1, 12], [3, 10], [3, 12]] },
54
+ yellow: { origin: [9, 9], slots: [[10, 10], [10, 12], [12, 10], [12, 12]] },
55
+ blue: { origin: [9, 0], slots: [[10, 1], [10, 3], [12, 1], [12, 3]] },
56
+ };
57
+
58
+ const CENTER = [7, 7];
59
+
60
+ module.exports = {
61
+ TRACK,
62
+ TRACK_LEN,
63
+ HOME_COLUMNS,
64
+ START_INDEX,
65
+ HOME_ENTRY_OFFSET,
66
+ GOAL_OFFSET,
67
+ SAFE_INDICES,
68
+ PLAYERS,
69
+ YARDS,
70
+ CENTER,
71
+ };
package/src/game.js ADDED
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ TRACK_LEN,
5
+ START_INDEX,
6
+ HOME_ENTRY_OFFSET,
7
+ GOAL_OFFSET,
8
+ SAFE_INDICES,
9
+ PLAYERS,
10
+ } = require('./constants');
11
+
12
+ // Token offsets:
13
+ // -1 -> in the yard (base), needs a 6 to come out
14
+ // 0..50 -> on the shared track (offset from the colour's start square)
15
+ // 51..56 -> inside the home column (56 == home / goal)
16
+ const BASE = -1;
17
+
18
+ class Game {
19
+ // activeColors: array of colour names actually playing (2-4).
20
+ constructor(activeColors = PLAYERS.slice()) {
21
+ this.colors = activeColors.slice();
22
+ this.tokens = {};
23
+ for (const c of this.colors) this.tokens[c] = [BASE, BASE, BASE, BASE];
24
+ this.humans = new Set(); // colours controlled by a human
25
+ this.turn = 0; // index into this.colors
26
+ this.lastDice = null;
27
+ this.sixStreak = 0;
28
+ this.winner = null;
29
+ this.finishers = []; // colours that have finished, in order
30
+ }
31
+
32
+ get current() {
33
+ return this.colors[this.turn];
34
+ }
35
+
36
+ isHuman(color) {
37
+ return this.humans.has(color);
38
+ }
39
+
40
+ // Absolute track index for a token, or null if in base / home column.
41
+ absIndex(color, offset) {
42
+ if (offset < 0 || offset > 50) return null;
43
+ return (START_INDEX[color] + offset) % TRACK_LEN;
44
+ }
45
+
46
+ hasFinished(color) {
47
+ return this.tokens[color].every((o) => o === GOAL_OFFSET);
48
+ }
49
+
50
+ // Legal moves for `color` given a dice value. Returns [{token, from, to}].
51
+ legalMoves(color, dice) {
52
+ const moves = [];
53
+ const offs = this.tokens[color];
54
+ for (let t = 0; t < offs.length; t++) {
55
+ const from = offs[t];
56
+ if (from === GOAL_OFFSET) continue; // already home
57
+ if (from === BASE) {
58
+ if (dice === 6) moves.push({ token: t, from, to: 0 });
59
+ continue;
60
+ }
61
+ const to = from + dice;
62
+ if (to > GOAL_OFFSET) continue; // must land exactly on the goal
63
+ moves.push({ token: t, from, to });
64
+ }
65
+ return moves;
66
+ }
67
+
68
+ // Would applying `move` for `color` capture at least one opponent token?
69
+ wouldCapture(color, move) {
70
+ return this.capturesFor(color, move.to).length > 0;
71
+ }
72
+
73
+ // List of {color, token} opponents captured if `color` lands on `toOffset`.
74
+ capturesFor(color, toOffset) {
75
+ const abs = this.absIndex(color, toOffset);
76
+ if (abs === null) return []; // home column: never captures
77
+ if (SAFE_INDICES.has(abs)) return []; // safe square: no captures
78
+ const hits = [];
79
+ for (const other of this.colors) {
80
+ if (other === color) continue;
81
+ const offs = this.tokens[other];
82
+ for (let t = 0; t < offs.length; t++) {
83
+ if (this.absIndex(other, offs[t]) === abs) hits.push({ color: other, token: t });
84
+ }
85
+ }
86
+ return hits;
87
+ }
88
+
89
+ // Apply a move. Returns { captured: [...], home: bool, extraTurn: bool }.
90
+ applyMove(color, move) {
91
+ const captured = this.capturesFor(color, move.to);
92
+ for (const cap of captured) this.tokens[cap.color][cap.token] = BASE;
93
+ this.tokens[color][move.token] = move.to;
94
+
95
+ const home = move.to === GOAL_OFFSET;
96
+ if (this.hasFinished(color) && !this.finishers.includes(color)) {
97
+ this.finishers.push(color);
98
+ }
99
+ const extraTurn = this.lastDice === 6 || captured.length > 0 || home;
100
+ return { captured, home, extraTurn };
101
+ }
102
+
103
+ // Advance to the next colour that has not finished.
104
+ nextTurn() {
105
+ this.sixStreak = 0;
106
+ for (let i = 0; i < this.colors.length; i++) {
107
+ this.turn = (this.turn + 1) % this.colors.length;
108
+ if (!this.hasFinished(this.current)) return;
109
+ }
110
+ }
111
+
112
+ // True once only one (or zero) unfinished players remain.
113
+ get gameOver() {
114
+ const remaining = this.colors.filter((c) => !this.hasFinished(c));
115
+ return remaining.length <= 1;
116
+ }
117
+
118
+ // Count of tokens home for a colour.
119
+ homeCount(color) {
120
+ return this.tokens[color].filter((o) => o === GOAL_OFFSET).length;
121
+ }
122
+
123
+ // Plain-object snapshot for saving to disk.
124
+ serialize() {
125
+ return {
126
+ v: 1,
127
+ colors: this.colors.slice(),
128
+ tokens: JSON.parse(JSON.stringify(this.tokens)),
129
+ humans: [...this.humans],
130
+ turn: this.turn,
131
+ lastDice: this.lastDice,
132
+ sixStreak: this.sixStreak,
133
+ finishers: this.finishers.slice(),
134
+ difficulty: this.difficulty,
135
+ };
136
+ }
137
+
138
+ // Rebuild a Game from a serialized snapshot.
139
+ static deserialize(data) {
140
+ if (!data || !Array.isArray(data.colors)) throw new Error('invalid save file');
141
+ const g = new Game(data.colors);
142
+ g.tokens = data.tokens;
143
+ g.humans = new Set(data.humans || []);
144
+ g.turn = data.turn || 0;
145
+ g.lastDice = data.lastDice ?? null;
146
+ g.sixStreak = data.sixStreak || 0;
147
+ g.finishers = data.finishers || [];
148
+ if (data.difficulty) g.difficulty = data.difficulty;
149
+ return g;
150
+ }
151
+ }
152
+
153
+ module.exports = { Game, BASE };
package/src/render.js ADDED
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ TRACK,
5
+ HOME_COLUMNS,
6
+ START_INDEX,
7
+ SAFE_INDICES,
8
+ YARDS,
9
+ CENTER,
10
+ PLAYERS,
11
+ } = require('./constants');
12
+ const { BASE } = require('./game');
13
+
14
+ const RESET = '\x1b[0m';
15
+ const BOLD = '\x1b[1m';
16
+ const DIM = '\x1b[2m';
17
+
18
+ // Foreground colours (bright) per player.
19
+ const FG = {
20
+ red: '\x1b[91m',
21
+ green: '\x1b[92m',
22
+ yellow: '\x1b[93m',
23
+ blue: '\x1b[94m',
24
+ };
25
+ // Background colours for yards / home columns.
26
+ const BG = {
27
+ red: '\x1b[101m',
28
+ green: '\x1b[102m',
29
+ yellow: '\x1b[103m',
30
+ blue: '\x1b[104m',
31
+ };
32
+ const BLACK_FG = '\x1b[30m'; // dark text on a coloured token so it stays legible
33
+
34
+ const LETTER = { red: 'R', green: 'G', yellow: 'Y', blue: 'B' };
35
+ const supportsColor = process.stdout.isTTY !== false && !process.env.NO_COLOR;
36
+
37
+ function paint(code, s) {
38
+ return supportsColor ? code + s + RESET : s;
39
+ }
40
+
41
+ function key(r, c) {
42
+ return r + ',' + c;
43
+ }
44
+
45
+ // Build lookup maps so we can decide what to draw in each grid cell.
46
+ const trackAt = new Map();
47
+ TRACK.forEach(([r, c], i) => trackAt.set(key(r, c), i));
48
+
49
+ const homeAt = new Map(); // "r,c" -> color
50
+ for (const color of PLAYERS) {
51
+ HOME_COLUMNS[color].forEach(([r, c]) => homeAt.set(key(r, c), color));
52
+ }
53
+
54
+ const yardCells = new Map(); // "r,c" -> color (the 6x6 corners)
55
+ for (const color of PLAYERS) {
56
+ const [r0, c0] = YARDS[color].origin;
57
+ for (let r = r0; r < r0 + 6; r++) {
58
+ for (let c = c0; c < c0 + 6; c++) yardCells.set(key(r, c), color);
59
+ }
60
+ }
61
+
62
+ const yardSlotColor = new Map(); // "r,c" -> color
63
+ for (const color of PLAYERS) {
64
+ YARDS[color].slots.forEach(([r, c]) => yardSlotColor.set(key(r, c), color));
65
+ }
66
+
67
+ const startCell = new Map(); // "r,c" -> color
68
+ for (const color of PLAYERS) {
69
+ const [r, c] = TRACK[START_INDEX[color]];
70
+ startCell.set(key(r, c), color);
71
+ }
72
+
73
+ // Render the whole board (plus token positions) to a string.
74
+ function render(game) {
75
+ // Map each grid cell to the tokens sitting on it.
76
+ const occupants = new Map(); // "r,c" -> [{color, token}]
77
+ const baseTokens = {}; // color -> list of based token indices
78
+ for (const color of game.colors) {
79
+ baseTokens[color] = [];
80
+ game.tokens[color].forEach((offset, t) => {
81
+ if (offset === BASE) {
82
+ baseTokens[color].push(t);
83
+ return;
84
+ }
85
+ const [r, c] = cellFor(color, offset);
86
+ const k = key(r, c);
87
+ if (!occupants.has(k)) occupants.set(k, []);
88
+ occupants.get(k).push({ color, token: t });
89
+ });
90
+ }
91
+
92
+ // Place based tokens into their yard slots.
93
+ const slotFill = new Map(); // "r,c" -> {color, token}
94
+ for (const color of game.colors) {
95
+ const slots = YARDS[color].slots;
96
+ baseTokens[color].forEach((t) => {
97
+ const [r, c] = slots[t];
98
+ slotFill.set(key(r, c), { color, token: t });
99
+ });
100
+ }
101
+
102
+ const lines = [];
103
+ for (let r = 0; r < 15; r++) {
104
+ let line = '';
105
+ for (let c = 0; c < 15; c++) {
106
+ line += cellString(game, r, c, occupants, slotFill);
107
+ }
108
+ lines.push(line);
109
+ }
110
+ return lines.join('\n');
111
+ }
112
+
113
+ // Grid coordinate for a token of `color` at a given offset (>=0).
114
+ function cellFor(color, offset) {
115
+ if (offset <= 50) {
116
+ const abs = (START_INDEX[color] + offset) % TRACK.length;
117
+ return TRACK[abs];
118
+ }
119
+ return HOME_COLUMNS[color][offset - 51];
120
+ }
121
+
122
+ function cellString(game, r, c, occupants, slotFill) {
123
+ const k = key(r, c);
124
+
125
+ // A token on the board takes precedence. Draw it as a filled coloured pill
126
+ // (dark letter on the colour's background) so it is easy to spot on the track.
127
+ const occ = occupants.get(k);
128
+ if (occ && occ.length) {
129
+ const top = occ[0];
130
+ const ch = occ.length > 1 ? String(occ.length) : LETTER[top.color];
131
+ return paint(BG[top.color] + BLACK_FG + BOLD, ch) + paint(BG[top.color], ' ');
132
+ }
133
+
134
+ // A based token in its yard slot.
135
+ const slot = slotFill.get(k);
136
+ if (slot) {
137
+ return paint(BG[slot.color] + BOLD, LETTER[slot.color]) + paint(BG[slot.color], ' ');
138
+ }
139
+
140
+ // Centre goal.
141
+ if (r === CENTER[0] && c === CENTER[1]) return paint(BOLD, '★') + ' ';
142
+
143
+ // Home column cell.
144
+ const homeColor = homeAt.get(k);
145
+ if (homeColor) return paint(BG[homeColor], ' ');
146
+
147
+ // Start square (coloured).
148
+ const startColor = startCell.get(k);
149
+ if (startColor) return paint(FG[startColor] + BOLD, '◈') + ' ';
150
+
151
+ // Regular track cell.
152
+ if (trackAt.has(k)) {
153
+ const abs = trackAt.get(k);
154
+ if (SAFE_INDICES.has(abs)) return paint(DIM, '✦') + ' ';
155
+ return paint(DIM, '·') + ' ';
156
+ }
157
+
158
+ // Yard interior (coloured block, empty slot marker).
159
+ const yardColor = yardCells.get(k);
160
+ if (yardColor) {
161
+ if (yardSlotColor.has(k)) return paint(BG[yardColor] + DIM, '○') + paint(BG[yardColor], ' ');
162
+ return paint(BG[yardColor], ' ');
163
+ }
164
+
165
+ return ' ';
166
+ }
167
+
168
+ // A short coloured status/legend block.
169
+ function status(game) {
170
+ const rows = game.colors.map((color) => {
171
+ const tag = game.isHuman(color) ? 'you' : 'cpu';
172
+ const home = game.homeCount(color);
173
+ const marker = color === game.current ? '▶' : ' ';
174
+ const label = paint(FG[color] + BOLD, LETTER[color] + ' ' + color.padEnd(6));
175
+ return `${marker} ${label} (${tag}) home ${home}/4`;
176
+ });
177
+ return rows.join('\n');
178
+ }
179
+
180
+ module.exports = { render, status, paint, FG, LETTER, RESET, BOLD, DIM };