pi-mega-compact 0.8.2 โ 0.8.4
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/html.js +1 -1
- package/dist/extensions/dashboard-server/html.test.js +11 -0
- package/dist/extensions/mega-game-cmds.js +15 -6
- package/dist/extensions/mega-game-cmds.test.js +19 -3
- 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/html.test.ts +12 -0
- package/extensions/dashboard-server/html.ts +1 -1
- package/extensions/mega-game-cmds.test.ts +21 -2
- package/extensions/mega-game-cmds.ts +16 -6
- 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
|
@@ -932,7 +932,7 @@ export function dashboardHtml(tierName) {
|
|
|
932
932
|
if (best != null && best > 100) {
|
|
933
933
|
tile.style.display = 'block';
|
|
934
934
|
tile.className = 'achievement-tile unlocked';
|
|
935
|
-
tile.innerHTML = '๐ Opie
|
|
935
|
+
tile.innerHTML = '๐ Opie\\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
|
|
936
936
|
} else { tile.style.display = 'none'; tile.className = 'achievement-tile'; }
|
|
937
937
|
}
|
|
938
938
|
// transient oopsie toast: a NEW mega_cache trophy row since the last poll
|
|
@@ -39,3 +39,14 @@ describe("S35 dashboard HTML achievements", () => {
|
|
|
39
39
|
assert.ok(html.includes("??? "), "??? teaser present");
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
describe("S35 dashboard HTML achievements escaping (v0.8.4 fix)", () => {
|
|
43
|
+
const html = dashboardHtml("custom");
|
|
44
|
+
test("Opie's Wild Ride JS string is backslash-escaped (no bare apostrophe)", () => {
|
|
45
|
+
// The served <script> uses single-quoted JS strings: '๐ Opie\'s Wild Ride...'.
|
|
46
|
+
// A bare apostrophe (Opie's) would terminate the string and halt the script,
|
|
47
|
+
// breaking .tab click bindings. The served HTML MUST contain the escaped
|
|
48
|
+
// form Opie\'s and MUST NOT contain the bare form '๐ Opie's'.
|
|
49
|
+
assert.ok(html.includes("๐ Opie\\'s Wild Ride"), "served JS contains Opie\\'s (escaped)");
|
|
50
|
+
assert.ok(!html.includes("๐ Opie's Wild Ride"), "no bare Opie's in served JS");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -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 [
|
|
@@ -72,7 +81,7 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
72
81
|
// on|off
|
|
73
82
|
if (sub === "on" || sub === "off") {
|
|
74
83
|
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
75
|
-
runtime
|
|
84
|
+
applyChange(ctx, runtime);
|
|
76
85
|
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
77
86
|
return;
|
|
78
87
|
}
|
|
@@ -99,7 +108,7 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
99
108
|
return;
|
|
100
109
|
}
|
|
101
110
|
const s = setGameState({ theme: id }, stateDir);
|
|
102
|
-
runtime
|
|
111
|
+
applyChange(ctx, runtime);
|
|
103
112
|
ctx.ui.notify(`[${TAG}] theme โ ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
104
113
|
return;
|
|
105
114
|
}
|
|
@@ -111,7 +120,7 @@ async function handleSettings(args, ctx, runtime) {
|
|
|
111
120
|
return;
|
|
112
121
|
}
|
|
113
122
|
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
114
|
-
runtime
|
|
123
|
+
applyChange(ctx, runtime);
|
|
115
124
|
ctx.ui.notify(`[${TAG}] tui โ ${s.tui_display_mode}`);
|
|
116
125
|
return;
|
|
117
126
|
}
|
|
@@ -134,7 +143,7 @@ async function runInteractiveMenu(ctx, runtime, stateDir) {
|
|
|
134
143
|
if (choice === toggleLabel) {
|
|
135
144
|
const next = !s.game_mode_on;
|
|
136
145
|
setGameState({ game_mode_on: next }, stateDir);
|
|
137
|
-
runtime
|
|
146
|
+
applyChange(ctx, runtime);
|
|
138
147
|
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
139
148
|
continue;
|
|
140
149
|
}
|
|
@@ -174,7 +183,7 @@ async function themeSubmenu(ctx, runtime, stateDir) {
|
|
|
174
183
|
}
|
|
175
184
|
if (id && id !== s.theme) {
|
|
176
185
|
setGameState({ theme: id }, stateDir);
|
|
177
|
-
runtime
|
|
186
|
+
applyChange(ctx, runtime);
|
|
178
187
|
ctx.ui.notify(`[${TAG}] theme โ ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
179
188
|
}
|
|
180
189
|
}
|
|
@@ -192,7 +201,7 @@ async function tuiSubmenu(ctx, runtime, stateDir) {
|
|
|
192
201
|
const mode = choice.split(/\s+/)[0];
|
|
193
202
|
if (mode === "full" || mode === "minimal") {
|
|
194
203
|
setGameState({ tui_display_mode: mode }, stateDir);
|
|
195
|
-
runtime
|
|
204
|
+
applyChange(ctx, runtime);
|
|
196
205
|
ctx.ui.notify(`[${TAG}] tui โ ${mode}`, "info");
|
|
197
206
|
}
|
|
198
207
|
}
|
|
@@ -16,10 +16,16 @@ 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, select) {
|
|
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
31
|
ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
|
|
@@ -33,7 +39,7 @@ function makeHarness(stateDir, select) {
|
|
|
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;
|
|
@@ -92,6 +98,16 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
92
98
|
assert.equal(getGameState().theme, "retro");
|
|
93
99
|
assert.ok(h.notifies.some((l) => l.includes("theme โ retro")));
|
|
94
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
|
+
});
|
|
95
111
|
it("on enables game mode and persists", async () => {
|
|
96
112
|
await run("on");
|
|
97
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,
|
|
@@ -48,3 +48,15 @@ describe("S35 dashboard HTML achievements", () => {
|
|
|
48
48
|
assert.ok(html.includes("??? "), "??? teaser present");
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("S35 dashboard HTML achievements escaping (v0.8.4 fix)", () => {
|
|
53
|
+
const html = dashboardHtml("custom");
|
|
54
|
+
test("Opie's Wild Ride JS string is backslash-escaped (no bare apostrophe)", () => {
|
|
55
|
+
// The served <script> uses single-quoted JS strings: '๐ Opie\'s Wild Ride...'.
|
|
56
|
+
// A bare apostrophe (Opie's) would terminate the string and halt the script,
|
|
57
|
+
// breaking .tab click bindings. The served HTML MUST contain the escaped
|
|
58
|
+
// form Opie\'s and MUST NOT contain the bare form '๐ Opie's'.
|
|
59
|
+
assert.ok(html.includes("๐ Opie\\'s Wild Ride"), "served JS contains Opie\\'s (escaped)");
|
|
60
|
+
assert.ok(!html.includes("๐ Opie's Wild Ride"), "no bare Opie's in served JS");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -935,7 +935,7 @@ export function dashboardHtml(tierName: string): string {
|
|
|
935
935
|
if (best != null && best > 100) {
|
|
936
936
|
tile.style.display = 'block';
|
|
937
937
|
tile.className = 'achievement-tile unlocked';
|
|
938
|
-
tile.innerHTML = '๐ Opie
|
|
938
|
+
tile.innerHTML = '๐ Opie\\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
|
|
939
939
|
} else { tile.style.display = 'none'; tile.className = 'achievement-tile'; }
|
|
940
940
|
}
|
|
941
941
|
// transient oopsie toast: a NEW mega_cache trophy row since the last poll
|
|
@@ -24,15 +24,23 @@ type Harness = {
|
|
|
24
24
|
commands: Record<string, Cmd>;
|
|
25
25
|
notifies: string[];
|
|
26
26
|
ctx: any;
|
|
27
|
+
snapshotCalls: number[];
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
function makeHarness(
|
|
30
31
|
stateDir: string,
|
|
31
32
|
select?: (title: string, options: string[]) => Promise<string | undefined>,
|
|
33
|
+
snapshotSpy?: () => void,
|
|
32
34
|
): Harness {
|
|
33
35
|
const commands: Record<string, Cmd> = {};
|
|
34
36
|
const notifies: string[] = [];
|
|
35
|
-
const
|
|
37
|
+
const snapshotCalls: number[] = [];
|
|
38
|
+
const runtime = {
|
|
39
|
+
bindRepo: () => {},
|
|
40
|
+
currentStateDir: stateDir,
|
|
41
|
+
bumpGameState: () => {},
|
|
42
|
+
snapshot: () => { snapshotCalls.push(1); snapshotSpy?.(); },
|
|
43
|
+
};
|
|
36
44
|
const ctx = {
|
|
37
45
|
cwd: stateDir,
|
|
38
46
|
ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
|
|
@@ -48,7 +56,7 @@ function makeHarness(
|
|
|
48
56
|
registerGameCommands: (pi: unknown, runtime: unknown) => void;
|
|
49
57
|
};
|
|
50
58
|
mod.registerGameCommands(fakePi, runtime);
|
|
51
|
-
return { commands, notifies, ctx };
|
|
59
|
+
return { commands, notifies, ctx, snapshotCalls };
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
@@ -115,6 +123,17 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
|
|
|
115
123
|
assert.ok(h.notifies.some((l) => l.includes("theme โ retro")));
|
|
116
124
|
});
|
|
117
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
|
+
|
|
118
137
|
it("on enables game mode and persists", async () => {
|
|
119
138
|
await run("on");
|
|
120
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 [
|
|
@@ -88,7 +98,7 @@ async function handleSettings(
|
|
|
88
98
|
// on|off
|
|
89
99
|
if (sub === "on" || sub === "off") {
|
|
90
100
|
const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
|
|
91
|
-
runtime
|
|
101
|
+
applyChange(ctx, runtime);
|
|
92
102
|
ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
|
|
93
103
|
return;
|
|
94
104
|
}
|
|
@@ -114,7 +124,7 @@ async function handleSettings(
|
|
|
114
124
|
return;
|
|
115
125
|
}
|
|
116
126
|
const s = setGameState({ theme: id }, stateDir);
|
|
117
|
-
runtime
|
|
127
|
+
applyChange(ctx, runtime);
|
|
118
128
|
ctx.ui.notify(`[${TAG}] theme โ ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
|
|
119
129
|
return;
|
|
120
130
|
}
|
|
@@ -127,7 +137,7 @@ async function handleSettings(
|
|
|
127
137
|
return;
|
|
128
138
|
}
|
|
129
139
|
const s = setGameState({ tui_display_mode: arg }, stateDir);
|
|
130
|
-
runtime
|
|
140
|
+
applyChange(ctx, runtime);
|
|
131
141
|
ctx.ui.notify(`[${TAG}] tui โ ${s.tui_display_mode}`);
|
|
132
142
|
return;
|
|
133
143
|
}
|
|
@@ -160,7 +170,7 @@ async function runInteractiveMenu(
|
|
|
160
170
|
if (choice === toggleLabel) {
|
|
161
171
|
const next = !s.game_mode_on;
|
|
162
172
|
setGameState({ game_mode_on: next }, stateDir);
|
|
163
|
-
runtime
|
|
173
|
+
applyChange(ctx, runtime);
|
|
164
174
|
ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
|
|
165
175
|
continue;
|
|
166
176
|
}
|
|
@@ -203,7 +213,7 @@ async function themeSubmenu(
|
|
|
203
213
|
}
|
|
204
214
|
if (id && id !== s.theme) {
|
|
205
215
|
setGameState({ theme: id }, stateDir);
|
|
206
|
-
runtime
|
|
216
|
+
applyChange(ctx, runtime);
|
|
207
217
|
ctx.ui.notify(`[${TAG}] theme โ ${id} (${getTheme(id)?.label ?? ""})`, "info");
|
|
208
218
|
}
|
|
209
219
|
}
|
|
@@ -225,7 +235,7 @@ async function tuiSubmenu(
|
|
|
225
235
|
const mode = choice.split(/\s+/)[0];
|
|
226
236
|
if (mode === "full" || mode === "minimal") {
|
|
227
237
|
setGameState({ tui_display_mode: mode }, stateDir);
|
|
228
|
-
runtime
|
|
238
|
+
applyChange(ctx, runtime);
|
|
229
239
|
ctx.ui.notify(`[${TAG}] tui โ ${mode}`, "info");
|
|
230
240
|
}
|
|
231
241
|
}
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi โ supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-2-Clause",
|