pi-mega-compact 0.8.1 → 0.8.2
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/dist/extensions/dashboard-server/server.js +16 -0
- package/dist/extensions/mega-game-cmds.js +104 -1
- package/dist/extensions/mega-game-cmds.test.js +31 -2
- package/extensions/dashboard-server/server.ts +16 -0
- package/extensions/mega-game-cmds.test.ts +37 -2
- package/extensions/mega-game-cmds.ts +117 -1
- package/package.json +1 -1
|
@@ -496,6 +496,18 @@ export async function launchDashboardServer(stateDir) {
|
|
|
496
496
|
log("server running", { url });
|
|
497
497
|
// eslint-disable-next-line no-console
|
|
498
498
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
499
|
+
// v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
|
|
500
|
+
// resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
|
|
501
|
+
// browser hit ::1:port and get connection refused. PREVENT-PI-004
|
|
502
|
+
// (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
|
|
503
|
+
// hosts or a ::1 already in use just skip the mirror.
|
|
504
|
+
let v6;
|
|
505
|
+
const v4Handler = server.listeners("request")[0];
|
|
506
|
+
if (v4Handler) {
|
|
507
|
+
v6 = createServer((r, s) => v4Handler.call(server, r, s));
|
|
508
|
+
v6.on("error", (e) => log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }));
|
|
509
|
+
v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
|
|
510
|
+
}
|
|
499
511
|
// Write port.pid
|
|
500
512
|
try {
|
|
501
513
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
@@ -510,6 +522,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
510
522
|
}
|
|
511
523
|
catch { /* already gone */ }
|
|
512
524
|
server.close();
|
|
525
|
+
try {
|
|
526
|
+
v6?.close();
|
|
527
|
+
}
|
|
528
|
+
catch { /* not bound */ }
|
|
513
529
|
process.exit(0);
|
|
514
530
|
};
|
|
515
531
|
process.on("SIGTERM", cleanup);
|
|
@@ -40,8 +40,20 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
40
40
|
runtime.bindRepo(ctx.cwd);
|
|
41
41
|
const stateDir = runtime.currentStateDir;
|
|
42
42
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
43
|
-
// bare →
|
|
43
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
44
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
45
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
46
|
+
// power users + scripts.
|
|
44
47
|
if (parts.length === 0) {
|
|
48
|
+
if (typeof ctx.ui.select === "function") {
|
|
49
|
+
try {
|
|
50
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
55
|
+
}
|
|
56
|
+
}
|
|
45
57
|
const s = getGameState(stateDir);
|
|
46
58
|
for (const line of fmtState(s))
|
|
47
59
|
ctx.ui.notify(line);
|
|
@@ -105,6 +117,97 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
105
117
|
}
|
|
106
118
|
ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
|
|
107
119
|
}
|
|
120
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
121
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
122
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
123
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
124
|
+
*
|
|
125
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
126
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
127
|
+
async function runInteractiveMenu(ctx, runtime, stateDir) {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const s = getGameState(stateDir);
|
|
130
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
131
|
+
const choice = await ctx.ui.select(`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`, [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"]);
|
|
132
|
+
if (choice === undefined || choice === "Done")
|
|
133
|
+
return;
|
|
134
|
+
if (choice === toggleLabel) {
|
|
135
|
+
const next = !s.game_mode_on;
|
|
136
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
137
|
+
runtime.bumpGameState();
|
|
138
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (choice === "Theme…") {
|
|
142
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (choice === "TUI display mode…") {
|
|
146
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (choice === "Achievements…") {
|
|
150
|
+
await achievementsView(ctx, stateDir);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
156
|
+
async function themeSubmenu(ctx, runtime, stateDir) {
|
|
157
|
+
const s = getGameState(stateDir);
|
|
158
|
+
const opts = THEMES.map((t) => {
|
|
159
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
160
|
+
return `${t.id}${mark} ${t.label}`;
|
|
161
|
+
});
|
|
162
|
+
opts.push("next (cycle to next theme)");
|
|
163
|
+
opts.push("Back");
|
|
164
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
165
|
+
if (choice === undefined || choice === "Back")
|
|
166
|
+
return;
|
|
167
|
+
const first = choice.split(/\s+/)[0];
|
|
168
|
+
let id;
|
|
169
|
+
if (first === "next") {
|
|
170
|
+
id = nextTheme(s.theme);
|
|
171
|
+
}
|
|
172
|
+
else if (isValidTheme(first)) {
|
|
173
|
+
id = first;
|
|
174
|
+
}
|
|
175
|
+
if (id && id !== s.theme) {
|
|
176
|
+
setGameState({ theme: id }, stateDir);
|
|
177
|
+
runtime.bumpGameState();
|
|
178
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
182
|
+
async function tuiSubmenu(ctx, runtime, stateDir) {
|
|
183
|
+
const s = getGameState(stateDir);
|
|
184
|
+
const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
|
|
185
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
186
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
187
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
188
|
+
"Back",
|
|
189
|
+
]);
|
|
190
|
+
if (choice === undefined || choice === "Back")
|
|
191
|
+
return;
|
|
192
|
+
const mode = choice.split(/\s+/)[0];
|
|
193
|
+
if (mode === "full" || mode === "minimal") {
|
|
194
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
195
|
+
runtime.bumpGameState();
|
|
196
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
200
|
+
async function achievementsView(ctx, stateDir) {
|
|
201
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
202
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
203
|
+
for (const r of rows)
|
|
204
|
+
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
205
|
+
const lines = rows.length
|
|
206
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
207
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
208
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
209
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
210
|
+
}
|
|
108
211
|
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
109
212
|
export function registerGameCommands(pi, runtime) {
|
|
110
213
|
const description = "Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
|
|
@@ -16,13 +16,13 @@ import { THEME_IDS } from "../src/config/themes.js";
|
|
|
16
16
|
// that wires the command against a fake pi without binding to the real pi
|
|
17
17
|
// module at load time.
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
|
-
function makeHarness(stateDir) {
|
|
19
|
+
function makeHarness(stateDir, select) {
|
|
20
20
|
const commands = {};
|
|
21
21
|
const notifies = [];
|
|
22
22
|
const runtime = { bindRepo: () => { }, currentStateDir: stateDir, bumpGameState: () => { } };
|
|
23
23
|
const ctx = {
|
|
24
24
|
cwd: stateDir,
|
|
25
|
-
ui: { notify: (s) => notifies.push(s) },
|
|
25
|
+
ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
|
|
26
26
|
};
|
|
27
27
|
const fakePi = {
|
|
28
28
|
registerCommand: (name, opts) => {
|
|
@@ -63,6 +63,35 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
63
63
|
assert.ok(lines.some((l) => l.includes("transparent")));
|
|
64
64
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
65
65
|
});
|
|
66
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
67
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
68
|
+
let i = 0;
|
|
69
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
70
|
+
h.notifies.length = 0;
|
|
71
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
72
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
73
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
74
|
+
// toggle back off via the menu
|
|
75
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
76
|
+
let j = 0;
|
|
77
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
78
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
79
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
80
|
+
});
|
|
81
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
82
|
+
// default harness has no select — mimics RPC/print mode
|
|
83
|
+
const lines = await run("");
|
|
84
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
85
|
+
});
|
|
86
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
87
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
88
|
+
let i = 0;
|
|
89
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
90
|
+
h.notifies.length = 0;
|
|
91
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
92
|
+
assert.equal(getGameState().theme, "retro");
|
|
93
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
94
|
+
});
|
|
66
95
|
it("on enables game mode and persists", async () => {
|
|
67
96
|
await run("on");
|
|
68
97
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -470,6 +470,21 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
470
470
|
// eslint-disable-next-line no-console
|
|
471
471
|
console.log(`[mega-compact] dashboard server running: ${url}`);
|
|
472
472
|
|
|
473
|
+
// v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
|
|
474
|
+
// resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
|
|
475
|
+
// browser hit ::1:port and get connection refused. PREVENT-PI-004
|
|
476
|
+
// (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
|
|
477
|
+
// hosts or a ::1 already in use just skip the mirror.
|
|
478
|
+
let v6: ReturnType<typeof createServer> | undefined;
|
|
479
|
+
const v4Handler = server.listeners("request")[0];
|
|
480
|
+
if (v4Handler) {
|
|
481
|
+
v6 = createServer((r, s) => (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(server, r, s));
|
|
482
|
+
v6.on("error", (e: NodeJS.ErrnoException) =>
|
|
483
|
+
log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }),
|
|
484
|
+
);
|
|
485
|
+
v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
|
|
486
|
+
}
|
|
487
|
+
|
|
473
488
|
// Write port.pid
|
|
474
489
|
try {
|
|
475
490
|
writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
|
|
@@ -481,6 +496,7 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
|
|
|
481
496
|
const cleanup = () => {
|
|
482
497
|
try { unlinkSync(portFile); } catch { /* already gone */ }
|
|
483
498
|
server.close();
|
|
499
|
+
try { v6?.close(); } catch { /* not bound */ }
|
|
484
500
|
process.exit(0);
|
|
485
501
|
};
|
|
486
502
|
process.on("SIGTERM", cleanup);
|
|
@@ -26,13 +26,16 @@ type Harness = {
|
|
|
26
26
|
ctx: any;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function makeHarness(
|
|
29
|
+
function makeHarness(
|
|
30
|
+
stateDir: string,
|
|
31
|
+
select?: (title: string, options: string[]) => Promise<string | undefined>,
|
|
32
|
+
): Harness {
|
|
30
33
|
const commands: Record<string, Cmd> = {};
|
|
31
34
|
const notifies: string[] = [];
|
|
32
35
|
const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
|
|
33
36
|
const ctx = {
|
|
34
37
|
cwd: stateDir,
|
|
35
|
-
ui: { notify: (s: string) => notifies.push(s) },
|
|
38
|
+
ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
|
|
36
39
|
};
|
|
37
40
|
const fakePi = {
|
|
38
41
|
registerCommand: (name: string, opts: Cmd) => {
|
|
@@ -80,6 +83,38 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
80
83
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
81
84
|
});
|
|
82
85
|
|
|
86
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
87
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
88
|
+
let i = 0;
|
|
89
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
90
|
+
h.notifies.length = 0;
|
|
91
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
92
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
93
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
94
|
+
// toggle back off via the menu
|
|
95
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
96
|
+
let j = 0;
|
|
97
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
98
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
99
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
103
|
+
// default harness has no select — mimics RPC/print mode
|
|
104
|
+
const lines = await run("");
|
|
105
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
109
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
110
|
+
let i = 0;
|
|
111
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
112
|
+
h.notifies.length = 0;
|
|
113
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
114
|
+
assert.equal(getGameState().theme, "retro");
|
|
115
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
116
|
+
});
|
|
117
|
+
|
|
83
118
|
it("on enables game mode and persists", async () => {
|
|
84
119
|
await run("on");
|
|
85
120
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -55,8 +55,19 @@ async function handleSettings(
|
|
|
55
55
|
const stateDir = runtime.currentStateDir;
|
|
56
56
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
57
57
|
|
|
58
|
-
// bare →
|
|
58
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
59
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
60
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
61
|
+
// power users + scripts.
|
|
59
62
|
if (parts.length === 0) {
|
|
63
|
+
if (typeof ctx.ui.select === "function") {
|
|
64
|
+
try {
|
|
65
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
66
|
+
return;
|
|
67
|
+
} catch {
|
|
68
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
69
|
+
}
|
|
70
|
+
}
|
|
60
71
|
const s = getGameState(stateDir);
|
|
61
72
|
for (const line of fmtState(s)) ctx.ui.notify(line);
|
|
62
73
|
return;
|
|
@@ -126,6 +137,111 @@ async function handleSettings(
|
|
|
126
137
|
);
|
|
127
138
|
}
|
|
128
139
|
|
|
140
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
141
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
142
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
143
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
144
|
+
*
|
|
145
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
146
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
147
|
+
async function runInteractiveMenu(
|
|
148
|
+
ctx: ExtensionContext,
|
|
149
|
+
runtime: MegaRuntime,
|
|
150
|
+
stateDir: string,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const s = getGameState(stateDir);
|
|
154
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
155
|
+
const choice = await ctx.ui.select(
|
|
156
|
+
`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
|
|
157
|
+
[toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
|
|
158
|
+
);
|
|
159
|
+
if (choice === undefined || choice === "Done") return;
|
|
160
|
+
if (choice === toggleLabel) {
|
|
161
|
+
const next = !s.game_mode_on;
|
|
162
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
163
|
+
runtime.bumpGameState();
|
|
164
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (choice === "Theme…") {
|
|
168
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (choice === "TUI display mode…") {
|
|
172
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (choice === "Achievements…") {
|
|
176
|
+
await achievementsView(ctx, stateDir);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
183
|
+
async function themeSubmenu(
|
|
184
|
+
ctx: ExtensionContext,
|
|
185
|
+
runtime: MegaRuntime,
|
|
186
|
+
stateDir: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const s = getGameState(stateDir);
|
|
189
|
+
const opts = THEMES.map((t) => {
|
|
190
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
191
|
+
return `${t.id}${mark} ${t.label}`;
|
|
192
|
+
});
|
|
193
|
+
opts.push("next (cycle to next theme)");
|
|
194
|
+
opts.push("Back");
|
|
195
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
196
|
+
if (choice === undefined || choice === "Back") return;
|
|
197
|
+
const first = choice.split(/\s+/)[0]!;
|
|
198
|
+
let id: string | undefined;
|
|
199
|
+
if (first === "next") {
|
|
200
|
+
id = nextTheme(s.theme);
|
|
201
|
+
} else if (isValidTheme(first)) {
|
|
202
|
+
id = first;
|
|
203
|
+
}
|
|
204
|
+
if (id && id !== s.theme) {
|
|
205
|
+
setGameState({ theme: id }, stateDir);
|
|
206
|
+
runtime.bumpGameState();
|
|
207
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
212
|
+
async function tuiSubmenu(
|
|
213
|
+
ctx: ExtensionContext,
|
|
214
|
+
runtime: MegaRuntime,
|
|
215
|
+
stateDir: string,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const s = getGameState(stateDir);
|
|
218
|
+
const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
|
|
219
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
220
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
221
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
222
|
+
"Back",
|
|
223
|
+
]);
|
|
224
|
+
if (choice === undefined || choice === "Back") return;
|
|
225
|
+
const mode = choice.split(/\s+/)[0];
|
|
226
|
+
if (mode === "full" || mode === "minimal") {
|
|
227
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
228
|
+
runtime.bumpGameState();
|
|
229
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
234
|
+
async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
|
|
235
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
236
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
237
|
+
for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
238
|
+
const lines = rows.length
|
|
239
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
240
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
241
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
242
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
243
|
+
}
|
|
244
|
+
|
|
129
245
|
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
130
246
|
export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
|
|
131
247
|
const description =
|
package/package.json
CHANGED