afk-snake 0.2.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 +50 -0
- package/package.json +14 -0
- package/src/api.js +23 -0
- package/src/cli.js +22 -0
- package/src/economyClient.js +67 -0
- package/src/economyFooter.js +8 -0
- package/src/engine.js +89 -0
- package/src/gameover.js +27 -0
- package/src/glow.js +15 -0
- package/src/highscore.js +33 -0
- package/src/hooks.js +38 -0
- package/src/index.js +3 -0
- package/src/input.js +20 -0
- package/src/install.js +32 -0
- package/src/launcher.js +37 -0
- package/src/login.js +57 -0
- package/src/loop.js +204 -0
- package/src/popup.js +60 -0
- package/src/profile.js +36 -0
- package/src/readycard.js +21 -0
- package/src/render.js +66 -0
- package/src/resume.js +11 -0
- package/src/session.js +29 -0
- package/src/settings.js +21 -0
- package/src/skins.js +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jsiwinski
|
|
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,50 @@
|
|
|
1
|
+
# afk-snake ๐
|
|
2
|
+
|
|
3
|
+
A game you play in your terminal while you wait for your AI agents to finish a job.
|
|
4
|
+
No install, one command:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx afk-snake
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Step 1: Snake
|
|
11
|
+
|
|
12
|
+
- **Steer:** Arrow keys or **WASD**
|
|
13
|
+
- **Pause:** `P` โ freezes the game so you can jump back to your Claude terminal (`Enter` returns to Claude; an arrow key resumes)
|
|
14
|
+
- **Restart:** `R` ยท **Quit:** `Q` / `Ctrl-C`
|
|
15
|
+
- Walls and your own tail are deadly. The snake speeds up as you eat.
|
|
16
|
+
- Your best score is saved to `~/.term-game.json`.
|
|
17
|
+
|
|
18
|
+
Beat your high score, screenshot the game-over card, and share it โ the
|
|
19
|
+
install command is right there on the card.
|
|
20
|
+
|
|
21
|
+
## Develop
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm start # play from source
|
|
25
|
+
npm test # run the unit tests (node:test, zero deps)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Phase 2: Auto-launch while you wait
|
|
29
|
+
|
|
30
|
+
Run your AI agent (Claude Code) **inside tmux**, then once:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
afk-snake install # adds UserPromptSubmit + Stop hooks to ~/.claude/settings.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Your game is one continuous session: it freezes when the popup closes and **resumes
|
|
37
|
+
right where you left off** (same score and snake) on the next prompt โ press any key to
|
|
38
|
+
un-pause. Dying, quitting (`Q`), or restarting (`R`) starts a fresh game next time.
|
|
39
|
+
|
|
40
|
+
Now every time you send the agent a prompt, Snake pops up over your terminal and
|
|
41
|
+
disappears the moment the agent replies. Remove it any time with:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
afk-snake uninstall
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Not inside tmux? The hooks stay out of your way (the popup is skipped) โ just run
|
|
48
|
+
`afk-snake` in a spare terminal instead. A separate-window fallback is future work.
|
|
49
|
+
|
|
50
|
+
MIT ยฉ jsiwinski
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "afk-snake",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A terminal game to play while AI agents finish jobs. npx afk-snake",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "afk-snake": "src/index.js" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/index.js",
|
|
9
|
+
"test": "node --test"
|
|
10
|
+
},
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"files": ["src", "README.md", "LICENSE"]
|
|
14
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/api.js
|
|
2
|
+
// The ONLY client module that touches the network. `fetch` is injectable for tests.
|
|
3
|
+
// Callers wrap calls in try/catch and degrade to "didn't sync" โ gameplay never blocks.
|
|
4
|
+
export function createApi(baseUrl, { fetch = globalThis.fetch } = {}) {
|
|
5
|
+
async function call(path, token, body) {
|
|
6
|
+
const res = await fetch(baseUrl + path, {
|
|
7
|
+
method: body ? 'POST' : 'GET',
|
|
8
|
+
headers: {
|
|
9
|
+
'content-type': 'application/json',
|
|
10
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
11
|
+
},
|
|
12
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
15
|
+
return res.json();
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
postGame: (token, facts) => call('/games', token, facts),
|
|
19
|
+
postUnlock: (token, itemId) => call('/unlocks', token, { itemId }),
|
|
20
|
+
getMe: (token) => call('/me', token),
|
|
21
|
+
postLogin: (githubToken) => call('/login', null, { githubToken }),
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { run } from './loop.js';
|
|
2
|
+
import { install, uninstall } from './install.js';
|
|
3
|
+
import { startPopup, stopPopup } from './popup.js';
|
|
4
|
+
import { login } from './login.js';
|
|
5
|
+
|
|
6
|
+
const SUBCOMMANDS = new Set(['install', 'uninstall', '_start', '_stop', 'login']);
|
|
7
|
+
|
|
8
|
+
export function parseCommand(argv) {
|
|
9
|
+
const arg = argv[2];
|
|
10
|
+
return SUBCOMMANDS.has(arg) ? arg : 'play';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function main(argv = process.argv, env = process.env) {
|
|
14
|
+
switch (parseCommand(argv)) {
|
|
15
|
+
case 'install': return install(env);
|
|
16
|
+
case 'uninstall': return uninstall(env);
|
|
17
|
+
case '_start': return startPopup(env);
|
|
18
|
+
case '_stop': return stopPopup(env);
|
|
19
|
+
case 'login': return login().then((r) => { if (!r || !r.ok) process.exit(1); });
|
|
20
|
+
default: return run();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/economyClient.js
|
|
2
|
+
// The loop's single touchpoint for the account + token economy. All network calls
|
|
3
|
+
// degrade silently (gameplay never blocks). `api` and `loadAuth` are injectable.
|
|
4
|
+
import { createApi } from './api.js';
|
|
5
|
+
import { loadAuth as realLoadAuth } from './profile.js';
|
|
6
|
+
import { hasGlow, SKIN_GLOW, GLOW_PRICE } from './skins.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BASE = 'https://term-game-api.jsiwinski2.workers.dev';
|
|
9
|
+
const isUnauthorized = (err) => /\b401\b/.test(String(err && err.message));
|
|
10
|
+
|
|
11
|
+
export function createEconomyClient({ api, loadAuth = realLoadAuth, env = process.env } = {}) {
|
|
12
|
+
const client = api || createApi(env.TERM_GAME_API || DEFAULT_BASE);
|
|
13
|
+
const state = { loggedIn: false, token: null, login: null, balance: 0, best: 0, unlocks: [] };
|
|
14
|
+
|
|
15
|
+
async function load() {
|
|
16
|
+
const auth = loadAuth();
|
|
17
|
+
if (!auth) return;
|
|
18
|
+
state.loggedIn = true;
|
|
19
|
+
state.token = auth.token;
|
|
20
|
+
state.login = auth.login;
|
|
21
|
+
try {
|
|
22
|
+
const me = await client.getMe(auth.token);
|
|
23
|
+
state.balance = me.balance;
|
|
24
|
+
state.best = me.best;
|
|
25
|
+
state.unlocks = me.unlocks || [];
|
|
26
|
+
} catch {
|
|
27
|
+
// logged in but /me unreachable: keep defaults, no glow this session
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function submitGame(facts) {
|
|
32
|
+
if (!state.loggedIn) return null;
|
|
33
|
+
try {
|
|
34
|
+
const r = await client.postGame(state.token, facts);
|
|
35
|
+
state.balance = r.balance;
|
|
36
|
+
state.best = r.best;
|
|
37
|
+
return r;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (isUnauthorized(err)) state.loggedIn = false;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function buyGlow() {
|
|
45
|
+
if (!state.loggedIn) return { ok: false };
|
|
46
|
+
try {
|
|
47
|
+
const r = await client.postUnlock(state.token, SKIN_GLOW);
|
|
48
|
+
state.balance = r.balance;
|
|
49
|
+
state.unlocks = r.unlocks || state.unlocks;
|
|
50
|
+
return { ok: true, balance: r.balance };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (isUnauthorized(err)) state.loggedIn = false;
|
|
53
|
+
return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
load,
|
|
59
|
+
submitGame,
|
|
60
|
+
buyGlow,
|
|
61
|
+
hasGlow: () => hasGlow(state.unlocks),
|
|
62
|
+
canBuyGlow: () => state.loggedIn && !hasGlow(state.unlocks) && state.balance >= GLOW_PRICE,
|
|
63
|
+
get loggedIn() { return state.loggedIn; },
|
|
64
|
+
get balance() { return state.balance; },
|
|
65
|
+
get best() { return state.best; },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// src/economyFooter.js
|
|
2
|
+
// Pure: the one game-over line that sells the loop. Kept โค 30 chars so it fits
|
|
3
|
+
// the centered game-over card's inner width. No I/O, no network.
|
|
4
|
+
export function economyLine({ loggedIn, awarded = 0, balance = 0, canAfford = false } = {}) {
|
|
5
|
+
if (!loggedIn) return `+${awarded} tokens [A] keep them`;
|
|
6
|
+
if (canAfford) return `+${awarded} bal ${balance} [S] glow skin`;
|
|
7
|
+
return `+${awarded} earned balance ${balance}`;
|
|
8
|
+
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const DIRS = {
|
|
2
|
+
up: { x: 0, y: -1 },
|
|
3
|
+
down: { x: 0, y: 1 },
|
|
4
|
+
left: { x: -1, y: 0 },
|
|
5
|
+
right: { x: 1, y: 0 },
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const FOOD_COUNT = 3;
|
|
9
|
+
|
|
10
|
+
export function spawnFood(width, height, blocked, rng) {
|
|
11
|
+
const occupied = new Set(blocked.map((c) => `${c.x},${c.y}`));
|
|
12
|
+
const free = [];
|
|
13
|
+
for (let y = 0; y < height; y++) {
|
|
14
|
+
for (let x = 0; x < width; x++) {
|
|
15
|
+
if (!occupied.has(`${x},${y}`)) free.push({ x, y });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (free.length === 0) return null;
|
|
19
|
+
return free[Math.floor(rng() * free.length)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Top a foods array up toward FOOD_COUNT, never placing one on the snake or an
|
|
23
|
+
// existing food. Returns as many as fit if the board lacks free cells.
|
|
24
|
+
export function fillFoods(width, height, snake, foods, rng) {
|
|
25
|
+
const result = [...foods];
|
|
26
|
+
while (result.length < FOOD_COUNT) {
|
|
27
|
+
const food = spawnFood(width, height, [...snake, ...result], rng);
|
|
28
|
+
if (!food) break;
|
|
29
|
+
result.push(food);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createGame({ width, height, rng = Math.random }) {
|
|
35
|
+
const cx = Math.floor(width / 2);
|
|
36
|
+
const cy = Math.floor(height / 2);
|
|
37
|
+
const snake = [
|
|
38
|
+
{ x: cx, y: cy },
|
|
39
|
+
{ x: cx - 1, y: cy },
|
|
40
|
+
{ x: cx - 2, y: cy },
|
|
41
|
+
];
|
|
42
|
+
const dir = DIRS.right;
|
|
43
|
+
const foods = fillFoods(width, height, snake, [], rng);
|
|
44
|
+
return { width, height, snake, dir, pendingDir: dir, foods, score: 0, status: 'playing', rng };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isOpposite(a, b) {
|
|
48
|
+
return a.x === -b.x && a.y === -b.y;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function turn(state, dirName) {
|
|
52
|
+
const dir = DIRS[dirName];
|
|
53
|
+
if (!dir) return state;
|
|
54
|
+
if (state.snake.length > 1 && isOpposite(dir, state.dir)) return state;
|
|
55
|
+
return { ...state, pendingDir: dir };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function step(state) {
|
|
59
|
+
if (state.status !== 'playing') return state;
|
|
60
|
+
const dir = state.pendingDir;
|
|
61
|
+
const head = state.snake[0];
|
|
62
|
+
const newHead = { x: head.x + dir.x, y: head.y + dir.y };
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
newHead.x < 0 || newHead.x >= state.width ||
|
|
66
|
+
newHead.y < 0 || newHead.y >= state.height
|
|
67
|
+
) {
|
|
68
|
+
return { ...state, dir, status: 'over' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const eatenIndex = state.foods.findIndex((f) => f.x === newHead.x && f.y === newHead.y);
|
|
72
|
+
const willEat = eatenIndex !== -1;
|
|
73
|
+
const body = willEat ? state.snake : state.snake.slice(0, -1);
|
|
74
|
+
if (body.some((c) => c.x === newHead.x && c.y === newHead.y)) {
|
|
75
|
+
return { ...state, dir, status: 'over' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const newSnake = [newHead, ...state.snake];
|
|
79
|
+
let score = state.score;
|
|
80
|
+
let foods = state.foods;
|
|
81
|
+
if (willEat) {
|
|
82
|
+
score += 1;
|
|
83
|
+
const remaining = state.foods.filter((_, i) => i !== eatenIndex);
|
|
84
|
+
foods = fillFoods(state.width, state.height, newSnake, remaining, state.rng);
|
|
85
|
+
} else {
|
|
86
|
+
newSnake.pop();
|
|
87
|
+
}
|
|
88
|
+
return { ...state, dir, snake: newSnake, foods, score };
|
|
89
|
+
}
|
package/src/gameover.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Built programmatically so every line is exactly the same length โ required for the
|
|
2
|
+
// centered overlay (overlayCenter). No wide emoji (which would break column alignment).
|
|
3
|
+
export function gameOverCard({ score, best, isNewBest, economyLine }) {
|
|
4
|
+
const W = 30; // inner width (between the side borders)
|
|
5
|
+
const pad = (s, w = W) => {
|
|
6
|
+
const total = Math.max(0, w - s.length);
|
|
7
|
+
const left = Math.floor(total / 2);
|
|
8
|
+
return ' '.repeat(left) + s + ' '.repeat(total - left);
|
|
9
|
+
};
|
|
10
|
+
const leftLine = (s) => (' ' + s).padEnd(W).slice(0, W);
|
|
11
|
+
const row = (s) => 'โ' + s + 'โ';
|
|
12
|
+
const rows = [
|
|
13
|
+
'โ' + 'โ'.repeat(W) + 'โ',
|
|
14
|
+
row(pad('G A M E O V E R')),
|
|
15
|
+
row(' '.repeat(W)),
|
|
16
|
+
row(leftLine('Score: ' + score)),
|
|
17
|
+
row(leftLine('Best: ' + best)),
|
|
18
|
+
row(isNewBest ? pad('new best!') : ' '.repeat(W)),
|
|
19
|
+
];
|
|
20
|
+
if (economyLine) rows.push(row(leftLine(economyLine)));
|
|
21
|
+
rows.push(
|
|
22
|
+
row(leftLine('play: npx afk-snake')),
|
|
23
|
+
'โ' + 'โ'.repeat(W) + 'โ',
|
|
24
|
+
pad('[R] play again [Q] quit', W + 2), // matches the box outer width (W + 2 borders)
|
|
25
|
+
);
|
|
26
|
+
return rows.join('\n');
|
|
27
|
+
}
|
package/src/glow.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/glow.js
|
|
2
|
+
// Pure comet-trail coloring: a bright bold head dimming toward the tail, using
|
|
3
|
+
// 256-colour ANSI. Each painted cell is self-contained (colour + char + reset),
|
|
4
|
+
// so colour never bleeds between cells.
|
|
5
|
+
const ESC = '\x1b[';
|
|
6
|
+
export const RESET = `${ESC}0m`;
|
|
7
|
+
const RAMP = [231, 230, 228, 222, 220, 214, 208, 202, 196]; // white -> yellow -> orange -> red
|
|
8
|
+
|
|
9
|
+
export function cometColor(index) {
|
|
10
|
+
return `${ESC}38;5;${RAMP[Math.min(index, RAMP.length - 1)]}m`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function paintCell(ch, index) {
|
|
14
|
+
return `${cometColor(index)}${ch}${RESET}`;
|
|
15
|
+
}
|
package/src/highscore.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PATH = join(homedir(), '.term-game.json');
|
|
6
|
+
|
|
7
|
+
export function loadHighScore(path = DEFAULT_PATH) {
|
|
8
|
+
try {
|
|
9
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
return typeof data.best === 'number' ? data.best : 0;
|
|
11
|
+
} catch {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveHighScore(score, path = DEFAULT_PATH) {
|
|
17
|
+
writeFileSync(path, JSON.stringify({ best: score }), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function updateHighScore(score, path = DEFAULT_PATH) {
|
|
21
|
+
const best = loadHighScore(path);
|
|
22
|
+
if (score > best) {
|
|
23
|
+
saveHighScore(score, path);
|
|
24
|
+
return { best: score, isNewBest: true };
|
|
25
|
+
}
|
|
26
|
+
return { best, isNewBest: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure, in-memory best tracking โ no file I/O. No-account play uses this so the
|
|
30
|
+
// best lives only for the sitting (durable best moves to the account/server).
|
|
31
|
+
export function nextBest(best, score) {
|
|
32
|
+
return score > best ? { best: score, isNewBest: true } : { best, isNewBest: false };
|
|
33
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A harmless trailing shell comment that tags our hook commands so uninstall
|
|
2
|
+
// can find them regardless of how the launcher was resolved.
|
|
3
|
+
export const MARKER = '# term-game-phase2';
|
|
4
|
+
|
|
5
|
+
const EVENTS = { UserPromptSubmit: '_start', Stop: '_stop' };
|
|
6
|
+
|
|
7
|
+
function entryFor(launcherCmd, sub) {
|
|
8
|
+
return { hooks: [{ type: 'command', command: `${launcherCmd} ${sub} ${MARKER}` }] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isOurEntry(group) {
|
|
12
|
+
// Match only commands ending in the marker (entryFor always appends it last),
|
|
13
|
+
// so a user hook that merely contains the marker string is never touched.
|
|
14
|
+
return (
|
|
15
|
+
group &&
|
|
16
|
+
Array.isArray(group.hooks) &&
|
|
17
|
+
group.hooks.some((h) => typeof h.command === 'string' && h.command.endsWith(` ${MARKER}`))
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function withoutTermGameHooks(settings) {
|
|
22
|
+
if (!settings.hooks) return settings;
|
|
23
|
+
const hooks = {};
|
|
24
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
25
|
+
const kept = (groups || []).filter((g) => !isOurEntry(g));
|
|
26
|
+
if (kept.length) hooks[event] = kept; // drop now-empty events
|
|
27
|
+
}
|
|
28
|
+
return { ...settings, hooks };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withTermGameHooks(settings, launcherCmd) {
|
|
32
|
+
const hooks = { ...(settings.hooks || {}) };
|
|
33
|
+
for (const [event, sub] of Object.entries(EVENTS)) {
|
|
34
|
+
const existing = (hooks[event] || []).filter((g) => !isOurEntry(g)); // drop stale -> idempotent
|
|
35
|
+
hooks[event] = [...existing, entryFor(launcherCmd, sub)];
|
|
36
|
+
}
|
|
37
|
+
return { ...settings, hooks };
|
|
38
|
+
}
|
package/src/index.js
ADDED
package/src/input.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function mapKey(data) {
|
|
2
|
+
const s = data.toString();
|
|
3
|
+
switch (s) {
|
|
4
|
+
case '\x1b[A': return 'up';
|
|
5
|
+
case '\x1b[B': return 'down';
|
|
6
|
+
case '\x1b[C': return 'right';
|
|
7
|
+
case '\x1b[D': return 'left';
|
|
8
|
+
case 'w': case 'W': return 'up';
|
|
9
|
+
case 's': case 'S': return 'down';
|
|
10
|
+
case 'a': case 'A': return 'left';
|
|
11
|
+
case 'd': case 'D': return 'right';
|
|
12
|
+
case 'r': case 'R': return 'restart';
|
|
13
|
+
case 'p': case 'P': return 'pause';
|
|
14
|
+
case 'q': case 'Q': return 'quit';
|
|
15
|
+
case ' ': return 'space';
|
|
16
|
+
case '\r': case '\n': return 'enter';
|
|
17
|
+
case '\x03': return 'quit'; // Ctrl-C
|
|
18
|
+
default: return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { settingsPath, readSettings, writeSettings } from './settings.js';
|
|
2
|
+
import { withTermGameHooks, withoutTermGameHooks } from './hooks.js';
|
|
3
|
+
import { launcherCommand } from './launcher.js';
|
|
4
|
+
|
|
5
|
+
function manualSnippet(cmd) {
|
|
6
|
+
const snippet = {
|
|
7
|
+
hooks: {
|
|
8
|
+
UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${cmd} _start` }] }],
|
|
9
|
+
Stop: [{ hooks: [{ type: 'command', command: `${cmd} _stop` }] }],
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
return JSON.stringify(snippet, null, 2) + '\n';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function install(env = process.env, out = process.stdout, cmd = launcherCommand()) {
|
|
16
|
+
const path = settingsPath(env);
|
|
17
|
+
writeSettings(path, withTermGameHooks(readSettings(path), cmd));
|
|
18
|
+
out.write(`afk-snake: installed wait-game hooks in ${path}\n`);
|
|
19
|
+
out.write(` UserPromptSubmit -> ${cmd} _start\n`);
|
|
20
|
+
out.write(` Stop -> ${cmd} _stop\n`);
|
|
21
|
+
out.write(`Run your agent inside tmux for the popup. Remove with: afk-snake uninstall\n`);
|
|
22
|
+
out.write(`\nManual equivalent (merge into ${path} yourself if you prefer;\n`);
|
|
23
|
+
out.write(`note: hooks added by hand must also be removed by hand โ uninstall only\n`);
|
|
24
|
+
out.write(`removes hooks written by 'afk-snake install'):\n`);
|
|
25
|
+
out.write(manualSnippet(cmd));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function uninstall(env = process.env, out = process.stdout) {
|
|
29
|
+
const path = settingsPath(env);
|
|
30
|
+
writeSettings(path, withoutTermGameHooks(readSettings(path)));
|
|
31
|
+
out.write(`afk-snake: removed wait-game hooks from ${path}\n`);
|
|
32
|
+
}
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
// This package's own entry point โ always present, so it is the reliable
|
|
6
|
+
// dev/local fallback for launching the game.
|
|
7
|
+
export const LOCAL_ENTRY = join(dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
8
|
+
|
|
9
|
+
export function isInsideTmux(env = process.env) {
|
|
10
|
+
return Boolean(env.TMUX);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Pure. onPath: did `term-game` resolve on PATH? (Param is named `onPath`, not
|
|
14
|
+
// `termGameOnPath`, to avoid colliding with the probe function of that name.)
|
|
15
|
+
// Tier 1 also covers a global install and `npx afk-snake` after publish.
|
|
16
|
+
export function resolveGameCommand({ onPath, localEntry = LOCAL_ENTRY }) {
|
|
17
|
+
if (onPath) return ['afk-snake'];
|
|
18
|
+
return ['node', localEntry];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Thin (not unit-tested): probe whether `term-game` is runnable on PATH.
|
|
22
|
+
export function termGameOnPath() {
|
|
23
|
+
try {
|
|
24
|
+
execSync('command -v afk-snake', { stdio: 'ignore', shell: '/bin/sh' });
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Thin: the resolved launch command as a single string, for hook commands.
|
|
32
|
+
// Path segments containing spaces are quoted so a space in the install dir
|
|
33
|
+
// doesn't break shell tokenisation when the string is embedded in a hook.
|
|
34
|
+
export function launcherCommand() {
|
|
35
|
+
const parts = resolveGameCommand({ onPath: termGameOnPath() });
|
|
36
|
+
return parts.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
|
|
37
|
+
}
|
package/src/login.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/login.js
|
|
2
|
+
// The `afk-snake login` GitHub device flow. Runs in a normal shell (never inside the
|
|
3
|
+
// real-time popup). Everything is injectable so it tests without network or delays.
|
|
4
|
+
import { saveAuth as realSaveAuth } from './profile.js';
|
|
5
|
+
import { createApi } from './api.js';
|
|
6
|
+
|
|
7
|
+
// Public OAuth client id for the term-game device flow (no secret needed).
|
|
8
|
+
export const GITHUB_CLIENT_ID = 'Ov23liVs5WRAqLXaySlG';
|
|
9
|
+
const DEFAULT_BASE = 'https://term-game-api.jsiwinski2.workers.dev';
|
|
10
|
+
|
|
11
|
+
export async function login({
|
|
12
|
+
fetch = globalThis.fetch,
|
|
13
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
14
|
+
out = process.stdout,
|
|
15
|
+
saveAuth = realSaveAuth,
|
|
16
|
+
env = process.env,
|
|
17
|
+
clientId = GITHUB_CLIENT_ID,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const codeRes = await fetch('https://github.com/login/device/code', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ client_id: clientId, scope: '' }),
|
|
23
|
+
});
|
|
24
|
+
const code = await codeRes.json();
|
|
25
|
+
out.write(`\nGo to ${code.verification_uri} and enter code: ${code.user_code}\n`);
|
|
26
|
+
|
|
27
|
+
let interval = code.interval || 5;
|
|
28
|
+
const deadline = (code.expires_in || 900);
|
|
29
|
+
let elapsed = 0;
|
|
30
|
+
while (elapsed < deadline) {
|
|
31
|
+
await sleep(interval * 1000);
|
|
32
|
+
elapsed += interval;
|
|
33
|
+
const tokRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
client_id: clientId,
|
|
38
|
+
device_code: code.device_code,
|
|
39
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
const tok = await tokRes.json();
|
|
43
|
+
if (tok.access_token) {
|
|
44
|
+
const api = createApi(env.TERM_GAME_API || DEFAULT_BASE, { fetch });
|
|
45
|
+
const session = await api.postLogin(tok.access_token);
|
|
46
|
+
saveAuth({ token: session.token, login: session.login });
|
|
47
|
+
out.write(`\nWelcome, @${session.login}\n`);
|
|
48
|
+
return { ok: true, login: session.login };
|
|
49
|
+
}
|
|
50
|
+
if (tok.error === 'authorization_pending') continue;
|
|
51
|
+
if (tok.error === 'slow_down') { interval += 5; continue; }
|
|
52
|
+
out.write(`\nLogin failed: ${tok.error || 'unknown error'}\n`);
|
|
53
|
+
return { ok: false, error: tok.error };
|
|
54
|
+
}
|
|
55
|
+
out.write('\nLogin timed out. Run `afk-snake login` again.\n');
|
|
56
|
+
return { ok: false, error: 'timeout' };
|
|
57
|
+
}
|
package/src/loop.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createGame, turn, step } from './engine.js';
|
|
3
|
+
import { drawFrame, fitBoard, clearScreen, overlayCenter } from './render.js';
|
|
4
|
+
import { mapKey } from './input.js';
|
|
5
|
+
import { nextBest } from './highscore.js';
|
|
6
|
+
import { gameOverCard } from './gameover.js';
|
|
7
|
+
import { readyCard } from './readycard.js';
|
|
8
|
+
import { loadSession, saveSession, clearSession } from './session.js';
|
|
9
|
+
import { canResume } from './resume.js';
|
|
10
|
+
import { createEconomyClient } from './economyClient.js';
|
|
11
|
+
import { economyLine } from './economyFooter.js';
|
|
12
|
+
|
|
13
|
+
const BASE_TICK_MS = 100;
|
|
14
|
+
const MIN_TICK_MS = 55;
|
|
15
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
16
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
17
|
+
const PLAY_HINT = 'p pause ยท q quit';
|
|
18
|
+
const PAUSE_HINT = 'paused ยท arrows resume ยท Enter back to Claude ยท q quit';
|
|
19
|
+
const READY_FOOTER = 'โ agent ready ยท Enter to return';
|
|
20
|
+
|
|
21
|
+
export function run(out = process.stdout, inp = process.stdin, economy = createEconomyClient()) {
|
|
22
|
+
const size = fitBoard(out.columns, out.rows);
|
|
23
|
+
if (!size) {
|
|
24
|
+
out.write('afk-snake needs a bigger terminal window. Resize and try again.\n');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const saved = loadSession();
|
|
29
|
+
const resuming = canResume(saved, size);
|
|
30
|
+
let state = resuming ? saved : createGame(size);
|
|
31
|
+
let lastScore = state.score;
|
|
32
|
+
let paused = resuming;
|
|
33
|
+
let ready = false;
|
|
34
|
+
let agentReady = false;
|
|
35
|
+
let best = 0;
|
|
36
|
+
let gameId = randomUUID();
|
|
37
|
+
let startTime = Date.now();
|
|
38
|
+
let economyText = null; // the game-over economy line, once known
|
|
39
|
+
let glowPending = false; // guards against a double-buy while /unlocks is in flight
|
|
40
|
+
let timer = null;
|
|
41
|
+
|
|
42
|
+
// Pull account state in the background; when it lands, refresh best + redraw (for glow).
|
|
43
|
+
economy.load().then(() => { best = economy.best; draw(); }).catch(() => {});
|
|
44
|
+
|
|
45
|
+
const tickMs = () => Math.max(MIN_TICK_MS, BASE_TICK_MS - state.score * 6);
|
|
46
|
+
|
|
47
|
+
const cleanup = () => {
|
|
48
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
49
|
+
if (inp.isTTY) inp.setRawMode(false);
|
|
50
|
+
inp.pause();
|
|
51
|
+
out.write(SHOW_CURSOR + '\n');
|
|
52
|
+
};
|
|
53
|
+
const quit = (code = 0) => { clearSession(); cleanup(); process.exit(code); };
|
|
54
|
+
const saveAndExit = () => {
|
|
55
|
+
if (state.status === 'playing') saveSession(state);
|
|
56
|
+
cleanup();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const showReady = () => {
|
|
61
|
+
agentReady = true;
|
|
62
|
+
ready = true;
|
|
63
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
64
|
+
draw();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const board = () => drawFrame(state, { glow: economy.hasGlow() });
|
|
68
|
+
|
|
69
|
+
const drawGameOver = () => {
|
|
70
|
+
const card = gameOverCard({ score: state.score, best, isNewBest: state.score >= best && state.score > 0, economyLine: economyText });
|
|
71
|
+
out.write(clearScreen() + overlayCenter(board(), card.split('\n')).join('\r\n'));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const draw = () => {
|
|
75
|
+
if (state.status === 'over') return drawGameOver();
|
|
76
|
+
if (ready) {
|
|
77
|
+
const composed = overlayCenter(board(), readyCard().split('\n'));
|
|
78
|
+
out.write(clearScreen() + composed.join('\r\n'));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const lines = board();
|
|
82
|
+
if (paused) lines.push(PAUSE_HINT);
|
|
83
|
+
else if (agentReady) lines.push(READY_FOOTER);
|
|
84
|
+
else if (state.status === 'playing') lines[lines.length - 1] += ' ยท ' + PLAY_HINT;
|
|
85
|
+
out.write(clearScreen() + lines.join('\r\n'));
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const schedule = () => {
|
|
89
|
+
if (timer) clearInterval(timer);
|
|
90
|
+
timer = setInterval(onTick, tickMs());
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const newGame = () => {
|
|
94
|
+
clearSession();
|
|
95
|
+
state = createGame(size);
|
|
96
|
+
lastScore = 0;
|
|
97
|
+
paused = false;
|
|
98
|
+
gameId = randomUUID();
|
|
99
|
+
startTime = Date.now();
|
|
100
|
+
economyText = null;
|
|
101
|
+
draw();
|
|
102
|
+
schedule();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// On game-over: set best, show the card immediately, then (logged in) post facts and
|
|
106
|
+
// refresh the economy line with the server's truth.
|
|
107
|
+
function onGameOver() {
|
|
108
|
+
clearInterval(timer); timer = null;
|
|
109
|
+
clearSession();
|
|
110
|
+
best = nextBest(best, state.score).best;
|
|
111
|
+
if (economy.loggedIn) {
|
|
112
|
+
economyText = economyLine({ loggedIn: true, awarded: state.score, balance: economy.balance, canAfford: economy.canBuyGlow() });
|
|
113
|
+
drawGameOver();
|
|
114
|
+
economy.submitGame({ gameId, score: state.score, durationMs: Date.now() - startTime, foodsEaten: state.score })
|
|
115
|
+
.then((r) => {
|
|
116
|
+
if (!r) return;
|
|
117
|
+
best = nextBest(best, r.best).best;
|
|
118
|
+
economyText = economyLine({ loggedIn: true, awarded: r.awarded, balance: economy.balance, canAfford: economy.canBuyGlow() });
|
|
119
|
+
drawGameOver();
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {});
|
|
122
|
+
} else {
|
|
123
|
+
economyText = economyLine({ loggedIn: false, awarded: state.score });
|
|
124
|
+
drawGameOver();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onTick() {
|
|
129
|
+
state = step(state);
|
|
130
|
+
if (state.status === 'over') return onGameOver();
|
|
131
|
+
if (state.score !== lastScore) { lastScore = state.score; schedule(); }
|
|
132
|
+
draw();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onKey(data) {
|
|
136
|
+
const intent = mapKey(data);
|
|
137
|
+
if (ready) {
|
|
138
|
+
if (intent === 'enter') return saveAndExit();
|
|
139
|
+
const RESUME = ['space', 'up', 'down', 'left', 'right'];
|
|
140
|
+
if (RESUME.includes(intent)) {
|
|
141
|
+
if (state.status !== 'playing') return;
|
|
142
|
+
ready = false;
|
|
143
|
+
paused = true;
|
|
144
|
+
draw();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Game-over: [S] buys the glow skin, [A] points to `afk-snake login`. Read the raw
|
|
150
|
+
// key because 's'/'a' map to movement (WASD), which is irrelevant on a dead game.
|
|
151
|
+
if (state.status === 'over') {
|
|
152
|
+
const raw = data.toString().toLowerCase();
|
|
153
|
+
if (raw === 's' && economy.canBuyGlow() && !glowPending) {
|
|
154
|
+
glowPending = true;
|
|
155
|
+
economy.buyGlow().then((res) => {
|
|
156
|
+
glowPending = false;
|
|
157
|
+
economyText = res.ok
|
|
158
|
+
? 'Glowing trail unlocked'
|
|
159
|
+
: economyLine({ loggedIn: economy.loggedIn, awarded: state.score, balance: economy.balance, canAfford: economy.canBuyGlow() });
|
|
160
|
+
drawGameOver();
|
|
161
|
+
}).catch(() => { glowPending = false; });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (raw === 'a' && !economy.loggedIn) {
|
|
165
|
+
economyText = 'run: afk-snake login';
|
|
166
|
+
drawGameOver();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (paused) {
|
|
171
|
+
if (intent === 'quit') return quit(0);
|
|
172
|
+
if (intent === 'restart') return newGame();
|
|
173
|
+
if (intent === 'enter') return saveAndExit();
|
|
174
|
+
paused = false;
|
|
175
|
+
if (intent && intent !== 'space' && intent !== 'pause') state = turn(state, intent);
|
|
176
|
+
draw();
|
|
177
|
+
schedule();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!intent) return;
|
|
181
|
+
if (intent === 'quit') return quit(0);
|
|
182
|
+
if (intent === 'restart') return newGame();
|
|
183
|
+
if (intent === 'enter') { if (agentReady) return saveAndExit(); return; }
|
|
184
|
+
if (intent === 'pause') {
|
|
185
|
+
if (state.status !== 'playing') return;
|
|
186
|
+
paused = true;
|
|
187
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
188
|
+
draw();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (intent === 'space') return;
|
|
192
|
+
if (state.status === 'playing') state = turn(state, intent);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (inp.isTTY) inp.setRawMode(true);
|
|
196
|
+
inp.resume();
|
|
197
|
+
inp.on('data', onKey);
|
|
198
|
+
process.on('SIGINT', () => quit(0));
|
|
199
|
+
process.on('SIGTERM', saveAndExit);
|
|
200
|
+
process.on('SIGUSR2', showReady);
|
|
201
|
+
out.write(HIDE_CURSOR);
|
|
202
|
+
draw();
|
|
203
|
+
if (!paused) schedule();
|
|
204
|
+
}
|
package/src/popup.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isInsideTmux, resolveGameCommand, termGameOnPath, LOCAL_ENTRY } from './launcher.js';
|
|
6
|
+
|
|
7
|
+
// The popup runs a wrapper shell that records its OWN pid (which `exec` then
|
|
8
|
+
// hands to the game process) so the Stop hook can SIGTERM the real game. The
|
|
9
|
+
// game's existing SIGTERM handler restores the terminal and exits, the popup
|
|
10
|
+
// command finishes, and tmux closes the popup.
|
|
11
|
+
export function buildPopupCommand(gameArgv, pidfile) {
|
|
12
|
+
// Quote argv parts and the pidfile that contain spaces, so an install path
|
|
13
|
+
// like `~/My Projects/term_game` doesn't word-split inside the wrapper shell.
|
|
14
|
+
const game = gameArgv.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
|
|
15
|
+
return `sh -c 'echo $$ > "${pidfile}"; exec ${game}'`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pidfilePath(env = process.env) {
|
|
19
|
+
return join(env.TMPDIR || tmpdir(), 'term-game-popup.pid');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pidAlive(pid) {
|
|
23
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// UserPromptSubmit hook: agent started working.
|
|
27
|
+
export function startPopup(env = process.env, out = process.stderr) {
|
|
28
|
+
if (!isInsideTmux(env)) {
|
|
29
|
+
out.write('afk-snake: run inside tmux to enable the wait-game popup.\n');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const pidfile = pidfilePath(env);
|
|
33
|
+
if (existsSync(pidfile)) {
|
|
34
|
+
const pid = Number(readFileSync(pidfile, 'utf8').trim());
|
|
35
|
+
if (pid && pidAlive(pid)) return; // already up -> no stacked popups
|
|
36
|
+
}
|
|
37
|
+
const gameArgv = resolveGameCommand({ onPath: termGameOnPath(), localEntry: LOCAL_ENTRY });
|
|
38
|
+
const popupCmd = buildPopupCommand(gameArgv, pidfile);
|
|
39
|
+
// Detached + unref so the hook returns immediately and never blocks the agent.
|
|
40
|
+
const child = spawn('tmux', ['display-popup', '-E', '-w', '90%', '-h', '90%', popupCmd], {
|
|
41
|
+
stdio: 'ignore',
|
|
42
|
+
detached: true,
|
|
43
|
+
});
|
|
44
|
+
// spawn errors (e.g. tmux not on PATH) arrive async as an 'error' event;
|
|
45
|
+
// swallow them so a hook environment without tmux never crashes the agent.
|
|
46
|
+
child.on('error', () => {});
|
|
47
|
+
child.unref();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stop hook: agent finished its turn. Signal the running game (SIGUSR2) to show its
|
|
51
|
+
// "Your Agent is Ready" card. Do NOT kill it or close the popup โ the game closes itself
|
|
52
|
+
// when the player presses Enter. Leave the pidfile in place (the game is still alive).
|
|
53
|
+
export function stopPopup(env = process.env) {
|
|
54
|
+
const pidfile = pidfilePath(env);
|
|
55
|
+
if (!existsSync(pidfile)) return;
|
|
56
|
+
const pid = Number(readFileSync(pidfile, 'utf8').trim());
|
|
57
|
+
if (pid && pidAlive(pid)) {
|
|
58
|
+
try { process.kill(pid, 'SIGUSR2'); } catch {}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/profile.js
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const PROFILE_PATH = join(homedir(), '.term-game.json');
|
|
7
|
+
|
|
8
|
+
export function loadProfile(path = PROFILE_PATH) {
|
|
9
|
+
try {
|
|
10
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
11
|
+
return data && typeof data === 'object' ? data : {};
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveProfile(profile, path = PROFILE_PATH) {
|
|
18
|
+
writeFileSync(path, JSON.stringify(profile), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadAuth(path = PROFILE_PATH) {
|
|
22
|
+
const { auth } = loadProfile(path);
|
|
23
|
+
return auth && typeof auth.token === 'string' ? auth : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveAuth({ token, login }, path = PROFILE_PATH) {
|
|
27
|
+
const profile = loadProfile(path);
|
|
28
|
+
profile.auth = { token, login };
|
|
29
|
+
saveProfile(profile, path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearAuth(path = PROFILE_PATH) {
|
|
33
|
+
const profile = loadProfile(path);
|
|
34
|
+
delete profile.auth;
|
|
35
|
+
saveProfile(profile, path);
|
|
36
|
+
}
|
package/src/readycard.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Built programmatically so every line is exactly the same length โ required for the
|
|
2
|
+
// centered overlay (overlayCenter). No wide emoji (which would break column alignment).
|
|
3
|
+
export function readyCard() {
|
|
4
|
+
const W = 34; // inner width (between the side borders)
|
|
5
|
+
const pad = (s) => {
|
|
6
|
+
const total = Math.max(0, W - s.length);
|
|
7
|
+
const left = Math.floor(total / 2);
|
|
8
|
+
return ' '.repeat(left) + s + ' '.repeat(total - left);
|
|
9
|
+
};
|
|
10
|
+
// label padded to a fixed width so the arrows line up across action rows
|
|
11
|
+
const action = (label, desc) => (' ' + label.padEnd(14) + 'โ ' + desc).padEnd(W).slice(0, W);
|
|
12
|
+
const row = (s) => 'โ' + s + 'โ';
|
|
13
|
+
return [
|
|
14
|
+
'โ' + 'โ'.repeat(W) + 'โ',
|
|
15
|
+
row(pad('YOUR AGENT IS READY')),
|
|
16
|
+
row(' '.repeat(W)),
|
|
17
|
+
row(action('Enter', 'back to Claude')),
|
|
18
|
+
row(action('Space/Arrows', 'resume playing')),
|
|
19
|
+
'โ' + 'โ'.repeat(W) + 'โ',
|
|
20
|
+
].join('\n');
|
|
21
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { paintCell } from './glow.js';
|
|
2
|
+
|
|
3
|
+
const RESET = '\x1b[0m';
|
|
4
|
+
|
|
5
|
+
export const CHARS = {
|
|
6
|
+
head: '@', body: 'o', food: '*', empty: ' ',
|
|
7
|
+
h: 'โ', v: 'โ', tl: 'โ', tr: 'โ', bl: 'โ', br: 'โ',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function fitBoard(cols, rows, min = { width: 10, height: 8 }) {
|
|
11
|
+
const width = (cols ?? 0) - 2; // 2 vertical borders
|
|
12
|
+
const height = (rows ?? 0) - 3; // 2 horizontal borders + 1 score line
|
|
13
|
+
if (width < min.width || height < min.height) return null;
|
|
14
|
+
return { width, height };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function drawFrame(state, { glow = false } = {}) {
|
|
18
|
+
const { width, height, snake, foods, score } = state;
|
|
19
|
+
const grid = [];
|
|
20
|
+
for (let y = 0; y < height; y++) grid.push(new Array(width).fill(CHARS.empty));
|
|
21
|
+
for (const f of foods) grid[f.y][f.x] = CHARS.food;
|
|
22
|
+
snake.forEach((c, i) => {
|
|
23
|
+
const ch = i === 0 ? CHARS.head : CHARS.body;
|
|
24
|
+
grid[c.y][c.x] = glow ? paintCell(ch, i) : ch;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const lines = [];
|
|
28
|
+
lines.push(CHARS.tl + CHARS.h.repeat(width) + CHARS.tr);
|
|
29
|
+
for (const row of grid) lines.push(CHARS.v + row.join('') + CHARS.v);
|
|
30
|
+
lines.push(CHARS.bl + CHARS.h.repeat(width) + CHARS.br);
|
|
31
|
+
lines.push(`Score: ${score}`);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ESC = '\x1b[';
|
|
36
|
+
export function clearScreen() {
|
|
37
|
+
return `${ESC}2J${ESC}H`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Split a line into visible cells (1 display column each), where any ANSI escape
|
|
41
|
+
// run attaches to the printable char that follows it. We only emit our own escapes
|
|
42
|
+
// (colour + reset), so this is sufficient.
|
|
43
|
+
function visibleCells(line) {
|
|
44
|
+
return line.match(/(?:\x1b\[[0-9;]*m)*[\s\S]/g) || [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Write `card` lines centered over `base` lines. For plain lines this is the original
|
|
48
|
+
// byte-for-byte behavior; for lines containing ANSI it splices by VISIBLE column and
|
|
49
|
+
// wraps the inserted card text in resets so ambient colour never bleeds into it.
|
|
50
|
+
export function overlayCenter(base, card) {
|
|
51
|
+
if (card.length === 0) return [...base];
|
|
52
|
+
const rowOffset = Math.max(0, Math.floor((base.length - card.length) / 2));
|
|
53
|
+
return base.map((line, i) => {
|
|
54
|
+
const c = card[i - rowOffset];
|
|
55
|
+
if (c === undefined) return line;
|
|
56
|
+
if (!line.includes('\x1b')) {
|
|
57
|
+
const colOffset = Math.max(0, Math.floor((line.length - c.length) / 2));
|
|
58
|
+
return line.slice(0, colOffset) + c + line.slice(colOffset + c.length);
|
|
59
|
+
}
|
|
60
|
+
const cells = visibleCells(line);
|
|
61
|
+
const colOffset = Math.max(0, Math.floor((cells.length - c.length) / 2));
|
|
62
|
+
const left = cells.slice(0, colOffset).join('');
|
|
63
|
+
const right = cells.slice(colOffset + c.length).join('');
|
|
64
|
+
return left + RESET + c + RESET + right;
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Decide whether a loaded session should resume, or be discarded for a fresh game.
|
|
2
|
+
// Resume only an in-progress game whose board still fits the current popup size.
|
|
3
|
+
export function canResume(saved, size) {
|
|
4
|
+
return Boolean(
|
|
5
|
+
saved &&
|
|
6
|
+
saved.status === 'playing' &&
|
|
7
|
+
size &&
|
|
8
|
+
saved.width <= size.width &&
|
|
9
|
+
saved.height <= size.height,
|
|
10
|
+
);
|
|
11
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function sessionPath(env = process.env) {
|
|
6
|
+
return join(env.TMPDIR || tmpdir(), 'term-game-session.json');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function saveSession(state, env = process.env) {
|
|
10
|
+
const { rng, ...rest } = state; // rng is a function โ not JSON-serializable
|
|
11
|
+
writeFileSync(sessionPath(env), JSON.stringify(rest), 'utf8');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadSession(env = process.env) {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(readFileSync(sessionPath(env), 'utf8'));
|
|
17
|
+
if (!data || !Array.isArray(data.snake) || typeof data.score !== 'number') return null;
|
|
18
|
+
// back-compat: pre-multi-food saves stored a single `food`; normalise to `foods`.
|
|
19
|
+
if (!Array.isArray(data.foods)) data.foods = data.food ? [data.food] : [];
|
|
20
|
+
delete data.food;
|
|
21
|
+
return { ...data, rng: Math.random };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearSession(env = process.env) {
|
|
28
|
+
rmSync(sessionPath(env), { force: true }); // force: tolerate a missing file, surface real errors
|
|
29
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function settingsPath(env = process.env) {
|
|
6
|
+
const home = env.HOME || homedir();
|
|
7
|
+
return join(home, '.claude', 'settings.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readSettings(path) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeSettings(path, settings) {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
21
|
+
}
|
package/src/skins.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/skins.js
|
|
2
|
+
// Shared id for the one v1 unlock (mirrors server/src/catalog.js) + ownership check.
|
|
3
|
+
export const SKIN_GLOW = 'skin-glow';
|
|
4
|
+
|
|
5
|
+
export function hasGlow(unlocks = []) {
|
|
6
|
+
return Array.isArray(unlocks) && unlocks.includes(SKIN_GLOW);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Client-side mirror of the server catalog price, used only to decide whether to
|
|
10
|
+
// show the [S] spend prompt. The server remains authoritative on the actual charge.
|
|
11
|
+
export const GLOW_PRICE = 30;
|