pi-mega-compact 0.8.1 → 0.8.3
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 +116 -4
- package/dist/extensions/mega-game-cmds.test.js +49 -4
- package/dist/extensions/mega-pipeline/compact.js +1 -0
- package/dist/extensions/mega-runtime/state.js +40 -3
- package/dist/extensions/mega-runtime/state.test.js +58 -0
- package/dist/extensions/mega-runtime/widget.js +52 -6
- package/dist/extensions/mega-runtime/widget.test.js +45 -0
- package/extensions/dashboard-server/server.ts +16 -0
- package/extensions/mega-game-cmds.test.ts +58 -4
- package/extensions/mega-game-cmds.ts +130 -4
- package/extensions/mega-pipeline/compact.ts +1 -0
- package/extensions/mega-runtime/state.test.ts +58 -0
- package/extensions/mega-runtime/state.ts +48 -3
- package/extensions/mega-runtime/widget.test.ts +50 -0
- package/extensions/mega-runtime/widget.ts +65 -6
- 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);
|
|
@@ -27,6 +27,15 @@ import { listAchievements } from "../src/store/sqlite/game-achievements.js";
|
|
|
27
27
|
import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
|
|
28
28
|
/** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
|
|
29
29
|
const TAG = "mega-compact-settings";
|
|
30
|
+
/** Apply a game_state mutation to the live TUI: evict the memoized cache
|
|
31
|
+
* (bumpGameState) THEN recompute the widget snapshot (snapshot(ctx)) so the
|
|
32
|
+
* panel picks up the new theme/mode/toggle immediately — no pi restart needed.
|
|
33
|
+
* snapshot() re-reads the (evicted) game_state into widgetData and re-registers
|
|
34
|
+
* the widget factory. Order matters: bump first, snapshot second. */
|
|
35
|
+
function applyChange(ctx, runtime) {
|
|
36
|
+
runtime.bumpGameState();
|
|
37
|
+
runtime.snapshot(ctx);
|
|
38
|
+
}
|
|
30
39
|
/** Format the current state as a human-readable status line set. */
|
|
31
40
|
function fmtState(s) {
|
|
32
41
|
return [
|
|
@@ -40,8 +49,20 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
40
49
|
runtime.bindRepo(ctx.cwd);
|
|
41
50
|
const stateDir = runtime.currentStateDir;
|
|
42
51
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
43
|
-
// bare →
|
|
52
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
53
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
54
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
55
|
+
// power users + scripts.
|
|
44
56
|
if (parts.length === 0) {
|
|
57
|
+
if (typeof ctx.ui.select === "function") {
|
|
58
|
+
try {
|
|
59
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
const s = getGameState(stateDir);
|
|
46
67
|
for (const line of fmtState(s))
|
|
47
68
|
ctx.ui.notify(line);
|
|
@@ -60,7 +81,7 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
60
81
|
// on|off
|
|
61
82
|
if (sub === "on" || sub === "off") {
|
|
62
83
|
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
63
|
-
runtime
|
|
84
|
+
applyChange(ctx, runtime);
|
|
64
85
|
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
65
86
|
return;
|
|
66
87
|
}
|
|
@@ -87,7 +108,7 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
87
108
|
return;
|
|
88
109
|
}
|
|
89
110
|
const s = setGameState({ theme: id }, stateDir);
|
|
90
|
-
runtime
|
|
111
|
+
applyChange(ctx, runtime);
|
|
91
112
|
ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
92
113
|
return;
|
|
93
114
|
}
|
|
@@ -99,12 +120,103 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
99
120
|
return;
|
|
100
121
|
}
|
|
101
122
|
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
102
|
-
runtime
|
|
123
|
+
applyChange(ctx, runtime);
|
|
103
124
|
ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
|
|
104
125
|
return;
|
|
105
126
|
}
|
|
106
127
|
ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
|
|
107
128
|
}
|
|
129
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
130
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
131
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
132
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
133
|
+
*
|
|
134
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
135
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
136
|
+
async function runInteractiveMenu(ctx, runtime, stateDir) {
|
|
137
|
+
for (;;) {
|
|
138
|
+
const s = getGameState(stateDir);
|
|
139
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
140
|
+
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"]);
|
|
141
|
+
if (choice === undefined || choice === "Done")
|
|
142
|
+
return;
|
|
143
|
+
if (choice === toggleLabel) {
|
|
144
|
+
const next = !s.game_mode_on;
|
|
145
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
146
|
+
applyChange(ctx, runtime);
|
|
147
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (choice === "Theme…") {
|
|
151
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (choice === "TUI display mode…") {
|
|
155
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (choice === "Achievements…") {
|
|
159
|
+
await achievementsView(ctx, stateDir);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
165
|
+
async function themeSubmenu(ctx, runtime, stateDir) {
|
|
166
|
+
const s = getGameState(stateDir);
|
|
167
|
+
const opts = THEMES.map((t) => {
|
|
168
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
169
|
+
return `${t.id}${mark} ${t.label}`;
|
|
170
|
+
});
|
|
171
|
+
opts.push("next (cycle to next theme)");
|
|
172
|
+
opts.push("Back");
|
|
173
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
174
|
+
if (choice === undefined || choice === "Back")
|
|
175
|
+
return;
|
|
176
|
+
const first = choice.split(/\s+/)[0];
|
|
177
|
+
let id;
|
|
178
|
+
if (first === "next") {
|
|
179
|
+
id = nextTheme(s.theme);
|
|
180
|
+
}
|
|
181
|
+
else if (isValidTheme(first)) {
|
|
182
|
+
id = first;
|
|
183
|
+
}
|
|
184
|
+
if (id && id !== s.theme) {
|
|
185
|
+
setGameState({ theme: id }, stateDir);
|
|
186
|
+
applyChange(ctx, runtime);
|
|
187
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
191
|
+
async function tuiSubmenu(ctx, runtime, stateDir) {
|
|
192
|
+
const s = getGameState(stateDir);
|
|
193
|
+
const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
|
|
194
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
195
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
196
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
197
|
+
"Back",
|
|
198
|
+
]);
|
|
199
|
+
if (choice === undefined || choice === "Back")
|
|
200
|
+
return;
|
|
201
|
+
const mode = choice.split(/\s+/)[0];
|
|
202
|
+
if (mode === "full" || mode === "minimal") {
|
|
203
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
204
|
+
applyChange(ctx, runtime);
|
|
205
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
209
|
+
async function achievementsView(ctx, stateDir) {
|
|
210
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
211
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
212
|
+
for (const r of rows)
|
|
213
|
+
ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
214
|
+
const lines = rows.length
|
|
215
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
216
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
217
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
218
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
219
|
+
}
|
|
108
220
|
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
109
221
|
export function registerGameCommands(pi, runtime) {
|
|
110
222
|
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,19 @@ 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, snapshotSpy) {
|
|
20
20
|
const commands = {};
|
|
21
21
|
const notifies = [];
|
|
22
|
-
const
|
|
22
|
+
const snapshotCalls = [];
|
|
23
|
+
const runtime = {
|
|
24
|
+
bindRepo: () => { },
|
|
25
|
+
currentStateDir: stateDir,
|
|
26
|
+
bumpGameState: () => { },
|
|
27
|
+
snapshot: () => { snapshotCalls.push(1); snapshotSpy?.(); },
|
|
28
|
+
};
|
|
23
29
|
const ctx = {
|
|
24
30
|
cwd: stateDir,
|
|
25
|
-
ui: { notify: (s) => notifies.push(s) },
|
|
31
|
+
ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
|
|
26
32
|
};
|
|
27
33
|
const fakePi = {
|
|
28
34
|
registerCommand: (name, opts) => {
|
|
@@ -33,7 +39,7 @@ function makeHarness(stateDir) {
|
|
|
33
39
|
// test from binding to the real pi module at load time.
|
|
34
40
|
const mod = require("./mega-game-cmds.js");
|
|
35
41
|
mod.registerGameCommands(fakePi, runtime);
|
|
36
|
-
return { commands, notifies, ctx };
|
|
42
|
+
return { commands, notifies, ctx, snapshotCalls };
|
|
37
43
|
}
|
|
38
44
|
describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
39
45
|
let dir;
|
|
@@ -63,6 +69,45 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
63
69
|
assert.ok(lines.some((l) => l.includes("transparent")));
|
|
64
70
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
65
71
|
});
|
|
72
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
73
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
74
|
+
let i = 0;
|
|
75
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
76
|
+
h.notifies.length = 0;
|
|
77
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
78
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
79
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
80
|
+
// toggle back off via the menu
|
|
81
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
82
|
+
let j = 0;
|
|
83
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
84
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
85
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
86
|
+
});
|
|
87
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
88
|
+
// default harness has no select — mimics RPC/print mode
|
|
89
|
+
const lines = await run("");
|
|
90
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
91
|
+
});
|
|
92
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
93
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
94
|
+
let i = 0;
|
|
95
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
96
|
+
h.notifies.length = 0;
|
|
97
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
98
|
+
assert.equal(getGameState().theme, "retro");
|
|
99
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
100
|
+
});
|
|
101
|
+
it("menu toggle calls runtime.snapshot() so the widget refreshes live", async () => {
|
|
102
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
103
|
+
let i = 0;
|
|
104
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
105
|
+
h.notifies.length = 0;
|
|
106
|
+
h.snapshotCalls.length = 0;
|
|
107
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
108
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
109
|
+
assert.ok(h.snapshotCalls.length >= 1, "snapshot() called so widget refreshes live");
|
|
110
|
+
});
|
|
66
111
|
it("on enables game mode and persists", async () => {
|
|
67
112
|
await run("on");
|
|
68
113
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -44,6 +44,7 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
|
|
46
46
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
47
|
+
runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
|
|
47
48
|
// S21.2: reset the per-compaction memory-op counter so the post-compact
|
|
48
49
|
// consolidate pass only fires when memory rows actually changed during the
|
|
49
50
|
// compaction window (turn_end → auto-review may have written some).
|
|
@@ -60,6 +60,12 @@ export class MegaRuntime {
|
|
|
60
60
|
// when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
|
|
61
61
|
// snapshot() so the widget renders the oopsie gag, then reset (one cycle).
|
|
62
62
|
megaCacheFlare = false;
|
|
63
|
+
/** v0.8.3: ambient effect state for animated panel borders keyed off
|
|
64
|
+
* status transitions (level-up, mega-cache overshoot, achievement unlock,
|
|
65
|
+
* compaction start). Threaded into widgetData as `activeEffect`; the widget
|
|
66
|
+
* computes the per-frame phase from startedAt vs Date.now() (non-expired).
|
|
67
|
+
* Null when idle/expired. */
|
|
68
|
+
activeEffect = null;
|
|
63
69
|
megaCacheFlarePct = 0;
|
|
64
70
|
levelUpFlare = false;
|
|
65
71
|
lastLevel = 0;
|
|
@@ -510,8 +516,11 @@ export class MegaRuntime {
|
|
|
510
516
|
const gs = this.getCachedGameState();
|
|
511
517
|
// S34: derive the level-up flare from the turn count each snapshot.
|
|
512
518
|
const curLevel = this.getTurnLevel();
|
|
513
|
-
if (curLevel > this.lastLevel)
|
|
519
|
+
if (curLevel > this.lastLevel) {
|
|
514
520
|
this.levelUpFlare = true;
|
|
521
|
+
// v0.8.3: arm a pulse border effect to celebrate the level-up.
|
|
522
|
+
this.setEffect("pulse", "accent", 1500);
|
|
523
|
+
}
|
|
515
524
|
const cachePct = st.dedupHitRate * 100;
|
|
516
525
|
this.widgetData = {
|
|
517
526
|
version: ownVersion(),
|
|
@@ -555,6 +564,9 @@ export class MegaRuntime {
|
|
|
555
564
|
levelUpFlare: this.levelUpFlare,
|
|
556
565
|
achievementFlare: this.achievementFlare,
|
|
557
566
|
achievementFlareTitles: this.achievementFlareTitles,
|
|
567
|
+
// v0.8.3: ambient border effect — threaded live so the widget can
|
|
568
|
+
// compute the per-frame phase and render animated borders.
|
|
569
|
+
activeEffect: this.activeEffect,
|
|
558
570
|
};
|
|
559
571
|
// S33: consume the flare after copying it into widgetData so it fires
|
|
560
572
|
// for exactly one render cycle (the gag flares once, then clears).
|
|
@@ -568,6 +580,17 @@ export class MegaRuntime {
|
|
|
568
580
|
// (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
|
|
569
581
|
this.achievementFlare = false;
|
|
570
582
|
this.achievementFlareTitles = [];
|
|
583
|
+
// v0.8.3: expire the ambient border effect once its time window has
|
|
584
|
+
// elapsed. SEPARATE from the one-shot flares above (those are per-cycle
|
|
585
|
+
// consumes; activeEffect is time-windowed and cleared when Date.now()
|
|
586
|
+
// crosses startedAt + durationMs). The widget also defends this per-frame
|
|
587
|
+
// (effectBorderSgr returns '' once expired), so this is bookkeeping to
|
|
588
|
+
// free the slot and prevent a stale effect lingering between snapshots.
|
|
589
|
+
if (this.activeEffect &&
|
|
590
|
+
Date.now() - this.activeEffect.startedAt >=
|
|
591
|
+
this.activeEffect.durationMs) {
|
|
592
|
+
this.activeEffect = null;
|
|
593
|
+
}
|
|
571
594
|
// Auto-fit: register a factory so pi re-renders the panel at the REAL
|
|
572
595
|
// terminal width every frame (tui.columns), instead of guessing with
|
|
573
596
|
// process.stdout.columns. buildWidgetLines reads this.widgetData live.
|
|
@@ -833,17 +856,31 @@ export class MegaRuntime {
|
|
|
833
856
|
return turnLevel(this.currentTurn);
|
|
834
857
|
}
|
|
835
858
|
/** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
|
|
836
|
-
* into widgetData and the widget renders the oopsie gag for one cycle.
|
|
859
|
+
* into widgetData and the widget renders the oopsie gag for one cycle.
|
|
860
|
+
* v0.8.3: also arm a 'flash' ambient effect on the panel borders (mega
|
|
861
|
+
* color) for 1.2s. */
|
|
837
862
|
armMegaCacheFlare(peakPct) {
|
|
838
863
|
this.megaCacheFlare = true;
|
|
839
864
|
this.megaCacheFlarePct = peakPct;
|
|
865
|
+
this.setEffect("flash", "mega", 1200);
|
|
840
866
|
}
|
|
841
867
|
/** S35: arm the transient achievement-unlock flare with the newly-unlocked
|
|
842
868
|
* titles so the next snapshot() copies them into widgetData and the widget
|
|
843
|
-
* renders the one-time unlock toast for one render cycle.
|
|
869
|
+
* renders the one-time unlock toast for one render cycle.
|
|
870
|
+
* v0.8.3: also arm a 'pulse' ambient effect on the panel borders (accent
|
|
871
|
+
* color) for 2s to celebrate the unlock. */
|
|
844
872
|
armAchievementFlare(titles) {
|
|
845
873
|
this.achievementFlare = true;
|
|
846
874
|
this.achievementFlareTitles = titles;
|
|
875
|
+
this.setEffect("pulse", "accent", 2000);
|
|
876
|
+
}
|
|
877
|
+
/** v0.8.3: arm an ambient border effect (animated pulse/flash on the panel
|
|
878
|
+
* borders). Replaces any in-flight effect (last call wins — a later event
|
|
879
|
+
* like a level-up during an achievement pulse simply overrides). The widget
|
|
880
|
+
* reads activeEffect each frame and computes the per-frame phase from
|
|
881
|
+
* startedAt vs Date.now(); it renders '' once the window elapses. */
|
|
882
|
+
setEffect(type, role, durationMs) {
|
|
883
|
+
this.activeEffect = { type, role, startedAt: Date.now(), durationMs };
|
|
847
884
|
}
|
|
848
885
|
/** Build the sync onTier callback that paints the live per-tier trace. */
|
|
849
886
|
makeTierCallback(ctx) {
|
|
@@ -52,6 +52,64 @@ function minimalConfig(stateDir) {
|
|
|
52
52
|
function freshDir() {
|
|
53
53
|
return mkdtempSync(join(tmpdir(), "mc-state-"));
|
|
54
54
|
}
|
|
55
|
+
describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
|
|
56
|
+
const dirs = [];
|
|
57
|
+
after(() => {
|
|
58
|
+
for (const d of dirs) {
|
|
59
|
+
try {
|
|
60
|
+
closeStore(d);
|
|
61
|
+
}
|
|
62
|
+
catch { /* */ }
|
|
63
|
+
}
|
|
64
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
65
|
+
for (const d of dirs)
|
|
66
|
+
rmSync(d, { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
it("activeEffect starts null and setEffect arms it with startedAt ~now", () => {
|
|
69
|
+
const dir = freshDir();
|
|
70
|
+
dirs.push(dir);
|
|
71
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
72
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
73
|
+
assert.equal(rt.activeEffect, null, "idle on construction");
|
|
74
|
+
const before = Date.now();
|
|
75
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
76
|
+
const after = Date.now();
|
|
77
|
+
assert.equal(rt.activeEffect.type, "pulse");
|
|
78
|
+
assert.equal(rt.activeEffect.role, "accent");
|
|
79
|
+
assert.equal(rt.activeEffect.durationMs, 2000);
|
|
80
|
+
assert.ok(rt.activeEffect.startedAt >= before && rt.activeEffect.startedAt <= after, "startedAt within the call window");
|
|
81
|
+
});
|
|
82
|
+
it("setEffect replaces an in-flight effect (last call wins)", () => {
|
|
83
|
+
const dir = freshDir();
|
|
84
|
+
dirs.push(dir);
|
|
85
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
86
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
87
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
88
|
+
const first = rt.activeEffect;
|
|
89
|
+
rt.setEffect("flash", "mega", 1200);
|
|
90
|
+
assert.notEqual(rt.activeEffect, first, "a fresh object replaced the prior");
|
|
91
|
+
assert.equal(rt.activeEffect.type, "flash");
|
|
92
|
+
assert.equal(rt.activeEffect.role, "mega");
|
|
93
|
+
assert.equal(rt.activeEffect.durationMs, 1200);
|
|
94
|
+
});
|
|
95
|
+
it("a backdated effect is recognized as expired by the snapshot predicate", () => {
|
|
96
|
+
const dir = freshDir();
|
|
97
|
+
dirs.push(dir);
|
|
98
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
99
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
100
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
101
|
+
// Backdate startedAt past the duration window (no snapshot/ctx needed —
|
|
102
|
+
// this mirrors the exact predicate snapshot() runs after the flare consume).
|
|
103
|
+
rt.activeEffect.startedAt = Date.now() - 3000;
|
|
104
|
+
const expired = !!rt.activeEffect &&
|
|
105
|
+
Date.now() - rt.activeEffect.startedAt >= rt.activeEffect.durationMs;
|
|
106
|
+
assert.ok(expired, "backdated effect satisfies the expiry predicate");
|
|
107
|
+
// Clearing mirrors the snapshot() bookkeeping branch:
|
|
108
|
+
if (expired)
|
|
109
|
+
rt.activeEffect = null;
|
|
110
|
+
assert.equal(rt.activeEffect, null, "cleared once expired");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
55
113
|
describe("MegaRuntime game-state cache (S31)", () => {
|
|
56
114
|
const dirs = [];
|
|
57
115
|
after(() => {
|
|
@@ -121,6 +121,47 @@ function panelBar(width, ch = "─", panelBg = DEFAULT_PANEL_BG) {
|
|
|
121
121
|
// ever swapped for a wide/fullwidth character.
|
|
122
122
|
return truncateToWidth(panelBg + ch.repeat(Math.max(0, width)), width, "", false) + "\x1b[0m";
|
|
123
123
|
}
|
|
124
|
+
// ── v0.8.3: ambient border-effect helpers ───────────────────────────────
|
|
125
|
+
// The panel borders animate when an `activeEffect` is armed (level-up,
|
|
126
|
+
// mega-cache overshoot, achievement unlock, compaction start). Two modes:
|
|
127
|
+
// • pulse — a sine ramp on a 256-color base (accent=51 / mega=214 / red=203):
|
|
128
|
+
// the base index is scaled by sin(π·t) so the border swells 0→peak→0 over
|
|
129
|
+
// the duration, then returns to '' (idle). 256-color indices are clamped
|
|
130
|
+
// to 0–255 defensively (the bases are all ≤214 so the clamp rarely bites).
|
|
131
|
+
// • flash — a 120ms hard on/off alternate using the base index at full.
|
|
132
|
+
// Returns '' when idle, expired, or elapsed<0 (clock skew) so non-effect
|
|
133
|
+
// renders are byte-identical to the pre-effect panel (S31 matrix stays green).
|
|
134
|
+
const EFFECT_BASE = {
|
|
135
|
+
accent: 51,
|
|
136
|
+
mega: 214,
|
|
137
|
+
red: 203,
|
|
138
|
+
};
|
|
139
|
+
/** Resolve the per-frame border-fg SGR for an active effect. '' when idle or
|
|
140
|
+
* expired (the widget's real per-frame expiry enforcer — snapshot-level clear
|
|
141
|
+
* is just bookkeeping since snapshot is event-driven). */
|
|
142
|
+
function effectBorderSgr(ae, now) {
|
|
143
|
+
if (!ae)
|
|
144
|
+
return "";
|
|
145
|
+
const elapsed = now - ae.startedAt;
|
|
146
|
+
if (elapsed < 0 || elapsed >= ae.durationMs)
|
|
147
|
+
return "";
|
|
148
|
+
const base = EFFECT_BASE[ae.role];
|
|
149
|
+
if (ae.type === "flash") {
|
|
150
|
+
// 120ms hard alternate: on (full base) / off (no SGR).
|
|
151
|
+
return Math.floor(elapsed / 120) % 2 === 0 ? `\x1b[38;5;${base}m` : "";
|
|
152
|
+
}
|
|
153
|
+
// pulse: sine ramp 0 → peak → 0 over the duration.
|
|
154
|
+
const t = elapsed / ae.durationMs;
|
|
155
|
+
const amp = Math.sin(Math.PI * t); // 0 at start/end, 1 at midpoint
|
|
156
|
+
const idx = Math.max(0, Math.min(255, Math.round(base * amp)));
|
|
157
|
+
return `\x1b[38;5;${idx}m`;
|
|
158
|
+
}
|
|
159
|
+
/** Prepend the effect border SGR to a panel bar line. The SGR is a pure-fg
|
|
160
|
+
* escape (zero visible width), so it never perturbs truncateToWidth's width
|
|
161
|
+
* math — the bar's own `\x1b[0m` tail resets both fg + bg. No-op when sgr=''. */
|
|
162
|
+
function effectBar(bar, sgr) {
|
|
163
|
+
return sgr ? sgr + bar : bar;
|
|
164
|
+
}
|
|
124
165
|
/** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
|
|
125
166
|
* 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
|
|
126
167
|
function fmtTokens(x) {
|
|
@@ -175,11 +216,16 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
175
216
|
// every panelLine/panelBar/wrapLine so the bg stays continuous and the width
|
|
176
217
|
// guard (truncateToWidth) still holds for transparent themes.
|
|
177
218
|
const panelBg = wd?.theme ? panelBgFor(wd.theme) : DEFAULT_PANEL_BG;
|
|
219
|
+
// v0.8.3: resolve the animated border SGR once per render. '' when idle,
|
|
220
|
+
// expired, or wd is null (warm-up) — so non-effect renders are byte-identical
|
|
221
|
+
// to the pre-effect panel (the existing S31 matrix tests stay green).
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const borderSgr = effectBorderSgr(wd?.activeEffect ?? null, now);
|
|
178
224
|
if (!wd) {
|
|
179
225
|
return [
|
|
180
|
-
panelBar(width, "─", panelBg),
|
|
226
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
181
227
|
panelLine(" mega-compact: warming up…", width, panelBg),
|
|
182
|
-
panelBar(width, "─", panelBg),
|
|
228
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
183
229
|
];
|
|
184
230
|
}
|
|
185
231
|
// S31: minimal TUI mode — a single content line `LVL n | cache NN%` flanked
|
|
@@ -199,9 +245,9 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
199
245
|
? `${accent}${wd.gameMode && wd.levelUpFlare ? "\x1b[5m" : ""}LVL ${lvl}${wd.gameMode && wd.levelUpFlare ? "\x1b[0m" : ""}${sgrReset(accent)} ${C.dim}|${C.reset} cache ${cacheStr}${megaFlare}`
|
|
200
246
|
: `cache ${cacheStr}${megaFlare}`;
|
|
201
247
|
return [
|
|
202
|
-
panelBar(width, "─", panelBg),
|
|
248
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
203
249
|
panelLine(` ${body}`, width, panelBg),
|
|
204
|
-
panelBar(width, "─", panelBg),
|
|
250
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
205
251
|
];
|
|
206
252
|
}
|
|
207
253
|
const pulse = wd.pulsing
|
|
@@ -234,7 +280,7 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
234
280
|
// Wrap to terminal width and pad each line
|
|
235
281
|
const wrapped = wrapLine(content, width - 2, panelBg); // 2-char indent
|
|
236
282
|
const lines = [
|
|
237
|
-
panelBar(width, "─", panelBg),
|
|
283
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
238
284
|
...wrapped.map((l) => panelLine(l, width, panelBg)),
|
|
239
285
|
];
|
|
240
286
|
// L4 — agents block (S27, count + status; per-agent tokens gated on P0)
|
|
@@ -265,6 +311,6 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
265
311
|
lines.push(panelLine(` ${accentSgr}🏆 Achievement unlocked: ${titlesStr}${sgrReset(accentSgr)}`, width, panelBg));
|
|
266
312
|
}
|
|
267
313
|
// bottom border
|
|
268
|
-
lines.push(panelBar(width, "─", panelBg));
|
|
314
|
+
lines.push(effectBar(panelBar(width, "─", panelBg), borderSgr));
|
|
269
315
|
return lines;
|
|
270
316
|
}
|
|
@@ -136,6 +136,51 @@ describe("buildWidgetLines (S31)", () => {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
});
|
|
139
|
+
describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
140
|
+
const effBase = (overrides = {}) => baseWd({
|
|
141
|
+
theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
|
|
142
|
+
});
|
|
143
|
+
const isBorder = (l) => l.includes("─");
|
|
144
|
+
it("activeEffect (pulse, mid-window) -> border lines carry a 256-color fg SGR", () => {
|
|
145
|
+
const ae = { type: "pulse", role: "accent", startedAt: Date.now() - 250, durationMs: 2000 };
|
|
146
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
147
|
+
const borders = lines.filter(isBorder);
|
|
148
|
+
assert.ok(borders.length >= 2, "has top + bottom borders");
|
|
149
|
+
for (const b of borders) {
|
|
150
|
+
assert.ok(b.includes("\x1b[38;5;"), `border carries 256-color fg: ${JSON.stringify(b)}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
it("activeEffect null -> plain borders, no 38;5 fg SGR on border lines", () => {
|
|
154
|
+
const lines = buildWidgetLines(effBase({ activeEffect: null }), WIDTH, 0);
|
|
155
|
+
const borders = lines.filter(isBorder);
|
|
156
|
+
for (const b of borders) {
|
|
157
|
+
assert.ok(!b.includes("\x1b[38;5;"), `no effect SGR on plain border: ${JSON.stringify(b)}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
it("expired activeEffect -> plain borders (per-frame expiry enforced)", () => {
|
|
161
|
+
const ae = { type: "pulse", role: "accent", startedAt: Date.now() - 5000, durationMs: 1000 };
|
|
162
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
163
|
+
const borders = lines.filter(isBorder);
|
|
164
|
+
for (const b of borders) {
|
|
165
|
+
assert.ok(!b.includes("\x1b[38;5;"), `expired effect -> plain border: ${JSON.stringify(b)}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
it("activeEffect border lines are width-safe (pulse, minimal + full)", () => {
|
|
169
|
+
for (const tuiMode of ["minimal", "full"]) {
|
|
170
|
+
const ae = { type: "pulse", role: "mega", startedAt: Date.now() - 100, durationMs: 2000 };
|
|
171
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae, tuiMode }), 60, 0);
|
|
172
|
+
for (const l of lines)
|
|
173
|
+
assert.ok(visibleWidth(l) <= 60, `width safe (${tuiMode}): ${visibleWidth(l)}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
it("flash effect mid-window border carries the full base index SGR", () => {
|
|
177
|
+
// Force an 'on' phase of the 120ms alternate by starting just now.
|
|
178
|
+
const ae = { type: "flash", role: "red", startedAt: Date.now(), durationMs: 1200 };
|
|
179
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
180
|
+
const borders = lines.filter(isBorder);
|
|
181
|
+
assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
139
184
|
describe("buildWidgetLines achievement flare (S35)", () => {
|
|
140
185
|
const achBase = (overrides = {}) => baseWd({
|
|
141
186
|
theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
|
|
@@ -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);
|
|
@@ -24,15 +24,26 @@ type Harness = {
|
|
|
24
24
|
commands: Record<string, Cmd>;
|
|
25
25
|
notifies: string[];
|
|
26
26
|
ctx: any;
|
|
27
|
+
snapshotCalls: number[];
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
function makeHarness(
|
|
30
|
+
function makeHarness(
|
|
31
|
+
stateDir: string,
|
|
32
|
+
select?: (title: string, options: string[]) => Promise<string | undefined>,
|
|
33
|
+
snapshotSpy?: () => void,
|
|
34
|
+
): Harness {
|
|
30
35
|
const commands: Record<string, Cmd> = {};
|
|
31
36
|
const notifies: string[] = [];
|
|
32
|
-
const
|
|
37
|
+
const snapshotCalls: number[] = [];
|
|
38
|
+
const runtime = {
|
|
39
|
+
bindRepo: () => {},
|
|
40
|
+
currentStateDir: stateDir,
|
|
41
|
+
bumpGameState: () => {},
|
|
42
|
+
snapshot: () => { snapshotCalls.push(1); snapshotSpy?.(); },
|
|
43
|
+
};
|
|
33
44
|
const ctx = {
|
|
34
45
|
cwd: stateDir,
|
|
35
|
-
ui: { notify: (s: string) => notifies.push(s) },
|
|
46
|
+
ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
|
|
36
47
|
};
|
|
37
48
|
const fakePi = {
|
|
38
49
|
registerCommand: (name: string, opts: Cmd) => {
|
|
@@ -45,7 +56,7 @@ function makeHarness(stateDir: string): Harness {
|
|
|
45
56
|
registerGameCommands: (pi: unknown, runtime: unknown) => void;
|
|
46
57
|
};
|
|
47
58
|
mod.registerGameCommands(fakePi, runtime);
|
|
48
|
-
return { commands, notifies, ctx };
|
|
59
|
+
return { commands, notifies, ctx, snapshotCalls };
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
@@ -80,6 +91,49 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
80
91
|
assert.ok(lines.some((l) => l.includes("tui:")));
|
|
81
92
|
});
|
|
82
93
|
|
|
94
|
+
it("bare command opens interactive menu (select) and toggles game mode", async () => {
|
|
95
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
96
|
+
let i = 0;
|
|
97
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
98
|
+
h.notifies.length = 0;
|
|
99
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
100
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
101
|
+
assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
|
|
102
|
+
// toggle back off via the menu
|
|
103
|
+
const seq2 = ["Turn game mode OFF", "Done"];
|
|
104
|
+
let j = 0;
|
|
105
|
+
const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
|
|
106
|
+
await h2.commands["mega-compact-settings"].handler("", h2.ctx);
|
|
107
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("bare command falls back to status print when select is unavailable", async () => {
|
|
111
|
+
// default harness has no select — mimics RPC/print mode
|
|
112
|
+
const lines = await run("");
|
|
113
|
+
assert.ok(lines.some((l) => l.includes("game mode: off")));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("menu Theme… → picks a theme and persists", async () => {
|
|
117
|
+
const seq = ["Theme…", "retro Retro Terminal", "Done"];
|
|
118
|
+
let i = 0;
|
|
119
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
120
|
+
h.notifies.length = 0;
|
|
121
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
122
|
+
assert.equal(getGameState().theme, "retro");
|
|
123
|
+
assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("menu toggle calls runtime.snapshot() so the widget refreshes live", async () => {
|
|
127
|
+
const seq = ["Turn game mode ON", "Done"];
|
|
128
|
+
let i = 0;
|
|
129
|
+
const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
|
|
130
|
+
h.notifies.length = 0;
|
|
131
|
+
h.snapshotCalls.length = 0;
|
|
132
|
+
await h.commands["mega-compact-settings"].handler("", h.ctx);
|
|
133
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
134
|
+
assert.ok(h.snapshotCalls.length >= 1, "snapshot() called so widget refreshes live");
|
|
135
|
+
});
|
|
136
|
+
|
|
83
137
|
it("on enables game mode and persists", async () => {
|
|
84
138
|
await run("on");
|
|
85
139
|
assert.equal(getGameState().game_mode_on, true);
|
|
@@ -36,6 +36,16 @@ import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } f
|
|
|
36
36
|
/** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
|
|
37
37
|
const TAG = "mega-compact-settings";
|
|
38
38
|
|
|
39
|
+
/** Apply a game_state mutation to the live TUI: evict the memoized cache
|
|
40
|
+
* (bumpGameState) THEN recompute the widget snapshot (snapshot(ctx)) so the
|
|
41
|
+
* panel picks up the new theme/mode/toggle immediately — no pi restart needed.
|
|
42
|
+
* snapshot() re-reads the (evicted) game_state into widgetData and re-registers
|
|
43
|
+
* the widget factory. Order matters: bump first, snapshot second. */
|
|
44
|
+
function applyChange(ctx: ExtensionContext, runtime: MegaRuntime): void {
|
|
45
|
+
runtime.bumpGameState();
|
|
46
|
+
runtime.snapshot(ctx);
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** Format the current state as a human-readable status line set. */
|
|
40
50
|
function fmtState(s: GameState): string[] {
|
|
41
51
|
return [
|
|
@@ -55,8 +65,19 @@ async function handleSettings(
|
|
|
55
65
|
const stateDir = runtime.currentStateDir;
|
|
56
66
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
57
67
|
|
|
58
|
-
// bare →
|
|
68
|
+
// bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
|
|
69
|
+
// static status print when there's no interactive UI (RPC/print mode, or a
|
|
70
|
+
// test harness stubbing only notify). CLI subcommands below still work for
|
|
71
|
+
// power users + scripts.
|
|
59
72
|
if (parts.length === 0) {
|
|
73
|
+
if (typeof ctx.ui.select === "function") {
|
|
74
|
+
try {
|
|
75
|
+
await runInteractiveMenu(ctx, runtime, stateDir);
|
|
76
|
+
return;
|
|
77
|
+
} catch {
|
|
78
|
+
// select threw (non-interactive impl) → fall through to status print
|
|
79
|
+
}
|
|
80
|
+
}
|
|
60
81
|
const s = getGameState(stateDir);
|
|
61
82
|
for (const line of fmtState(s)) ctx.ui.notify(line);
|
|
62
83
|
return;
|
|
@@ -77,7 +98,7 @@ async function handleSettings(
|
|
|
77
98
|
// on|off
|
|
78
99
|
if (sub === "on" || sub === "off") {
|
|
79
100
|
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
80
|
-
runtime
|
|
101
|
+
applyChange(ctx, runtime);
|
|
81
102
|
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
82
103
|
return;
|
|
83
104
|
}
|
|
@@ -103,7 +124,7 @@ async function handleSettings(
|
|
|
103
124
|
return;
|
|
104
125
|
}
|
|
105
126
|
const s = setGameState({ theme: id }, stateDir);
|
|
106
|
-
runtime
|
|
127
|
+
applyChange(ctx, runtime);
|
|
107
128
|
ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
108
129
|
return;
|
|
109
130
|
}
|
|
@@ -116,7 +137,7 @@ async function handleSettings(
|
|
|
116
137
|
return;
|
|
117
138
|
}
|
|
118
139
|
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
119
|
-
runtime
|
|
140
|
+
applyChange(ctx, runtime);
|
|
120
141
|
ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
|
|
121
142
|
return;
|
|
122
143
|
}
|
|
@@ -126,6 +147,111 @@ async function handleSettings(
|
|
|
126
147
|
);
|
|
127
148
|
}
|
|
128
149
|
|
|
150
|
+
/** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
|
|
151
|
+
* (a real TUI picker in interactive mode). Loops until the user cancels (or
|
|
152
|
+
* picks "Done"). Each action mutates the global game_state row + bumps the
|
|
153
|
+
* runtime cache so the widget/dashboard reflect it immediately.
|
|
154
|
+
*
|
|
155
|
+
* Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
|
|
156
|
+
* unavailable we fall back to the static fmtState notify print. */
|
|
157
|
+
async function runInteractiveMenu(
|
|
158
|
+
ctx: ExtensionContext,
|
|
159
|
+
runtime: MegaRuntime,
|
|
160
|
+
stateDir: string,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
for (;;) {
|
|
163
|
+
const s = getGameState(stateDir);
|
|
164
|
+
const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
|
|
165
|
+
const choice = await ctx.ui.select(
|
|
166
|
+
`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
|
|
167
|
+
[toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
|
|
168
|
+
);
|
|
169
|
+
if (choice === undefined || choice === "Done") return;
|
|
170
|
+
if (choice === toggleLabel) {
|
|
171
|
+
const next = !s.game_mode_on;
|
|
172
|
+
setGameState({ game_mode_on: next }, stateDir);
|
|
173
|
+
applyChange(ctx, runtime);
|
|
174
|
+
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (choice === "Theme…") {
|
|
178
|
+
await themeSubmenu(ctx, runtime, stateDir);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (choice === "TUI display mode…") {
|
|
182
|
+
await tuiSubmenu(ctx, runtime, stateDir);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (choice === "Achievements…") {
|
|
186
|
+
await achievementsView(ctx, stateDir);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
|
|
193
|
+
async function themeSubmenu(
|
|
194
|
+
ctx: ExtensionContext,
|
|
195
|
+
runtime: MegaRuntime,
|
|
196
|
+
stateDir: string,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const s = getGameState(stateDir);
|
|
199
|
+
const opts = THEMES.map((t) => {
|
|
200
|
+
const mark = t.id === s.theme ? " ✓" : "";
|
|
201
|
+
return `${t.id}${mark} ${t.label}`;
|
|
202
|
+
});
|
|
203
|
+
opts.push("next (cycle to next theme)");
|
|
204
|
+
opts.push("Back");
|
|
205
|
+
const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
|
|
206
|
+
if (choice === undefined || choice === "Back") return;
|
|
207
|
+
const first = choice.split(/\s+/)[0]!;
|
|
208
|
+
let id: string | undefined;
|
|
209
|
+
if (first === "next") {
|
|
210
|
+
id = nextTheme(s.theme);
|
|
211
|
+
} else if (isValidTheme(first)) {
|
|
212
|
+
id = first;
|
|
213
|
+
}
|
|
214
|
+
if (id && id !== s.theme) {
|
|
215
|
+
setGameState({ theme: id }, stateDir);
|
|
216
|
+
applyChange(ctx, runtime);
|
|
217
|
+
ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** TUI display-mode submenu — full vs minimal (current marked ✓). */
|
|
222
|
+
async function tuiSubmenu(
|
|
223
|
+
ctx: ExtensionContext,
|
|
224
|
+
runtime: MegaRuntime,
|
|
225
|
+
stateDir: string,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const s = getGameState(stateDir);
|
|
228
|
+
const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
|
|
229
|
+
const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
|
|
230
|
+
`full${mark("full")} — bars, stats, flair`,
|
|
231
|
+
`minimal${mark("minimal")} — one-line level + cache %`,
|
|
232
|
+
"Back",
|
|
233
|
+
]);
|
|
234
|
+
if (choice === undefined || choice === "Back") return;
|
|
235
|
+
const mode = choice.split(/\s+/)[0];
|
|
236
|
+
if (mode === "full" || mode === "minimal") {
|
|
237
|
+
setGameState({ tui_display_mode: mode }, stateDir);
|
|
238
|
+
applyChange(ctx, runtime);
|
|
239
|
+
ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Achievements view — terse notify list + a read-only select viewer. */
|
|
244
|
+
async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
|
|
245
|
+
const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
|
|
246
|
+
ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
|
|
247
|
+
for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
|
|
248
|
+
const lines = rows.length
|
|
249
|
+
? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
|
|
250
|
+
: ["(none unlocked yet — keep compacting!)"];
|
|
251
|
+
// select() as a read-only viewer; any selection / cancel returns to the menu.
|
|
252
|
+
await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
|
|
253
|
+
}
|
|
254
|
+
|
|
129
255
|
/** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
|
|
130
256
|
export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
|
|
131
257
|
const description =
|
|
@@ -79,6 +79,7 @@ function doCompact(
|
|
|
79
79
|
runtime: MegaRuntime,
|
|
80
80
|
): RunCompactResult {
|
|
81
81
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
82
|
+
runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
|
|
82
83
|
// S21.2: reset the per-compaction memory-op counter so the post-compact
|
|
83
84
|
// consolidate pass only fires when memory rows actually changed during the
|
|
84
85
|
// compaction window (turn_end → auto-review may have written some).
|
|
@@ -58,6 +58,64 @@ function freshDir(): string {
|
|
|
58
58
|
return mkdtempSync(join(tmpdir(), "mc-state-"));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
|
|
62
|
+
const dirs: string[] = [];
|
|
63
|
+
after(() => {
|
|
64
|
+
for (const d of dirs) {
|
|
65
|
+
try { closeStore(d); } catch { /* */ }
|
|
66
|
+
}
|
|
67
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
68
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("activeEffect starts null and setEffect arms it with startedAt ~now", () => {
|
|
72
|
+
const dir = freshDir(); dirs.push(dir);
|
|
73
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
74
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
75
|
+
assert.equal(rt.activeEffect, null, "idle on construction");
|
|
76
|
+
const before = Date.now();
|
|
77
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
78
|
+
const after = Date.now();
|
|
79
|
+
assert.equal(rt.activeEffect.type, "pulse");
|
|
80
|
+
assert.equal(rt.activeEffect.role, "accent");
|
|
81
|
+
assert.equal(rt.activeEffect.durationMs, 2000);
|
|
82
|
+
assert.ok(
|
|
83
|
+
rt.activeEffect.startedAt >= before && rt.activeEffect.startedAt <= after,
|
|
84
|
+
"startedAt within the call window",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("setEffect replaces an in-flight effect (last call wins)", () => {
|
|
89
|
+
const dir = freshDir(); dirs.push(dir);
|
|
90
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
91
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
92
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
93
|
+
const first = rt.activeEffect;
|
|
94
|
+
rt.setEffect("flash", "mega", 1200);
|
|
95
|
+
assert.notEqual(rt.activeEffect, first, "a fresh object replaced the prior");
|
|
96
|
+
assert.equal(rt.activeEffect.type, "flash");
|
|
97
|
+
assert.equal(rt.activeEffect.role, "mega");
|
|
98
|
+
assert.equal(rt.activeEffect.durationMs, 1200);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("a backdated effect is recognized as expired by the snapshot predicate", () => {
|
|
102
|
+
const dir = freshDir(); dirs.push(dir);
|
|
103
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
104
|
+
const rt = new MegaRuntime(minimalConfig(dir));
|
|
105
|
+
rt.setEffect("pulse", "accent", 2000);
|
|
106
|
+
// Backdate startedAt past the duration window (no snapshot/ctx needed —
|
|
107
|
+
// this mirrors the exact predicate snapshot() runs after the flare consume).
|
|
108
|
+
rt.activeEffect.startedAt = Date.now() - 3000;
|
|
109
|
+
const expired =
|
|
110
|
+
!!rt.activeEffect &&
|
|
111
|
+
Date.now() - rt.activeEffect.startedAt >= rt.activeEffect.durationMs;
|
|
112
|
+
assert.ok(expired, "backdated effect satisfies the expiry predicate");
|
|
113
|
+
// Clearing mirrors the snapshot() bookkeeping branch:
|
|
114
|
+
if (expired) rt.activeEffect = null;
|
|
115
|
+
assert.equal(rt.activeEffect, null, "cleared once expired");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
61
119
|
describe("MegaRuntime game-state cache (S31)", () => {
|
|
62
120
|
const dirs: string[] = [];
|
|
63
121
|
after(() => {
|
|
@@ -97,6 +97,12 @@ export class MegaRuntime {
|
|
|
97
97
|
// when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
|
|
98
98
|
// snapshot() so the widget renders the oopsie gag, then reset (one cycle).
|
|
99
99
|
megaCacheFlare = false;
|
|
100
|
+
/** v0.8.3: ambient effect state for animated panel borders keyed off
|
|
101
|
+
* status transitions (level-up, mega-cache overshoot, achievement unlock,
|
|
102
|
+
* compaction start). Threaded into widgetData as `activeEffect`; the widget
|
|
103
|
+
* computes the per-frame phase from startedAt vs Date.now() (non-expired).
|
|
104
|
+
* Null when idle/expired. */
|
|
105
|
+
activeEffect: { type: "pulse" | "flash"; role: "accent" | "mega" | "red"; startedAt: number; durationMs: number } | null = null;
|
|
100
106
|
megaCacheFlarePct = 0;
|
|
101
107
|
levelUpFlare = false;
|
|
102
108
|
lastLevel = 0;
|
|
@@ -575,7 +581,11 @@ export class MegaRuntime {
|
|
|
575
581
|
const gs = this.getCachedGameState();
|
|
576
582
|
// S34: derive the level-up flare from the turn count each snapshot.
|
|
577
583
|
const curLevel = this.getTurnLevel();
|
|
578
|
-
if (curLevel > this.lastLevel)
|
|
584
|
+
if (curLevel > this.lastLevel) {
|
|
585
|
+
this.levelUpFlare = true;
|
|
586
|
+
// v0.8.3: arm a pulse border effect to celebrate the level-up.
|
|
587
|
+
this.setEffect("pulse", "accent", 1500);
|
|
588
|
+
}
|
|
579
589
|
const cachePct = st.dedupHitRate * 100;
|
|
580
590
|
this.widgetData = {
|
|
581
591
|
version: ownVersion(),
|
|
@@ -619,6 +629,9 @@ export class MegaRuntime {
|
|
|
619
629
|
levelUpFlare: this.levelUpFlare,
|
|
620
630
|
achievementFlare: this.achievementFlare,
|
|
621
631
|
achievementFlareTitles: this.achievementFlareTitles,
|
|
632
|
+
// v0.8.3: ambient border effect — threaded live so the widget can
|
|
633
|
+
// compute the per-frame phase and render animated borders.
|
|
634
|
+
activeEffect: this.activeEffect,
|
|
622
635
|
};
|
|
623
636
|
// S33: consume the flare after copying it into widgetData so it fires
|
|
624
637
|
// for exactly one render cycle (the gag flares once, then clears).
|
|
@@ -633,6 +646,19 @@ export class MegaRuntime {
|
|
|
633
646
|
// (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
|
|
634
647
|
this.achievementFlare = false;
|
|
635
648
|
this.achievementFlareTitles = [];
|
|
649
|
+
// v0.8.3: expire the ambient border effect once its time window has
|
|
650
|
+
// elapsed. SEPARATE from the one-shot flares above (those are per-cycle
|
|
651
|
+
// consumes; activeEffect is time-windowed and cleared when Date.now()
|
|
652
|
+
// crosses startedAt + durationMs). The widget also defends this per-frame
|
|
653
|
+
// (effectBorderSgr returns '' once expired), so this is bookkeeping to
|
|
654
|
+
// free the slot and prevent a stale effect lingering between snapshots.
|
|
655
|
+
if (
|
|
656
|
+
this.activeEffect &&
|
|
657
|
+
Date.now() - this.activeEffect.startedAt >=
|
|
658
|
+
this.activeEffect.durationMs
|
|
659
|
+
) {
|
|
660
|
+
this.activeEffect = null;
|
|
661
|
+
}
|
|
636
662
|
// Auto-fit: register a factory so pi re-renders the panel at the REAL
|
|
637
663
|
// terminal width every frame (tui.columns), instead of guessing with
|
|
638
664
|
// process.stdout.columns. buildWidgetLines reads this.widgetData live.
|
|
@@ -915,18 +941,37 @@ export class MegaRuntime {
|
|
|
915
941
|
}
|
|
916
942
|
|
|
917
943
|
/** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
|
|
918
|
-
* into widgetData and the widget renders the oopsie gag for one cycle.
|
|
944
|
+
* into widgetData and the widget renders the oopsie gag for one cycle.
|
|
945
|
+
* v0.8.3: also arm a 'flash' ambient effect on the panel borders (mega
|
|
946
|
+
* color) for 1.2s. */
|
|
919
947
|
armMegaCacheFlare(peakPct: number): void {
|
|
920
948
|
this.megaCacheFlare = true;
|
|
921
949
|
this.megaCacheFlarePct = peakPct;
|
|
950
|
+
this.setEffect("flash", "mega", 1200);
|
|
922
951
|
}
|
|
923
952
|
|
|
924
953
|
/** S35: arm the transient achievement-unlock flare with the newly-unlocked
|
|
925
954
|
* titles so the next snapshot() copies them into widgetData and the widget
|
|
926
|
-
* renders the one-time unlock toast for one render cycle.
|
|
955
|
+
* renders the one-time unlock toast for one render cycle.
|
|
956
|
+
* v0.8.3: also arm a 'pulse' ambient effect on the panel borders (accent
|
|
957
|
+
* color) for 2s to celebrate the unlock. */
|
|
927
958
|
armAchievementFlare(titles: string[]): void {
|
|
928
959
|
this.achievementFlare = true;
|
|
929
960
|
this.achievementFlareTitles = titles;
|
|
961
|
+
this.setEffect("pulse", "accent", 2000);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** v0.8.3: arm an ambient border effect (animated pulse/flash on the panel
|
|
965
|
+
* borders). Replaces any in-flight effect (last call wins — a later event
|
|
966
|
+
* like a level-up during an achievement pulse simply overrides). The widget
|
|
967
|
+
* reads activeEffect each frame and computes the per-frame phase from
|
|
968
|
+
* startedAt vs Date.now(); it renders '' once the window elapses. */
|
|
969
|
+
setEffect(
|
|
970
|
+
type: "pulse" | "flash",
|
|
971
|
+
role: "accent" | "mega" | "red",
|
|
972
|
+
durationMs: number,
|
|
973
|
+
): void {
|
|
974
|
+
this.activeEffect = { type, role, startedAt: Date.now(), durationMs };
|
|
930
975
|
}
|
|
931
976
|
|
|
932
977
|
/** Build the sync onTier callback that paints the live per-tier trace. */
|
|
@@ -162,6 +162,56 @@ describe("buildWidgetLines (S31)", () => {
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
166
|
+
const effBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
|
|
167
|
+
theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
|
|
168
|
+
});
|
|
169
|
+
const isBorder = (l: string): boolean => l.includes("─");
|
|
170
|
+
|
|
171
|
+
it("activeEffect (pulse, mid-window) -> border lines carry a 256-color fg SGR", () => {
|
|
172
|
+
const ae = { type: "pulse" as const, role: "accent" as const, startedAt: Date.now() - 250, durationMs: 2000 };
|
|
173
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
174
|
+
const borders = lines.filter(isBorder);
|
|
175
|
+
assert.ok(borders.length >= 2, "has top + bottom borders");
|
|
176
|
+
for (const b of borders) {
|
|
177
|
+
assert.ok(b.includes("\x1b[38;5;"), `border carries 256-color fg: ${JSON.stringify(b)}`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("activeEffect null -> plain borders, no 38;5 fg SGR on border lines", () => {
|
|
182
|
+
const lines = buildWidgetLines(effBase({ activeEffect: null }), WIDTH, 0);
|
|
183
|
+
const borders = lines.filter(isBorder);
|
|
184
|
+
for (const b of borders) {
|
|
185
|
+
assert.ok(!b.includes("\x1b[38;5;"), `no effect SGR on plain border: ${JSON.stringify(b)}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("expired activeEffect -> plain borders (per-frame expiry enforced)", () => {
|
|
190
|
+
const ae = { type: "pulse" as const, role: "accent" as const, startedAt: Date.now() - 5000, durationMs: 1000 };
|
|
191
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
192
|
+
const borders = lines.filter(isBorder);
|
|
193
|
+
for (const b of borders) {
|
|
194
|
+
assert.ok(!b.includes("\x1b[38;5;"), `expired effect -> plain border: ${JSON.stringify(b)}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("activeEffect border lines are width-safe (pulse, minimal + full)", () => {
|
|
199
|
+
for (const tuiMode of ["minimal", "full"] as const) {
|
|
200
|
+
const ae = { type: "pulse" as const, role: "mega" as const, startedAt: Date.now() - 100, durationMs: 2000 };
|
|
201
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae, tuiMode }), 60, 0);
|
|
202
|
+
for (const l of lines) assert.ok(visibleWidth(l) <= 60, `width safe (${tuiMode}): ${visibleWidth(l)}`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("flash effect mid-window border carries the full base index SGR", () => {
|
|
207
|
+
// Force an 'on' phase of the 120ms alternate by starting just now.
|
|
208
|
+
const ae = { type: "flash" as const, role: "red" as const, startedAt: Date.now(), durationMs: 1200 };
|
|
209
|
+
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
210
|
+
const borders = lines.filter(isBorder);
|
|
211
|
+
assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
165
215
|
describe("buildWidgetLines achievement flare (S35)", () => {
|
|
166
216
|
const achBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
|
|
167
217
|
theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
|
|
@@ -128,6 +128,51 @@ function panelBar(width: number, ch = "─", panelBg: string = DEFAULT_PANEL_BG)
|
|
|
128
128
|
return truncateToWidth(panelBg + ch.repeat(Math.max(0, width)), width, "", false) + "\x1b[0m";
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// ── v0.8.3: ambient border-effect helpers ───────────────────────────────
|
|
132
|
+
// The panel borders animate when an `activeEffect` is armed (level-up,
|
|
133
|
+
// mega-cache overshoot, achievement unlock, compaction start). Two modes:
|
|
134
|
+
// • pulse — a sine ramp on a 256-color base (accent=51 / mega=214 / red=203):
|
|
135
|
+
// the base index is scaled by sin(π·t) so the border swells 0→peak→0 over
|
|
136
|
+
// the duration, then returns to '' (idle). 256-color indices are clamped
|
|
137
|
+
// to 0–255 defensively (the bases are all ≤214 so the clamp rarely bites).
|
|
138
|
+
// • flash — a 120ms hard on/off alternate using the base index at full.
|
|
139
|
+
// Returns '' when idle, expired, or elapsed<0 (clock skew) so non-effect
|
|
140
|
+
// renders are byte-identical to the pre-effect panel (S31 matrix stays green).
|
|
141
|
+
const EFFECT_BASE: Record<"accent" | "mega" | "red", number> = {
|
|
142
|
+
accent: 51,
|
|
143
|
+
mega: 214,
|
|
144
|
+
red: 203,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Resolve the per-frame border-fg SGR for an active effect. '' when idle or
|
|
148
|
+
* expired (the widget's real per-frame expiry enforcer — snapshot-level clear
|
|
149
|
+
* is just bookkeeping since snapshot is event-driven). */
|
|
150
|
+
function effectBorderSgr(
|
|
151
|
+
ae: NonNullable<WidgetData["activeEffect"]> | null,
|
|
152
|
+
now: number,
|
|
153
|
+
): string {
|
|
154
|
+
if (!ae) return "";
|
|
155
|
+
const elapsed = now - ae.startedAt;
|
|
156
|
+
if (elapsed < 0 || elapsed >= ae.durationMs) return "";
|
|
157
|
+
const base = EFFECT_BASE[ae.role];
|
|
158
|
+
if (ae.type === "flash") {
|
|
159
|
+
// 120ms hard alternate: on (full base) / off (no SGR).
|
|
160
|
+
return Math.floor(elapsed / 120) % 2 === 0 ? `\x1b[38;5;${base}m` : "";
|
|
161
|
+
}
|
|
162
|
+
// pulse: sine ramp 0 → peak → 0 over the duration.
|
|
163
|
+
const t = elapsed / ae.durationMs;
|
|
164
|
+
const amp = Math.sin(Math.PI * t); // 0 at start/end, 1 at midpoint
|
|
165
|
+
const idx = Math.max(0, Math.min(255, Math.round(base * amp)));
|
|
166
|
+
return `\x1b[38;5;${idx}m`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Prepend the effect border SGR to a panel bar line. The SGR is a pure-fg
|
|
170
|
+
* escape (zero visible width), so it never perturbs truncateToWidth's width
|
|
171
|
+
* math — the bar's own `\x1b[0m` tail resets both fg + bg. No-op when sgr=''. */
|
|
172
|
+
function effectBar(bar: string, sgr: string): string {
|
|
173
|
+
return sgr ? sgr + bar : bar;
|
|
174
|
+
}
|
|
175
|
+
|
|
131
176
|
/** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
|
|
132
177
|
* 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
|
|
133
178
|
function fmtTokens(x: number): string {
|
|
@@ -231,6 +276,15 @@ export interface WidgetData {
|
|
|
231
276
|
/** S35: achievement-unlock flare -- renders a one-line toast for one cycle. */
|
|
232
277
|
achievementFlare?: boolean;
|
|
233
278
|
achievementFlareTitles?: string[];
|
|
279
|
+
/** v0.8.3: ambient animated border effect (null when idle/expired). The
|
|
280
|
+
* widget computes the per-frame phase from startedAt vs Date.now() and
|
|
281
|
+
* renders a pulse/flash on the panel borders; '' once the window elapses. */
|
|
282
|
+
activeEffect?: {
|
|
283
|
+
type: "pulse" | "flash";
|
|
284
|
+
role: "accent" | "mega" | "red";
|
|
285
|
+
startedAt: number;
|
|
286
|
+
durationMs: number;
|
|
287
|
+
} | null;
|
|
234
288
|
}
|
|
235
289
|
|
|
236
290
|
// ── buildWidgetLines ───────────────────────────────────────────────────────
|
|
@@ -250,11 +304,16 @@ export function buildWidgetLines(
|
|
|
250
304
|
// every panelLine/panelBar/wrapLine so the bg stays continuous and the width
|
|
251
305
|
// guard (truncateToWidth) still holds for transparent themes.
|
|
252
306
|
const panelBg = wd?.theme ? panelBgFor(wd.theme) : DEFAULT_PANEL_BG;
|
|
307
|
+
// v0.8.3: resolve the animated border SGR once per render. '' when idle,
|
|
308
|
+
// expired, or wd is null (warm-up) — so non-effect renders are byte-identical
|
|
309
|
+
// to the pre-effect panel (the existing S31 matrix tests stay green).
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const borderSgr = effectBorderSgr(wd?.activeEffect ?? null, now);
|
|
253
312
|
if (!wd) {
|
|
254
313
|
return [
|
|
255
|
-
panelBar(width, "─", panelBg),
|
|
314
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
256
315
|
panelLine(" mega-compact: warming up…", width, panelBg),
|
|
257
|
-
panelBar(width, "─", panelBg),
|
|
316
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
258
317
|
];
|
|
259
318
|
}
|
|
260
319
|
// S31: minimal TUI mode — a single content line `LVL n | cache NN%` flanked
|
|
@@ -276,9 +335,9 @@ export function buildWidgetLines(
|
|
|
276
335
|
? `${accent}${wd.gameMode && wd.levelUpFlare ? "\x1b[5m" : ""}LVL ${lvl}${wd.gameMode && wd.levelUpFlare ? "\x1b[0m" : ""}${sgrReset(accent)} ${C.dim}|${C.reset} cache ${cacheStr}${megaFlare}`
|
|
277
336
|
: `cache ${cacheStr}${megaFlare}`;
|
|
278
337
|
return [
|
|
279
|
-
panelBar(width, "─", panelBg),
|
|
338
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
280
339
|
panelLine(` ${body}`, width, panelBg),
|
|
281
|
-
panelBar(width, "─", panelBg),
|
|
340
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
282
341
|
];
|
|
283
342
|
}
|
|
284
343
|
const pulse = wd.pulsing
|
|
@@ -312,7 +371,7 @@ export function buildWidgetLines(
|
|
|
312
371
|
// Wrap to terminal width and pad each line
|
|
313
372
|
const wrapped = wrapLine(content, width - 2, panelBg); // 2-char indent
|
|
314
373
|
const lines: string[] = [
|
|
315
|
-
panelBar(width, "─", panelBg),
|
|
374
|
+
effectBar(panelBar(width, "─", panelBg), borderSgr),
|
|
316
375
|
...wrapped.map((l) => panelLine(l, width, panelBg)),
|
|
317
376
|
];
|
|
318
377
|
// L4 — agents block (S27, count + status; per-agent tokens gated on P0)
|
|
@@ -354,6 +413,6 @@ export function buildWidgetLines(
|
|
|
354
413
|
lines.push(panelLine(` ${accentSgr}🏆 Achievement unlocked: ${titlesStr}${sgrReset(accentSgr)}`, width, panelBg));
|
|
355
414
|
}
|
|
356
415
|
// bottom border
|
|
357
|
-
lines.push(panelBar(width, "─", panelBg));
|
|
416
|
+
lines.push(effectBar(panelBar(width, "─", panelBg), borderSgr));
|
|
358
417
|
return lines;
|
|
359
418
|
}
|
package/package.json
CHANGED