pi-mega-compact 0.7.8 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -12
- package/dist/extensions/dashboard-server/html.js +1023 -0
- package/dist/extensions/dashboard-server/html.test.js +41 -0
- package/dist/extensions/dashboard-server/index-reader.js +133 -0
- package/dist/extensions/dashboard-server/server.js +530 -0
- package/dist/extensions/dashboard-server/server.test.js +120 -0
- package/dist/extensions/dashboard-server/snapshot.js +43 -0
- package/dist/extensions/dashboard-server/state.js +30 -0
- package/dist/extensions/dashboard-server/types.js +5 -0
- package/dist/extensions/dashboard-server-s32.test.js +181 -0
- package/dist/extensions/dashboard-server.js +7 -1315
- package/dist/extensions/mega-commands.js +162 -134
- package/dist/extensions/mega-compact.js +3 -0
- package/dist/extensions/mega-compact.test.js +90 -21
- package/dist/extensions/mega-conflict-cmds.js +5 -1
- package/dist/extensions/mega-dashboard-cmds.js +29 -22
- package/dist/extensions/mega-db-cmds.js +11 -2
- package/dist/extensions/mega-events/agent-handlers.js +222 -0
- package/dist/extensions/mega-events/compact-handlers.js +162 -0
- package/dist/extensions/mega-events/context-handler.js +249 -0
- package/dist/extensions/mega-events/register.js +21 -0
- package/dist/extensions/mega-events/session-handlers.js +142 -0
- package/dist/extensions/mega-events.js +15 -699
- package/dist/extensions/mega-game-cmds.js +106 -0
- package/dist/extensions/mega-game-cmds.test.js +113 -0
- package/dist/extensions/mega-pipeline/compact.js +324 -0
- package/dist/extensions/mega-pipeline/memory-review.js +38 -0
- package/dist/extensions/mega-pipeline/recall.js +147 -0
- package/dist/extensions/mega-pipeline.js +9 -480
- package/dist/extensions/mega-runtime/helpers.js +40 -0
- package/dist/extensions/mega-runtime/query.js +29 -0
- package/dist/extensions/mega-runtime/state.js +877 -0
- package/dist/extensions/mega-runtime/state.test.js +171 -0
- package/dist/extensions/mega-runtime/widget.js +270 -0
- package/dist/extensions/mega-runtime/widget.test.js +160 -0
- package/dist/extensions/mega-runtime.js +15 -947
- package/dist/src/config/themes.js +84 -0
- package/dist/src/config/themes.test.js +94 -0
- package/dist/src/game/scoring.js +105 -0
- package/dist/src/game/scoring.test.js +98 -0
- package/dist/src/store/sqlite/checkpoints.js +145 -0
- package/dist/src/store/sqlite/dedup-mirror.js +64 -0
- package/dist/src/store/sqlite/foundation.js +38 -0
- package/dist/src/store/sqlite/game-achievements.js +111 -0
- package/dist/src/store/sqlite/game-achievements.test.js +67 -0
- package/dist/src/store/sqlite/game-scores.js +105 -0
- package/dist/src/store/sqlite/game-scores.test.js +106 -0
- package/dist/src/store/sqlite/game-state.js +54 -0
- package/dist/src/store/sqlite/game-state.test.js +76 -0
- package/dist/src/store/sqlite/global-index.js +224 -0
- package/dist/src/store/sqlite/maintenance.js +235 -0
- package/dist/src/store/sqlite/memories.js +164 -0
- package/dist/src/store/sqlite/meta.js +82 -0
- package/dist/src/store/sqlite/model-snapshots.js +47 -0
- package/dist/src/store/sqlite/raptor.js +57 -0
- package/dist/src/store/sqlite/raw-transcript.js +134 -0
- package/dist/src/store/sqlite/schema.js +294 -0
- package/dist/src/store/sqlite/session-state.js +28 -0
- package/dist/src/store/sqlite/stats.js +66 -0
- package/dist/src/store/sqlite/utils.js +120 -0
- package/dist/src/store/sqlite.js +23 -1607
- package/extensions/dashboard-server/html.test.ts +50 -0
- package/extensions/dashboard-server/html.ts +1026 -0
- package/extensions/dashboard-server/index-reader.ts +130 -0
- package/extensions/dashboard-server/server.test.ts +131 -0
- package/extensions/dashboard-server/server.ts +505 -0
- package/extensions/dashboard-server/snapshot.ts +44 -0
- package/extensions/dashboard-server/state.ts +33 -0
- package/extensions/dashboard-server/types.ts +134 -0
- package/extensions/dashboard-server-s32.test.ts +195 -0
- package/extensions/dashboard-server.ts +7 -1431
- package/extensions/mega-commands.ts +33 -10
- package/extensions/mega-compact.test.ts +198 -43
- package/extensions/mega-compact.ts +3 -0
- package/extensions/mega-conflict-cmds.ts +6 -2
- package/extensions/mega-dashboard-cmds.ts +30 -23
- package/extensions/mega-db-cmds.ts +11 -3
- package/extensions/mega-events/agent-handlers.ts +262 -0
- package/extensions/mega-events/compact-handlers.ts +192 -0
- package/extensions/mega-events/context-handler.ts +290 -0
- package/extensions/mega-events/register.ts +37 -0
- package/extensions/mega-events/session-handlers.ts +165 -0
- package/extensions/mega-events.ts +15 -780
- package/extensions/mega-game-cmds.test.ts +137 -0
- package/extensions/mega-game-cmds.ts +122 -0
- package/extensions/mega-pipeline/compact.ts +366 -0
- package/extensions/mega-pipeline/memory-review.ts +46 -0
- package/extensions/mega-pipeline/recall.ts +165 -0
- package/extensions/mega-pipeline.ts +9 -537
- package/extensions/mega-runtime/helpers.ts +68 -0
- package/extensions/mega-runtime/query.ts +29 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +967 -0
- package/extensions/mega-runtime/widget.test.ts +185 -0
- package/extensions/mega-runtime/widget.ts +359 -0
- package/extensions/mega-runtime.ts +15 -1093
- package/package.json +4 -3
- package/src/config/themes.test.ts +116 -0
- package/src/config/themes.ts +124 -0
- package/src/game/scoring.test.ts +103 -0
- package/src/game/scoring.ts +158 -0
- package/src/store/sqlite/checkpoints.ts +204 -0
- package/src/store/sqlite/dedup-mirror.ts +114 -0
- package/src/store/sqlite/foundation.ts +63 -0
- package/src/store/sqlite/game-achievements.test.ts +80 -0
- package/src/store/sqlite/game-achievements.ts +147 -0
- package/src/store/sqlite/game-scores.test.ts +132 -0
- package/src/store/sqlite/game-scores.ts +168 -0
- package/src/store/sqlite/game-state.test.ts +89 -0
- package/src/store/sqlite/game-state.ts +87 -0
- package/src/store/sqlite/global-index.ts +305 -0
- package/src/store/sqlite/maintenance.ts +294 -0
- package/src/store/sqlite/memories.ts +217 -0
- package/src/store/sqlite/meta.ts +108 -0
- package/src/store/sqlite/model-snapshots.ts +83 -0
- package/src/store/sqlite/raptor.ts +107 -0
- package/src/store/sqlite/raw-transcript.ts +221 -0
- package/src/store/sqlite/schema.ts +305 -0
- package/src/store/sqlite/session-state.ts +38 -0
- package/src/store/sqlite/stats.ts +127 -0
- package/src/store/sqlite/utils.ts +125 -0
- package/src/store/sqlite.ts +23 -2204
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-scores.test.ts — S33 game_scores table round-trip + leaderboard ordering.
|
|
3
|
+
* Pi-agnostic. Uses an isolated state dir (never the real user dir — G7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { closeStore } from "./utils.js";
|
|
11
|
+
import {
|
|
12
|
+
recordScore,
|
|
13
|
+
leaderboard,
|
|
14
|
+
METRICS,
|
|
15
|
+
type GameMetric,
|
|
16
|
+
} from "./game-scores.js";
|
|
17
|
+
|
|
18
|
+
describe("game-scores (S33)", () => {
|
|
19
|
+
let dir: string;
|
|
20
|
+
before(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "mc-gamescores-"));
|
|
22
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
23
|
+
});
|
|
24
|
+
after(() => {
|
|
25
|
+
closeStore(dir);
|
|
26
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("records + reads back a cache score with parsed meta", () => {
|
|
31
|
+
recordScore(dir, {
|
|
32
|
+
repo_root: "/repo/a",
|
|
33
|
+
metric: "cache",
|
|
34
|
+
value: 42,
|
|
35
|
+
meta: { hits: 7, lookups: 10 },
|
|
36
|
+
});
|
|
37
|
+
const rows = leaderboard(dir, "cache", { repoRoot: "/repo/a" });
|
|
38
|
+
assert.equal(rows.length, 1);
|
|
39
|
+
assert.equal(rows[0].repo_root, "/repo/a");
|
|
40
|
+
assert.equal(rows[0].value, 42);
|
|
41
|
+
assert.deepEqual(rows[0].meta, { hits: 7, lookups: 10 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("cache leaderboard ranks by latest value per repo, descending", () => {
|
|
45
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 10 });
|
|
46
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 99 }); // latest wins
|
|
47
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 50 });
|
|
48
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 75 });
|
|
49
|
+
const rows = leaderboard(dir, "cache"); // all repos, default limit 10
|
|
50
|
+
assert.equal(rows.length, 3);
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
rows.map((r) => [r.repo_root, r.value]),
|
|
53
|
+
[["/repo/a", 99], ["/repo/c", 75], ["/repo/b", 50]],
|
|
54
|
+
);
|
|
55
|
+
const a = rows.find((r) => r.repo_root === "/repo/a")!;
|
|
56
|
+
assert.equal(a.value, 99); // not the older 10
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("turns leaderboard uses the latest value per repo", () => {
|
|
60
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "turns", value: 3 });
|
|
61
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "turns", value: 8 });
|
|
62
|
+
const rows = leaderboard(dir, "turns", { repoRoot: "/repo/a" });
|
|
63
|
+
assert.equal(rows.length, 1);
|
|
64
|
+
assert.equal(rows[0].value, 8);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("dedupe leaderboard sums collapses per repo, descending", () => {
|
|
68
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "dedupe", value: 2 });
|
|
69
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "dedupe", value: 3 });
|
|
70
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "dedupe", value: 10 });
|
|
71
|
+
const rows = leaderboard(dir, "dedupe");
|
|
72
|
+
assert.equal(rows.length, 2);
|
|
73
|
+
assert.deepEqual(
|
|
74
|
+
rows.map((r) => [r.repo_root, r.value]),
|
|
75
|
+
[["/repo/b", 10], ["/repo/a", 5]],
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("mega_cache leaderboard returns the max trophy per repo", () => {
|
|
80
|
+
recordScore(dir, {
|
|
81
|
+
repo_root: "/repo/a",
|
|
82
|
+
metric: "mega_cache",
|
|
83
|
+
value: 120,
|
|
84
|
+
meta: { peakPct: 120 },
|
|
85
|
+
});
|
|
86
|
+
recordScore(dir, {
|
|
87
|
+
repo_root: "/repo/a",
|
|
88
|
+
metric: "mega_cache",
|
|
89
|
+
value: 150,
|
|
90
|
+
meta: { peakPct: 150 },
|
|
91
|
+
});
|
|
92
|
+
const rows = leaderboard(dir, "mega_cache", { repoRoot: "/repo/a" });
|
|
93
|
+
assert.equal(rows.length, 1);
|
|
94
|
+
assert.equal(rows[0].value, 150);
|
|
95
|
+
assert.deepEqual(rows[0].meta, { peakPct: 150 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("repos leaderboard is the derived COUNT(DISTINCT repo_root)", () => {
|
|
99
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 1 });
|
|
100
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 1 });
|
|
101
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 1 });
|
|
102
|
+
const rows = leaderboard(dir, "repos");
|
|
103
|
+
assert.equal(rows.length, 1);
|
|
104
|
+
assert.equal(rows[0].value, 3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("parameterizes repo_root (no SQL injection via repo path)", () => {
|
|
108
|
+
const evil = "x'); DROP TABLE game_scores; --";
|
|
109
|
+
recordScore(dir, { repo_root: evil, metric: "cache", value: 5 });
|
|
110
|
+
const rows = leaderboard(dir, "cache", { repoRoot: evil });
|
|
111
|
+
assert.equal(rows.length, 1);
|
|
112
|
+
assert.equal(rows[0].repo_root, evil);
|
|
113
|
+
// the table is intact: a normal insert still works
|
|
114
|
+
recordScore(dir, { repo_root: "/repo/z", metric: "cache", value: 1 });
|
|
115
|
+
assert.equal(leaderboard(dir, "cache", { repoRoot: "/repo/z" }).length, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects an unknown metric", () => {
|
|
119
|
+
const bad = "bogus" as unknown as GameMetric;
|
|
120
|
+
assert.throws(() =>
|
|
121
|
+
recordScore(dir, { repo_root: "/r", metric: bad, value: 1 }),
|
|
122
|
+
);
|
|
123
|
+
assert.throws(() => leaderboard(dir, bad));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("METRICS allow-list matches the schema CHECK set", () => {
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
[...METRICS].sort(),
|
|
129
|
+
["cache", "dedupe", "mega_cache", "repos", "turns"],
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-scores.ts — `game_scores` table accessors (S33).
|
|
3
|
+
*
|
|
4
|
+
* Per-repo leaderboard metrics for game mode. One row per recorded event
|
|
5
|
+
* (turn_end / session_compact); leaderboard() derives rankings (latest / max /
|
|
6
|
+
* SUM / COUNT DISTINCT) per repo_root. 'repos' is derived (never recorded).
|
|
7
|
+
*
|
|
8
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
9
|
+
* PREVENT-002: all SQL parameterized (? placeholders, no string concat of user
|
|
10
|
+
* data). The metric is validated against a fixed allow-list before use; the
|
|
11
|
+
* only interpolated fragments are code-controlled constants (the aggregate
|
|
12
|
+
* selector + the optional repo_root filter clause), never external input.
|
|
13
|
+
* Pi-agnostic: no pi runtime types (mirrors game-state.ts / meta.ts).
|
|
14
|
+
*/
|
|
15
|
+
import { getStateDir } from "../../store.js";
|
|
16
|
+
import { openStore } from "./utils.js";
|
|
17
|
+
import type { SQLInputValue } from "node:sqlite";
|
|
18
|
+
import { GameMetric, METRICS } from "../../game/scoring.js";
|
|
19
|
+
|
|
20
|
+
// Process-local monotonic sequence appended into the low 3 digits of `ts` so
|
|
21
|
+
// back-to-back inserts within the same millisecond get distinct PK values
|
|
22
|
+
// (Date.now()*1000 + seq%1000). Production turns are >1ms apart, so seq is
|
|
23
|
+
// almost always 0; this only disambiguates the burst case. Without it,
|
|
24
|
+
// OR REPLACE would drop the earlier same-ms row and break the dedupe SUM.
|
|
25
|
+
let scoreSeq = 0;
|
|
26
|
+
function nextTs(): number {
|
|
27
|
+
return Date.now() * 1000 + (scoreSeq++ % 1000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type { GameMetric } from "../../game/scoring.js";
|
|
31
|
+
export { METRICS } from "../../game/scoring.js";
|
|
32
|
+
|
|
33
|
+
/** A single recorded game-score event (as stored). */
|
|
34
|
+
export interface ScoreRow {
|
|
35
|
+
repo_root: string;
|
|
36
|
+
metric: GameMetric;
|
|
37
|
+
ts: number;
|
|
38
|
+
value: number;
|
|
39
|
+
meta?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A leaderboard row returned by leaderboard(). */
|
|
43
|
+
export interface LeaderboardRow {
|
|
44
|
+
repo_root: string;
|
|
45
|
+
value: number;
|
|
46
|
+
ts: number;
|
|
47
|
+
meta: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for leaderboard(): optional repo filter + result limit. */
|
|
51
|
+
export interface LeaderboardOpts {
|
|
52
|
+
repoRoot?: string;
|
|
53
|
+
limit?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Record one game-score event. `ts` is set to Date.now() by the store. SQL is
|
|
58
|
+
* fully parameterized (PREVENT-002); the metric is validated against the fixed
|
|
59
|
+
* allow-list. Pi-agnostic. Never throws except on a genuinely unknown metric.
|
|
60
|
+
*/
|
|
61
|
+
export function recordScore(
|
|
62
|
+
stateDir: string = getStateDir(),
|
|
63
|
+
args: { repo_root: string; metric: GameMetric; value: number; meta?: unknown },
|
|
64
|
+
): void {
|
|
65
|
+
if (!METRICS.includes(args.metric)) {
|
|
66
|
+
throw new Error(`recordScore: unknown game metric ${String(args.metric)}`);
|
|
67
|
+
}
|
|
68
|
+
const db = openStore(stateDir);
|
|
69
|
+
db.prepare(
|
|
70
|
+
`INSERT INTO game_scores (repo_root, metric, ts, value, meta)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
72
|
+
).run(
|
|
73
|
+
args.repo_root,
|
|
74
|
+
args.metric,
|
|
75
|
+
nextTs(),
|
|
76
|
+
args.value,
|
|
77
|
+
args.meta != null ? JSON.stringify(args.meta) : null,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the leaderboard for a metric:
|
|
83
|
+
* - cache/turns: latest value per repo_root, sorted desc by value.
|
|
84
|
+
* - mega_cache: the max value (trophy) per repo_root, sorted desc.
|
|
85
|
+
* - dedupe: SUM(value) per repo_root (cumulative collapses), sorted desc.
|
|
86
|
+
* - repos: COUNT(DISTINCT repo_root) — derived; returns a single synthesized
|
|
87
|
+
* row (repo_root '*') with the distinct-repo count as `value`.
|
|
88
|
+
* The metric is validated against the allow-list. Pi-agnostic.
|
|
89
|
+
*/
|
|
90
|
+
export function leaderboard(
|
|
91
|
+
stateDir: string = getStateDir(),
|
|
92
|
+
metric: GameMetric,
|
|
93
|
+
opts: LeaderboardOpts = {},
|
|
94
|
+
): LeaderboardRow[] {
|
|
95
|
+
if (!METRICS.includes(metric)) {
|
|
96
|
+
throw new Error(`leaderboard: unknown game metric ${String(metric)}`);
|
|
97
|
+
}
|
|
98
|
+
const db = openStore(stateDir);
|
|
99
|
+
const limit = Math.max(1, opts.limit ?? 10);
|
|
100
|
+
// Code-controlled filter clauses (never external input) — safe under PREVENT-002.
|
|
101
|
+
// Two variants: the dedupe query is single-table (unqualified repo_root);
|
|
102
|
+
// the cache/turns/mega_cache query self-JOINs (must qualify as g.repo_root).
|
|
103
|
+
const repoFilterDedupe = opts.repoRoot != null ? " AND repo_root = ?" : "";
|
|
104
|
+
const repoFilter = opts.repoRoot != null ? " AND g.repo_root = ?" : "";
|
|
105
|
+
const repoParam: SQLInputValue[] = opts.repoRoot != null ? [opts.repoRoot] : [];
|
|
106
|
+
|
|
107
|
+
if (metric === "repos") {
|
|
108
|
+
const row = db
|
|
109
|
+
.prepare(`SELECT COUNT(DISTINCT repo_root) AS value FROM game_scores`)
|
|
110
|
+
.get() as { value: number };
|
|
111
|
+
return [{ repo_root: "*", value: row.value, ts: 0, meta: null }];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (metric === "dedupe") {
|
|
115
|
+
const sql = `SELECT repo_root, SUM(value) AS value, MAX(ts) AS ts, NULL AS meta
|
|
116
|
+
FROM game_scores
|
|
117
|
+
WHERE metric = ?${repoFilterDedupe}
|
|
118
|
+
GROUP BY repo_root
|
|
119
|
+
ORDER BY value DESC
|
|
120
|
+
LIMIT ?`;
|
|
121
|
+
const rows = db.prepare(sql).all(metric, ...repoParam, limit) as Array<{
|
|
122
|
+
repo_root: string;
|
|
123
|
+
value: number;
|
|
124
|
+
ts: number;
|
|
125
|
+
meta: string | null;
|
|
126
|
+
}>;
|
|
127
|
+
return rows.map(toLeaderboardRow);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// cache / turns / mega_cache: pick the qualifying row per repo_root (latest
|
|
131
|
+
// ts, or max value for mega_cache), then rank by value desc. The interpolated
|
|
132
|
+
// `key`/`col` fragments are derived from the validated metric — code constants,
|
|
133
|
+
// not user data (PREVENT-002 safe).
|
|
134
|
+
const key = metric === "mega_cache" ? "MAX(value)" : "MAX(ts)";
|
|
135
|
+
const col = metric === "mega_cache" ? "value" : "ts";
|
|
136
|
+
const sql = `SELECT g.repo_root, g.value AS value, g.ts AS ts, g.meta AS meta
|
|
137
|
+
FROM game_scores g
|
|
138
|
+
JOIN (SELECT repo_root, ${key} AS k
|
|
139
|
+
FROM game_scores WHERE metric = ? GROUP BY repo_root) m
|
|
140
|
+
ON m.repo_root = g.repo_root AND m.k = g.${col}
|
|
141
|
+
WHERE g.metric = ?${repoFilter}
|
|
142
|
+
ORDER BY g.value DESC
|
|
143
|
+
LIMIT ?`;
|
|
144
|
+
const rows = db
|
|
145
|
+
.prepare(sql)
|
|
146
|
+
.all(metric, metric, ...repoParam, limit) as Array<{
|
|
147
|
+
repo_root: string;
|
|
148
|
+
value: number;
|
|
149
|
+
ts: number;
|
|
150
|
+
meta: string | null;
|
|
151
|
+
}>;
|
|
152
|
+
return rows.map(toLeaderboardRow);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Helper: coerce a raw row (meta as JSON text) into a LeaderboardRow. */
|
|
156
|
+
function toLeaderboardRow(r: {
|
|
157
|
+
repo_root: string;
|
|
158
|
+
value: number;
|
|
159
|
+
ts: number;
|
|
160
|
+
meta: string | null;
|
|
161
|
+
}): LeaderboardRow {
|
|
162
|
+
return {
|
|
163
|
+
repo_root: r.repo_root,
|
|
164
|
+
value: r.value,
|
|
165
|
+
ts: r.ts,
|
|
166
|
+
meta: r.meta != null ? JSON.parse(r.meta) : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-state.test.ts — S30 game_state table round-trip + validation.
|
|
3
|
+
* Pi-agnostic. Uses an isolated state dir (never the real user dir — G7).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { closeStore } from "./utils.js";
|
|
11
|
+
import {
|
|
12
|
+
getGameState,
|
|
13
|
+
setGameState,
|
|
14
|
+
DEFAULT_GAME_STATE,
|
|
15
|
+
} from "./game-state.js";
|
|
16
|
+
|
|
17
|
+
describe("game-state (S30)", () => {
|
|
18
|
+
let dir: string;
|
|
19
|
+
before(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), "mc-gamestate-"));
|
|
21
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
22
|
+
});
|
|
23
|
+
after(() => {
|
|
24
|
+
closeStore(dir);
|
|
25
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns DEFAULT_GAME_STATE on a fresh store", () => {
|
|
30
|
+
const s = getGameState();
|
|
31
|
+
assert.deepEqual(s, { ...DEFAULT_GAME_STATE });
|
|
32
|
+
assert.equal(s.game_mode_on, false);
|
|
33
|
+
assert.equal(s.theme, "transparent");
|
|
34
|
+
assert.equal(s.tui_display_mode, "full");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("round-trips game_mode_on", () => {
|
|
38
|
+
setGameState({ game_mode_on: true });
|
|
39
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
40
|
+
setGameState({ game_mode_on: false });
|
|
41
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("round-trips a valid theme", () => {
|
|
45
|
+
setGameState({ theme: "retro" });
|
|
46
|
+
assert.equal(getGameState().theme, "retro");
|
|
47
|
+
setGameState({ theme: "cyan-neon" });
|
|
48
|
+
assert.equal(getGameState().theme, "cyan-neon");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects an unknown theme (keeps previous)", () => {
|
|
52
|
+
setGameState({ theme: "orange-bold" });
|
|
53
|
+
setGameState({ theme: "does-not-exist" as string });
|
|
54
|
+
assert.equal(getGameState().theme, "orange-bold");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("round-trips tui_display_mode full|minimal", () => {
|
|
58
|
+
setGameState({ tui_display_mode: "minimal" });
|
|
59
|
+
assert.equal(getGameState().tui_display_mode, "minimal");
|
|
60
|
+
setGameState({ tui_display_mode: "full" });
|
|
61
|
+
assert.equal(getGameState().tui_display_mode, "full");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("persists across a reopen (survives restart)", () => {
|
|
65
|
+
setGameState({ game_mode_on: true, theme: "amber-mono", tui_display_mode: "minimal" });
|
|
66
|
+
// evict + reopen the store to prove it hit disk
|
|
67
|
+
closeStore(dir);
|
|
68
|
+
const s = getGameState();
|
|
69
|
+
assert.equal(s.game_mode_on, true);
|
|
70
|
+
assert.equal(s.theme, "amber-mono");
|
|
71
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("setGameState is a partial merge (untouched fields preserved)", () => {
|
|
75
|
+
setGameState({ game_mode_on: true, theme: "grayscale", tui_display_mode: "minimal" });
|
|
76
|
+
const s = setGameState({ game_mode_on: false });
|
|
77
|
+
assert.equal(s.game_mode_on, false);
|
|
78
|
+
assert.equal(s.theme, "grayscale");
|
|
79
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("stays a single row (no duplicate id=1)", () => {
|
|
83
|
+
setGameState({ game_mode_on: true });
|
|
84
|
+
setGameState({ game_mode_on: false });
|
|
85
|
+
setGameState({ theme: "retro" });
|
|
86
|
+
const s = getGameState();
|
|
87
|
+
assert.ok(s); // single state, last-write-wins
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-state.ts — `game_state` table: the global game-mode toggle state (S30).
|
|
3
|
+
*
|
|
4
|
+
* Single row (id=1) holding: game_mode_on (bool), theme (one of THEME_IDS),
|
|
5
|
+
* tui_display_mode ('full'|'minimal'). Global — one choice across all repos.
|
|
6
|
+
* Survives restarts; editable from /mega-game (TUI) or the dashboard panel
|
|
7
|
+
* (S32), both reading/writing this same row so they stay in sync.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
10
|
+
* PREVENT-002: all SQL parameterized (@param / ? placeholders, no concat).
|
|
11
|
+
* Pi-agnostic: no pi runtime types (mirrors meta.ts / session-state.ts).
|
|
12
|
+
*/
|
|
13
|
+
import { DatabaseSync } from "node:sqlite";
|
|
14
|
+
import { getStateDir } from "../../store.js";
|
|
15
|
+
import { openStore } from "./utils.js";
|
|
16
|
+
import { DEFAULT_THEME, isValidTheme } from "../../config/themes.js";
|
|
17
|
+
|
|
18
|
+
/** The persisted game-mode state. `theme` is always a valid THEME_IDS member
|
|
19
|
+
* (callers may assume the invariant; setGameState validates). */
|
|
20
|
+
export interface GameState {
|
|
21
|
+
game_mode_on: boolean;
|
|
22
|
+
theme: string;
|
|
23
|
+
tui_display_mode: "full" | "minimal";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_GAME_STATE: GameState = {
|
|
27
|
+
game_mode_on: false,
|
|
28
|
+
theme: DEFAULT_THEME,
|
|
29
|
+
tui_display_mode: "full",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Read the global game-mode state. Returns DEFAULT_GAME_STATE if the row is
|
|
33
|
+
* absent (fresh install) or any column is unexpectedly null/invalid — never
|
|
34
|
+
* throws, so the widget / command can call it on every render safely. */
|
|
35
|
+
export function getGameState(stateDir: string = getStateDir()): GameState {
|
|
36
|
+
const db = openStore(stateDir);
|
|
37
|
+
return getGameStateRow(db);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read the game state from an already-open connection (used by callers that
|
|
41
|
+
* hold a db handle, e.g. within a transaction). Same defensive fallback. */
|
|
42
|
+
export function getGameStateRow(db: DatabaseSync): GameState {
|
|
43
|
+
const row = db.prepare(
|
|
44
|
+
"SELECT game_mode_on, theme, tui_display_mode FROM game_state WHERE id = 1",
|
|
45
|
+
).get() as { game_mode_on: number; theme: string; tui_display_mode: string } | undefined;
|
|
46
|
+
if (!row) return { ...DEFAULT_GAME_STATE };
|
|
47
|
+
const mode: "full" | "minimal" =
|
|
48
|
+
row.tui_display_mode === "minimal" ? "minimal" : "full";
|
|
49
|
+
return {
|
|
50
|
+
game_mode_on: row.game_mode_on === 1,
|
|
51
|
+
theme: isValidTheme(row.theme) ? row.theme : DEFAULT_THEME,
|
|
52
|
+
tui_display_mode: mode,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A partial update to the game state (omit fields you don't want to change). */
|
|
57
|
+
export type GameStatePatch = Partial<GameState>;
|
|
58
|
+
|
|
59
|
+
/** Upsert the game state row, applying `patch` on top of the current values.
|
|
60
|
+
* Validates `theme` (keeps previous on an unknown id) and `tui_display_mode`
|
|
61
|
+
* (coerces to 'full' on anything but 'minimal'). Parameterized (PREVENT-002).
|
|
62
|
+
* Returns the post-write state. */
|
|
63
|
+
export function setGameState(patch: GameStatePatch, stateDir: string = getStateDir()): GameState {
|
|
64
|
+
const db = openStore(stateDir);
|
|
65
|
+
return setGameStateRow(db, patch);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Upsert on an already-open connection (for transactional callers). */
|
|
69
|
+
export function setGameStateRow(db: DatabaseSync, patch: GameStatePatch): GameState {
|
|
70
|
+
const cur = getGameStateRow(db);
|
|
71
|
+
const next: GameState = {
|
|
72
|
+
game_mode_on: patch.game_mode_on ?? cur.game_mode_on,
|
|
73
|
+
theme: patch.theme != null && isValidTheme(patch.theme) ? patch.theme : cur.theme,
|
|
74
|
+
tui_display_mode: patch.tui_display_mode ?? cur.tui_display_mode,
|
|
75
|
+
};
|
|
76
|
+
// Single-row upsert (id=1). INSERT ... ON CONFLICT(id) DO UPDATE keeps it
|
|
77
|
+
// one row whether the table is fresh or already seeded.
|
|
78
|
+
db.prepare(
|
|
79
|
+
`INSERT INTO game_state (id, game_mode_on, theme, tui_display_mode)
|
|
80
|
+
VALUES (1, ?, ?, ?)
|
|
81
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
82
|
+
game_mode_on = excluded.game_mode_on,
|
|
83
|
+
theme = excluded.theme,
|
|
84
|
+
tui_display_mode = excluded.tui_display_mode`,
|
|
85
|
+
).run(next.game_mode_on ? 1 : 0, next.theme, next.tui_display_mode);
|
|
86
|
+
return next;
|
|
87
|
+
}
|