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 +21 -0
- package/README.md +201 -0
- package/bin/ludo.js +9 -0
- package/package.json +45 -0
- package/src/ai.js +79 -0
- package/src/cli.js +467 -0
- package/src/constants.js +71 -0
- package/src/game.js +153 -0
- package/src/render.js +180 -0
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
|
+
[](https://www.npmjs.com/package/terminal-ludo)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](package.json)
|
|
11
|
+
[](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
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 };
|
package/src/constants.js
ADDED
|
@@ -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 };
|