pi-mega-compact 0.7.9 → 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/dist/extensions/dashboard-server/html.js +333 -66
- package/dist/extensions/dashboard-server/html.test.js +41 -0
- package/dist/extensions/dashboard-server/server.js +161 -1
- package/dist/extensions/dashboard-server/server.test.js +120 -0
- package/dist/extensions/dashboard-server-s32.test.js +181 -0
- package/dist/extensions/mega-compact.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +49 -0
- package/dist/extensions/mega-events/compact-handlers.js +29 -0
- package/dist/extensions/mega-game-cmds.js +106 -0
- package/dist/extensions/mega-game-cmds.test.js +113 -0
- package/dist/extensions/mega-runtime/state.js +167 -1
- package/dist/extensions/mega-runtime/state.test.js +171 -0
- package/dist/extensions/mega-runtime/widget.js +93 -20
- package/dist/extensions/mega-runtime/widget.test.js +160 -0
- 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/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/schema.js +44 -0
- package/dist/src/store/sqlite.js +3 -0
- package/extensions/dashboard-server/html.test.ts +50 -0
- package/extensions/dashboard-server/html.ts +334 -66
- package/extensions/dashboard-server/server.test.ts +131 -0
- package/extensions/dashboard-server/server.ts +148 -1
- package/extensions/dashboard-server-s32.test.ts +195 -0
- package/extensions/mega-compact.ts +3 -0
- package/extensions/mega-events/agent-handlers.ts +48 -0
- package/extensions/mega-events/compact-handlers.ts +28 -0
- package/extensions/mega-game-cmds.test.ts +137 -0
- package/extensions/mega-game-cmds.ts +122 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +170 -0
- package/extensions/mega-runtime/widget.test.ts +185 -0
- package/extensions/mega-runtime/widget.ts +119 -18
- package/package.json +1 -1
- 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/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/schema.ts +47 -0
- package/src/store/sqlite.ts +3 -0
- package/dist/extensions/dashboard-server/helpers.js +0 -37
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
- package/dist/extensions/dashboard-server/html/body-open.js +0 -23
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
- package/dist/extensions/dashboard-server/html/head-open.js +0 -16
- package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
- package/dist/extensions/dashboard-server/html/script.js +0 -259
- package/dist/extensions/dashboard-server/html/styles.js +0 -103
- package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
- package/dist/extensions/dashboard-server/html-template.js +0 -41
- package/dist/src/store/sqlite/connection.js +0 -35
- package/dist/src/store/sqlite/index-store.js +0 -167
- package/dist/src/store/sqlite/memory.js +0 -54
- package/dist/src/store/sqlite/minhash-lsh.js +0 -47
- package/dist/src/store/sqlite/sessions.js +0 -39
- package/dist/src/store/sqlite/transaction.js +0 -19
- package/dist/src/vectorStore/add.js +0 -260
- package/dist/src/vectorStore/dedup.js +0 -52
- package/dist/src/vectorStore/index.js +0 -10
- package/dist/src/vectorStore/queries.js +0 -83
- package/dist/src/vectorStore/search.js +0 -95
- package/dist/src/vectorStore/session.js +0 -19
- package/dist/src/vectorStore/store.js +0 -105
- package/dist/src/vectorStore/types.js +0 -6
- package/dist/src/vectorStore/utils.js +0 -23
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scoring.test.ts — S33 pure scoring helpers. Pi-agnostic, no I/O.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { turnLevel, isMegaCache, cacheScore, evaluateAchievements, maxCompactCount, ACHIEVEMENT_DEFS } from "./scoring.js";
|
|
7
|
+
describe("scoring (S33)", () => {
|
|
8
|
+
it("turnLevel: floor(log2(n+1))+1 sequence", () => {
|
|
9
|
+
assert.equal(turnLevel(0), 1);
|
|
10
|
+
assert.equal(turnLevel(1), 2);
|
|
11
|
+
assert.equal(turnLevel(2), 2);
|
|
12
|
+
assert.equal(turnLevel(3), 3);
|
|
13
|
+
assert.equal(turnLevel(4), 3);
|
|
14
|
+
assert.equal(turnLevel(7), 4);
|
|
15
|
+
assert.equal(turnLevel(15), 5);
|
|
16
|
+
// defensive: non-finite / negative inputs collapse to level 1
|
|
17
|
+
assert.equal(turnLevel(-1), 1);
|
|
18
|
+
assert.equal(turnLevel(NaN), 1);
|
|
19
|
+
});
|
|
20
|
+
it("isMegaCache: 100% is the boundary", () => {
|
|
21
|
+
assert.equal(isMegaCache(99.9), false);
|
|
22
|
+
assert.equal(isMegaCache(100), true);
|
|
23
|
+
assert.equal(isMegaCache(150), true);
|
|
24
|
+
assert.equal(isMegaCache(0), false);
|
|
25
|
+
assert.equal(isMegaCache(NaN), false);
|
|
26
|
+
});
|
|
27
|
+
it("cacheScore: 0 lookups -> 0 (not NaN), else hits/lookups*100", () => {
|
|
28
|
+
assert.equal(cacheScore(0, 0), 0);
|
|
29
|
+
assert.equal(cacheScore(7, 10), 70);
|
|
30
|
+
assert.equal(cacheScore(0, 5), 0);
|
|
31
|
+
assert.equal(cacheScore(10, 10), 100);
|
|
32
|
+
// non-finite inputs are guarded (no NaN leaks)
|
|
33
|
+
assert.equal(cacheScore(1, NaN), 0);
|
|
34
|
+
assert.equal(cacheScore(NaN, 5), 0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
function achRow(value, opts = {}) {
|
|
38
|
+
return { repo_root: "/repo/a", value, ts: opts.ts ?? 1_700_000_000_000, meta: opts.meta ?? null };
|
|
39
|
+
}
|
|
40
|
+
describe("evaluateAchievements (S35)", () => {
|
|
41
|
+
const empty = { dedupe: [], turns: [], cache: [], megaCache: [], reposCount: 0 };
|
|
42
|
+
it("ACHIEVEMENT_DEFS: 9 achievements, exactly 1 hidden", () => {
|
|
43
|
+
assert.equal(ACHIEVEMENT_DEFS.length, 9);
|
|
44
|
+
assert.equal(ACHIEVEMENT_DEFS.filter((d) => d.hidden).length, 1);
|
|
45
|
+
});
|
|
46
|
+
it("first_compact: >=1 dedupe row", () => {
|
|
47
|
+
const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, []);
|
|
48
|
+
assert.ok(r.unlocked.includes("first_compact"));
|
|
49
|
+
assert.ok(r.newlyUnlocked.includes("first_compact"));
|
|
50
|
+
});
|
|
51
|
+
it("compact_streak: max meta.compactCount >= 5", () => {
|
|
52
|
+
const r = evaluateAchievements({ ...empty, dedupe: [achRow(1, { meta: { compactCount: 5 } })] }, []);
|
|
53
|
+
assert.ok(r.unlocked.includes("compact_streak"));
|
|
54
|
+
});
|
|
55
|
+
it("turn_veteran: max turns >= 25", () => {
|
|
56
|
+
const r = evaluateAchievements({ ...empty, turns: [achRow(25)] }, []);
|
|
57
|
+
assert.ok(r.unlocked.includes("turn_veteran"));
|
|
58
|
+
});
|
|
59
|
+
it("level_five: turnLevel(maxTurns) >= 5 (15 turns)", () => {
|
|
60
|
+
const r = evaluateAchievements({ ...empty, turns: [achRow(15)] }, []);
|
|
61
|
+
assert.ok(r.unlocked.includes("level_five"));
|
|
62
|
+
});
|
|
63
|
+
it("dedupe_master: sum dedupe >= 100", () => {
|
|
64
|
+
const r = evaluateAchievements({ ...empty, dedupe: [achRow(60), achRow(40)] }, []);
|
|
65
|
+
assert.ok(r.unlocked.includes("dedupe_master"));
|
|
66
|
+
});
|
|
67
|
+
it("repo_explorer: reposCount >= 3", () => {
|
|
68
|
+
const r = evaluateAchievements({ ...empty, reposCount: 3 }, []);
|
|
69
|
+
assert.ok(r.unlocked.includes("repo_explorer"));
|
|
70
|
+
});
|
|
71
|
+
it("night_owl: a row whose local hour is in [0,5)", () => {
|
|
72
|
+
const d = new Date();
|
|
73
|
+
d.setHours(2, 0, 0, 0);
|
|
74
|
+
const r = evaluateAchievements({ ...empty, cache: [achRow(50, { ts: d.getTime() })] }, []);
|
|
75
|
+
assert.ok(r.unlocked.includes("night_owl"));
|
|
76
|
+
});
|
|
77
|
+
it("flawless: maxCache >= 100 AND no mega_cache overshoot", () => {
|
|
78
|
+
const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [] }, []);
|
|
79
|
+
assert.ok(r.unlocked.includes("flawless"));
|
|
80
|
+
});
|
|
81
|
+
it("flawless NOT when a mega_cache overshoot row (>100) exists", () => {
|
|
82
|
+
const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [achRow(101)] }, []);
|
|
83
|
+
assert.ok(!r.unlocked.includes("flawless"));
|
|
84
|
+
});
|
|
85
|
+
it("opie_wild_ride: any mega_cache trophy row exists", () => {
|
|
86
|
+
const r = evaluateAchievements({ ...empty, megaCache: [achRow(100)] }, []);
|
|
87
|
+
assert.ok(r.unlocked.includes("opie_wild_ride"));
|
|
88
|
+
});
|
|
89
|
+
it("newlyUnlocked excludes ids already unlocked", () => {
|
|
90
|
+
const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, ["first_compact"]);
|
|
91
|
+
assert.ok(r.unlocked.includes("first_compact"));
|
|
92
|
+
assert.ok(!r.newlyUnlocked.includes("first_compact"));
|
|
93
|
+
});
|
|
94
|
+
it("maxCompactCount: 0 when meta absent, else the max", () => {
|
|
95
|
+
assert.equal(maxCompactCount([achRow(1)]), 0);
|
|
96
|
+
assert.equal(maxCompactCount([achRow(1, { meta: { compactCount: 7 } })]), 7);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-achievements.ts — `game_achievements` table accessors (S35).
|
|
3
|
+
*
|
|
4
|
+
* Per-achievement unlock state. One row per of the 9 seeded achievements
|
|
5
|
+
* (seeded idempotently in schema.ts on first open). `unlocked_at` is set
|
|
6
|
+
* once when the condition first holds; hidden achievements stay invisible
|
|
7
|
+
* (no tile, no teaser) until unlocked_at IS NOT NULL (the Opie invariant).
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
10
|
+
* PREVENT-002: all SQL parameterized (? placeholders, no concat of user data).
|
|
11
|
+
* Pi-agnostic: no pi runtime types (mirrors game-scores.ts / game-state.ts).
|
|
12
|
+
*/
|
|
13
|
+
import { getStateDir } from "../../store.js";
|
|
14
|
+
import { openStore } from "./utils.js";
|
|
15
|
+
import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
|
|
16
|
+
/** The fixed set of valid achievement ids (mirrors ACHIEVEMENT_DEFS). */
|
|
17
|
+
const VALID_IDS = new Set(ACHIEVEMENT_DEFS.map((d) => d.id));
|
|
18
|
+
/** Coerce a raw DB row into the typed AchievementRow shape. */
|
|
19
|
+
function toAchievementRow(r) {
|
|
20
|
+
return {
|
|
21
|
+
id: r.id,
|
|
22
|
+
title: r.title,
|
|
23
|
+
description: r.description,
|
|
24
|
+
hidden: r.hidden,
|
|
25
|
+
icon: r.icon,
|
|
26
|
+
unlocked_at: r.unlocked_at,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* List all achievement rows, ordered so hidden+locked (the Opie easter egg
|
|
31
|
+
* before it triggers) sort LAST and visible rows sort by id ASC. Parameterized
|
|
32
|
+
* n/a (no user input reaches this SQL — PREVENT-002 safe).
|
|
33
|
+
*/
|
|
34
|
+
export function listAchievements(stateDir = getStateDir()) {
|
|
35
|
+
const db = openStore(stateDir);
|
|
36
|
+
const rows = db
|
|
37
|
+
.prepare(`SELECT id, title, description, hidden, icon, unlocked_at
|
|
38
|
+
FROM game_achievements
|
|
39
|
+
ORDER BY (hidden=1 AND unlocked_at IS NULL) DESC, id ASC`)
|
|
40
|
+
.all();
|
|
41
|
+
return rows.map(toAchievementRow);
|
|
42
|
+
}
|
|
43
|
+
/** Get a single achievement row by id (parameterized). Returns null if absent. */
|
|
44
|
+
export function getAchievement(stateDir = getStateDir(), id) {
|
|
45
|
+
const db = openStore(stateDir);
|
|
46
|
+
const r = db
|
|
47
|
+
.prepare(`SELECT id, title, description, hidden, icon, unlocked_at
|
|
48
|
+
FROM game_achievements WHERE id = ?`)
|
|
49
|
+
.get(id);
|
|
50
|
+
return r ? toAchievementRow(r) : null;
|
|
51
|
+
}
|
|
52
|
+
/** True if the achievement has been unlocked (unlocked_at IS NOT NULL). */
|
|
53
|
+
export function isUnlocked(stateDir = getStateDir(), id) {
|
|
54
|
+
const db = openStore(stateDir);
|
|
55
|
+
const row = db
|
|
56
|
+
.prepare(`SELECT unlocked_at FROM game_achievements WHERE id = ?`)
|
|
57
|
+
.get(id);
|
|
58
|
+
return row?.unlocked_at != null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Unlock an achievement (set unlocked_at = now) only if it is currently
|
|
62
|
+
* locked (unlocked_at IS NULL). Returns true if this call newly unlocked it,
|
|
63
|
+
* false if it was already unlocked or the id is unknown. Throws on an
|
|
64
|
+
* unknown id (mirrors recordScore's validation discipline).
|
|
65
|
+
*/
|
|
66
|
+
export function unlockAchievement(stateDir = getStateDir(), id) {
|
|
67
|
+
if (!VALID_IDS.has(id)) {
|
|
68
|
+
throw new Error(`unlockAchievement: unknown achievement id ${String(id)}`);
|
|
69
|
+
}
|
|
70
|
+
const db = openStore(stateDir);
|
|
71
|
+
const res = db
|
|
72
|
+
.prepare(`UPDATE game_achievements SET unlocked_at = ? WHERE id = ? AND unlocked_at IS NULL`)
|
|
73
|
+
.run(Date.now(), id);
|
|
74
|
+
return res.changes > 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Orchestrator: evaluate all achievements against the live leaderboards and
|
|
78
|
+
* persist any newly-unlocked ones. Non-pure (SQLite I/O) but best-effort +
|
|
79
|
+
* non-fatal (G6) — returns [] on any failure. Returns the TITLES (not ids)
|
|
80
|
+
* of the newly-unlocked achievements so callers can fire a one-time toast.
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateAndUnlockAchievements(stateDir = getStateDir()) {
|
|
83
|
+
try {
|
|
84
|
+
const dedupe = leaderboard(stateDir, "dedupe");
|
|
85
|
+
const turns = leaderboard(stateDir, "turns");
|
|
86
|
+
const cache = leaderboard(stateDir, "cache");
|
|
87
|
+
const megaCache = leaderboard(stateDir, "mega_cache");
|
|
88
|
+
const reposCount = leaderboard(stateDir, "repos")[0]?.value ?? 0;
|
|
89
|
+
const alreadyUnlocked = listAchievements(stateDir)
|
|
90
|
+
.filter((r) => r.unlocked_at != null)
|
|
91
|
+
.map((r) => r.id);
|
|
92
|
+
const { newlyUnlocked } = evaluateAchievements({ dedupe, turns, cache, megaCache, reposCount }, alreadyUnlocked);
|
|
93
|
+
const titles = [];
|
|
94
|
+
for (const id of newlyUnlocked) {
|
|
95
|
+
const ok = unlockAchievement(stateDir, id);
|
|
96
|
+
if (ok) {
|
|
97
|
+
const def = ACHIEVEMENT_DEFS.find((d) => d.id === id);
|
|
98
|
+
if (def)
|
|
99
|
+
titles.push(def.title);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return titles;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// leaderboard is imported lazily-style at top to avoid a circular init: it lives in
|
|
109
|
+
// game-scores.ts (same submodule layer) and is parameterized (PREVENT-002).
|
|
110
|
+
import { leaderboard } from "./game-scores.js";
|
|
111
|
+
import { evaluateAchievements } from "../../game/scoring.js";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* game-achievements.test.ts — S35 game_achievements table + accessors.
|
|
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 { listAchievements, getAchievement, isUnlocked, unlockAchievement, } from "./game-achievements.js";
|
|
12
|
+
describe("game-achievements (S35)", () => {
|
|
13
|
+
let dir;
|
|
14
|
+
before(() => {
|
|
15
|
+
dir = mkdtempSync(join(tmpdir(), "mc-ach-"));
|
|
16
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
17
|
+
});
|
|
18
|
+
after(() => {
|
|
19
|
+
closeStore(dir);
|
|
20
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it("seeds exactly 9 rows on first open (idempotent)", () => {
|
|
24
|
+
const rows = listAchievements(dir);
|
|
25
|
+
assert.equal(rows.length, 9);
|
|
26
|
+
// re-open: seeding is idempotent, still 9, unlocked_at still null
|
|
27
|
+
const again = listAchievements(dir);
|
|
28
|
+
assert.equal(again.length, 9);
|
|
29
|
+
assert.ok(again.every((r) => r.unlocked_at == null));
|
|
30
|
+
});
|
|
31
|
+
it("ordering: visible rows sorted by id asc; opie hidden+locked present", () => {
|
|
32
|
+
const rows = listAchievements(dir);
|
|
33
|
+
const opie = rows.find((r) => r.id === "opie_wild_ride");
|
|
34
|
+
assert.ok(opie, "opie_wild_ride present");
|
|
35
|
+
assert.equal(opie.hidden, 1);
|
|
36
|
+
assert.equal(opie.unlocked_at, null);
|
|
37
|
+
const visible = rows.filter((r) => !(r.hidden === 1 && r.unlocked_at == null));
|
|
38
|
+
const ids = visible.map((r) => r.id);
|
|
39
|
+
const sorted = [...ids].sort();
|
|
40
|
+
assert.deepEqual(ids, sorted, "visible rows ordered by id asc");
|
|
41
|
+
});
|
|
42
|
+
it("getAchievement returns a row by id, null for unknown", () => {
|
|
43
|
+
const r = getAchievement(dir, "first_compact");
|
|
44
|
+
assert.ok(r);
|
|
45
|
+
assert.equal(r.title, "First Compact");
|
|
46
|
+
assert.equal(getAchievement(dir, "nope"), null);
|
|
47
|
+
});
|
|
48
|
+
it("isUnlocked false before unlock, true after", () => {
|
|
49
|
+
assert.equal(isUnlocked(dir, "first_compact"), false);
|
|
50
|
+
assert.equal(unlockAchievement(dir, "first_compact"), true);
|
|
51
|
+
assert.equal(isUnlocked(dir, "first_compact"), true);
|
|
52
|
+
});
|
|
53
|
+
it("unlock is idempotent (second call false, unlocked_at unchanged)", () => {
|
|
54
|
+
assert.equal(unlockAchievement(dir, "turn_veteran"), true);
|
|
55
|
+
const t0 = getAchievement(dir, "turn_veteran").unlocked_at;
|
|
56
|
+
assert.equal(unlockAchievement(dir, "turn_veteran"), false);
|
|
57
|
+
assert.equal(getAchievement(dir, "turn_veteran").unlocked_at, t0);
|
|
58
|
+
});
|
|
59
|
+
it("unlockAchievement throws on unknown id", () => {
|
|
60
|
+
assert.throws(() => unlockAchievement(dir, "bogus"));
|
|
61
|
+
});
|
|
62
|
+
it("parameterizes id (no SQL injection via crafted id)", () => {
|
|
63
|
+
// The accessors bind id as a param; a hostile id is a literal lookup, never executed.
|
|
64
|
+
assert.equal(getAchievement(dir, "x'); DROP TABLE game_achievements; --"), null);
|
|
65
|
+
assert.throws(() => unlockAchievement(dir, "x'); DROP TABLE game_achievements; --"));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
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 { METRICS } from "../../game/scoring.js";
|
|
18
|
+
// Process-local monotonic sequence appended into the low 3 digits of `ts` so
|
|
19
|
+
// back-to-back inserts within the same millisecond get distinct PK values
|
|
20
|
+
// (Date.now()*1000 + seq%1000). Production turns are >1ms apart, so seq is
|
|
21
|
+
// almost always 0; this only disambiguates the burst case. Without it,
|
|
22
|
+
// OR REPLACE would drop the earlier same-ms row and break the dedupe SUM.
|
|
23
|
+
let scoreSeq = 0;
|
|
24
|
+
function nextTs() {
|
|
25
|
+
return Date.now() * 1000 + (scoreSeq++ % 1000);
|
|
26
|
+
}
|
|
27
|
+
export { METRICS } from "../../game/scoring.js";
|
|
28
|
+
/**
|
|
29
|
+
* Record one game-score event. `ts` is set to Date.now() by the store. SQL is
|
|
30
|
+
* fully parameterized (PREVENT-002); the metric is validated against the fixed
|
|
31
|
+
* allow-list. Pi-agnostic. Never throws except on a genuinely unknown metric.
|
|
32
|
+
*/
|
|
33
|
+
export function recordScore(stateDir = getStateDir(), args) {
|
|
34
|
+
if (!METRICS.includes(args.metric)) {
|
|
35
|
+
throw new Error(`recordScore: unknown game metric ${String(args.metric)}`);
|
|
36
|
+
}
|
|
37
|
+
const db = openStore(stateDir);
|
|
38
|
+
db.prepare(`INSERT INTO game_scores (repo_root, metric, ts, value, meta)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?)`).run(args.repo_root, args.metric, nextTs(), args.value, args.meta != null ? JSON.stringify(args.meta) : null);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the leaderboard for a metric:
|
|
43
|
+
* - cache/turns: latest value per repo_root, sorted desc by value.
|
|
44
|
+
* - mega_cache: the max value (trophy) per repo_root, sorted desc.
|
|
45
|
+
* - dedupe: SUM(value) per repo_root (cumulative collapses), sorted desc.
|
|
46
|
+
* - repos: COUNT(DISTINCT repo_root) — derived; returns a single synthesized
|
|
47
|
+
* row (repo_root '*') with the distinct-repo count as `value`.
|
|
48
|
+
* The metric is validated against the allow-list. Pi-agnostic.
|
|
49
|
+
*/
|
|
50
|
+
export function leaderboard(stateDir = getStateDir(), metric, opts = {}) {
|
|
51
|
+
if (!METRICS.includes(metric)) {
|
|
52
|
+
throw new Error(`leaderboard: unknown game metric ${String(metric)}`);
|
|
53
|
+
}
|
|
54
|
+
const db = openStore(stateDir);
|
|
55
|
+
const limit = Math.max(1, opts.limit ?? 10);
|
|
56
|
+
// Code-controlled filter clauses (never external input) — safe under PREVENT-002.
|
|
57
|
+
// Two variants: the dedupe query is single-table (unqualified repo_root);
|
|
58
|
+
// the cache/turns/mega_cache query self-JOINs (must qualify as g.repo_root).
|
|
59
|
+
const repoFilterDedupe = opts.repoRoot != null ? " AND repo_root = ?" : "";
|
|
60
|
+
const repoFilter = opts.repoRoot != null ? " AND g.repo_root = ?" : "";
|
|
61
|
+
const repoParam = opts.repoRoot != null ? [opts.repoRoot] : [];
|
|
62
|
+
if (metric === "repos") {
|
|
63
|
+
const row = db
|
|
64
|
+
.prepare(`SELECT COUNT(DISTINCT repo_root) AS value FROM game_scores`)
|
|
65
|
+
.get();
|
|
66
|
+
return [{ repo_root: "*", value: row.value, ts: 0, meta: null }];
|
|
67
|
+
}
|
|
68
|
+
if (metric === "dedupe") {
|
|
69
|
+
const sql = `SELECT repo_root, SUM(value) AS value, MAX(ts) AS ts, NULL AS meta
|
|
70
|
+
FROM game_scores
|
|
71
|
+
WHERE metric = ?${repoFilterDedupe}
|
|
72
|
+
GROUP BY repo_root
|
|
73
|
+
ORDER BY value DESC
|
|
74
|
+
LIMIT ?`;
|
|
75
|
+
const rows = db.prepare(sql).all(metric, ...repoParam, limit);
|
|
76
|
+
return rows.map(toLeaderboardRow);
|
|
77
|
+
}
|
|
78
|
+
// cache / turns / mega_cache: pick the qualifying row per repo_root (latest
|
|
79
|
+
// ts, or max value for mega_cache), then rank by value desc. The interpolated
|
|
80
|
+
// `key`/`col` fragments are derived from the validated metric — code constants,
|
|
81
|
+
// not user data (PREVENT-002 safe).
|
|
82
|
+
const key = metric === "mega_cache" ? "MAX(value)" : "MAX(ts)";
|
|
83
|
+
const col = metric === "mega_cache" ? "value" : "ts";
|
|
84
|
+
const sql = `SELECT g.repo_root, g.value AS value, g.ts AS ts, g.meta AS meta
|
|
85
|
+
FROM game_scores g
|
|
86
|
+
JOIN (SELECT repo_root, ${key} AS k
|
|
87
|
+
FROM game_scores WHERE metric = ? GROUP BY repo_root) m
|
|
88
|
+
ON m.repo_root = g.repo_root AND m.k = g.${col}
|
|
89
|
+
WHERE g.metric = ?${repoFilter}
|
|
90
|
+
ORDER BY g.value DESC
|
|
91
|
+
LIMIT ?`;
|
|
92
|
+
const rows = db
|
|
93
|
+
.prepare(sql)
|
|
94
|
+
.all(metric, metric, ...repoParam, limit);
|
|
95
|
+
return rows.map(toLeaderboardRow);
|
|
96
|
+
}
|
|
97
|
+
/** Helper: coerce a raw row (meta as JSON text) into a LeaderboardRow. */
|
|
98
|
+
function toLeaderboardRow(r) {
|
|
99
|
+
return {
|
|
100
|
+
repo_root: r.repo_root,
|
|
101
|
+
value: r.value,
|
|
102
|
+
ts: r.ts,
|
|
103
|
+
meta: r.meta != null ? JSON.parse(r.meta) : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { recordScore, leaderboard, METRICS, } from "./game-scores.js";
|
|
12
|
+
describe("game-scores (S33)", () => {
|
|
13
|
+
let dir;
|
|
14
|
+
before(() => {
|
|
15
|
+
dir = mkdtempSync(join(tmpdir(), "mc-gamescores-"));
|
|
16
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
17
|
+
});
|
|
18
|
+
after(() => {
|
|
19
|
+
closeStore(dir);
|
|
20
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it("records + reads back a cache score with parsed meta", () => {
|
|
24
|
+
recordScore(dir, {
|
|
25
|
+
repo_root: "/repo/a",
|
|
26
|
+
metric: "cache",
|
|
27
|
+
value: 42,
|
|
28
|
+
meta: { hits: 7, lookups: 10 },
|
|
29
|
+
});
|
|
30
|
+
const rows = leaderboard(dir, "cache", { repoRoot: "/repo/a" });
|
|
31
|
+
assert.equal(rows.length, 1);
|
|
32
|
+
assert.equal(rows[0].repo_root, "/repo/a");
|
|
33
|
+
assert.equal(rows[0].value, 42);
|
|
34
|
+
assert.deepEqual(rows[0].meta, { hits: 7, lookups: 10 });
|
|
35
|
+
});
|
|
36
|
+
it("cache leaderboard ranks by latest value per repo, descending", () => {
|
|
37
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 10 });
|
|
38
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 99 }); // latest wins
|
|
39
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 50 });
|
|
40
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 75 });
|
|
41
|
+
const rows = leaderboard(dir, "cache"); // all repos, default limit 10
|
|
42
|
+
assert.equal(rows.length, 3);
|
|
43
|
+
assert.deepEqual(rows.map((r) => [r.repo_root, r.value]), [["/repo/a", 99], ["/repo/c", 75], ["/repo/b", 50]]);
|
|
44
|
+
const a = rows.find((r) => r.repo_root === "/repo/a");
|
|
45
|
+
assert.equal(a.value, 99); // not the older 10
|
|
46
|
+
});
|
|
47
|
+
it("turns leaderboard uses the latest value per repo", () => {
|
|
48
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "turns", value: 3 });
|
|
49
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "turns", value: 8 });
|
|
50
|
+
const rows = leaderboard(dir, "turns", { repoRoot: "/repo/a" });
|
|
51
|
+
assert.equal(rows.length, 1);
|
|
52
|
+
assert.equal(rows[0].value, 8);
|
|
53
|
+
});
|
|
54
|
+
it("dedupe leaderboard sums collapses per repo, descending", () => {
|
|
55
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "dedupe", value: 2 });
|
|
56
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "dedupe", value: 3 });
|
|
57
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "dedupe", value: 10 });
|
|
58
|
+
const rows = leaderboard(dir, "dedupe");
|
|
59
|
+
assert.equal(rows.length, 2);
|
|
60
|
+
assert.deepEqual(rows.map((r) => [r.repo_root, r.value]), [["/repo/b", 10], ["/repo/a", 5]]);
|
|
61
|
+
});
|
|
62
|
+
it("mega_cache leaderboard returns the max trophy per repo", () => {
|
|
63
|
+
recordScore(dir, {
|
|
64
|
+
repo_root: "/repo/a",
|
|
65
|
+
metric: "mega_cache",
|
|
66
|
+
value: 120,
|
|
67
|
+
meta: { peakPct: 120 },
|
|
68
|
+
});
|
|
69
|
+
recordScore(dir, {
|
|
70
|
+
repo_root: "/repo/a",
|
|
71
|
+
metric: "mega_cache",
|
|
72
|
+
value: 150,
|
|
73
|
+
meta: { peakPct: 150 },
|
|
74
|
+
});
|
|
75
|
+
const rows = leaderboard(dir, "mega_cache", { repoRoot: "/repo/a" });
|
|
76
|
+
assert.equal(rows.length, 1);
|
|
77
|
+
assert.equal(rows[0].value, 150);
|
|
78
|
+
assert.deepEqual(rows[0].meta, { peakPct: 150 });
|
|
79
|
+
});
|
|
80
|
+
it("repos leaderboard is the derived COUNT(DISTINCT repo_root)", () => {
|
|
81
|
+
recordScore(dir, { repo_root: "/repo/a", metric: "cache", value: 1 });
|
|
82
|
+
recordScore(dir, { repo_root: "/repo/b", metric: "cache", value: 1 });
|
|
83
|
+
recordScore(dir, { repo_root: "/repo/c", metric: "cache", value: 1 });
|
|
84
|
+
const rows = leaderboard(dir, "repos");
|
|
85
|
+
assert.equal(rows.length, 1);
|
|
86
|
+
assert.equal(rows[0].value, 3);
|
|
87
|
+
});
|
|
88
|
+
it("parameterizes repo_root (no SQL injection via repo path)", () => {
|
|
89
|
+
const evil = "x'); DROP TABLE game_scores; --";
|
|
90
|
+
recordScore(dir, { repo_root: evil, metric: "cache", value: 5 });
|
|
91
|
+
const rows = leaderboard(dir, "cache", { repoRoot: evil });
|
|
92
|
+
assert.equal(rows.length, 1);
|
|
93
|
+
assert.equal(rows[0].repo_root, evil);
|
|
94
|
+
// the table is intact: a normal insert still works
|
|
95
|
+
recordScore(dir, { repo_root: "/repo/z", metric: "cache", value: 1 });
|
|
96
|
+
assert.equal(leaderboard(dir, "cache", { repoRoot: "/repo/z" }).length, 1);
|
|
97
|
+
});
|
|
98
|
+
it("rejects an unknown metric", () => {
|
|
99
|
+
const bad = "bogus";
|
|
100
|
+
assert.throws(() => recordScore(dir, { repo_root: "/r", metric: bad, value: 1 }));
|
|
101
|
+
assert.throws(() => leaderboard(dir, bad));
|
|
102
|
+
});
|
|
103
|
+
it("METRICS allow-list matches the schema CHECK set", () => {
|
|
104
|
+
assert.deepEqual([...METRICS].sort(), ["cache", "dedupe", "mega_cache", "repos", "turns"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getStateDir } from "../../store.js";
|
|
2
|
+
import { openStore } from "./utils.js";
|
|
3
|
+
import { DEFAULT_THEME, isValidTheme } from "../../config/themes.js";
|
|
4
|
+
export const DEFAULT_GAME_STATE = {
|
|
5
|
+
game_mode_on: false,
|
|
6
|
+
theme: DEFAULT_THEME,
|
|
7
|
+
tui_display_mode: "full",
|
|
8
|
+
};
|
|
9
|
+
/** Read the global game-mode state. Returns DEFAULT_GAME_STATE if the row is
|
|
10
|
+
* absent (fresh install) or any column is unexpectedly null/invalid — never
|
|
11
|
+
* throws, so the widget / command can call it on every render safely. */
|
|
12
|
+
export function getGameState(stateDir = getStateDir()) {
|
|
13
|
+
const db = openStore(stateDir);
|
|
14
|
+
return getGameStateRow(db);
|
|
15
|
+
}
|
|
16
|
+
/** Read the game state from an already-open connection (used by callers that
|
|
17
|
+
* hold a db handle, e.g. within a transaction). Same defensive fallback. */
|
|
18
|
+
export function getGameStateRow(db) {
|
|
19
|
+
const row = db.prepare("SELECT game_mode_on, theme, tui_display_mode FROM game_state WHERE id = 1").get();
|
|
20
|
+
if (!row)
|
|
21
|
+
return { ...DEFAULT_GAME_STATE };
|
|
22
|
+
const mode = row.tui_display_mode === "minimal" ? "minimal" : "full";
|
|
23
|
+
return {
|
|
24
|
+
game_mode_on: row.game_mode_on === 1,
|
|
25
|
+
theme: isValidTheme(row.theme) ? row.theme : DEFAULT_THEME,
|
|
26
|
+
tui_display_mode: mode,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Upsert the game state row, applying `patch` on top of the current values.
|
|
30
|
+
* Validates `theme` (keeps previous on an unknown id) and `tui_display_mode`
|
|
31
|
+
* (coerces to 'full' on anything but 'minimal'). Parameterized (PREVENT-002).
|
|
32
|
+
* Returns the post-write state. */
|
|
33
|
+
export function setGameState(patch, stateDir = getStateDir()) {
|
|
34
|
+
const db = openStore(stateDir);
|
|
35
|
+
return setGameStateRow(db, patch);
|
|
36
|
+
}
|
|
37
|
+
/** Upsert on an already-open connection (for transactional callers). */
|
|
38
|
+
export function setGameStateRow(db, patch) {
|
|
39
|
+
const cur = getGameStateRow(db);
|
|
40
|
+
const next = {
|
|
41
|
+
game_mode_on: patch.game_mode_on ?? cur.game_mode_on,
|
|
42
|
+
theme: patch.theme != null && isValidTheme(patch.theme) ? patch.theme : cur.theme,
|
|
43
|
+
tui_display_mode: patch.tui_display_mode ?? cur.tui_display_mode,
|
|
44
|
+
};
|
|
45
|
+
// Single-row upsert (id=1). INSERT ... ON CONFLICT(id) DO UPDATE keeps it
|
|
46
|
+
// one row whether the table is fresh or already seeded.
|
|
47
|
+
db.prepare(`INSERT INTO game_state (id, game_mode_on, theme, tui_display_mode)
|
|
48
|
+
VALUES (1, ?, ?, ?)
|
|
49
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
50
|
+
game_mode_on = excluded.game_mode_on,
|
|
51
|
+
theme = excluded.theme,
|
|
52
|
+
tui_display_mode = excluded.tui_display_mode`).run(next.game_mode_on ? 1 : 0, next.theme, next.tui_display_mode);
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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 { getGameState, setGameState, DEFAULT_GAME_STATE, } from "./game-state.js";
|
|
12
|
+
describe("game-state (S30)", () => {
|
|
13
|
+
let dir;
|
|
14
|
+
before(() => {
|
|
15
|
+
dir = mkdtempSync(join(tmpdir(), "mc-gamestate-"));
|
|
16
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
17
|
+
});
|
|
18
|
+
after(() => {
|
|
19
|
+
closeStore(dir);
|
|
20
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
it("returns DEFAULT_GAME_STATE on a fresh store", () => {
|
|
24
|
+
const s = getGameState();
|
|
25
|
+
assert.deepEqual(s, { ...DEFAULT_GAME_STATE });
|
|
26
|
+
assert.equal(s.game_mode_on, false);
|
|
27
|
+
assert.equal(s.theme, "transparent");
|
|
28
|
+
assert.equal(s.tui_display_mode, "full");
|
|
29
|
+
});
|
|
30
|
+
it("round-trips game_mode_on", () => {
|
|
31
|
+
setGameState({ game_mode_on: true });
|
|
32
|
+
assert.equal(getGameState().game_mode_on, true);
|
|
33
|
+
setGameState({ game_mode_on: false });
|
|
34
|
+
assert.equal(getGameState().game_mode_on, false);
|
|
35
|
+
});
|
|
36
|
+
it("round-trips a valid theme", () => {
|
|
37
|
+
setGameState({ theme: "retro" });
|
|
38
|
+
assert.equal(getGameState().theme, "retro");
|
|
39
|
+
setGameState({ theme: "cyan-neon" });
|
|
40
|
+
assert.equal(getGameState().theme, "cyan-neon");
|
|
41
|
+
});
|
|
42
|
+
it("rejects an unknown theme (keeps previous)", () => {
|
|
43
|
+
setGameState({ theme: "orange-bold" });
|
|
44
|
+
setGameState({ theme: "does-not-exist" });
|
|
45
|
+
assert.equal(getGameState().theme, "orange-bold");
|
|
46
|
+
});
|
|
47
|
+
it("round-trips tui_display_mode full|minimal", () => {
|
|
48
|
+
setGameState({ tui_display_mode: "minimal" });
|
|
49
|
+
assert.equal(getGameState().tui_display_mode, "minimal");
|
|
50
|
+
setGameState({ tui_display_mode: "full" });
|
|
51
|
+
assert.equal(getGameState().tui_display_mode, "full");
|
|
52
|
+
});
|
|
53
|
+
it("persists across a reopen (survives restart)", () => {
|
|
54
|
+
setGameState({ game_mode_on: true, theme: "amber-mono", tui_display_mode: "minimal" });
|
|
55
|
+
// evict + reopen the store to prove it hit disk
|
|
56
|
+
closeStore(dir);
|
|
57
|
+
const s = getGameState();
|
|
58
|
+
assert.equal(s.game_mode_on, true);
|
|
59
|
+
assert.equal(s.theme, "amber-mono");
|
|
60
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
61
|
+
});
|
|
62
|
+
it("setGameState is a partial merge (untouched fields preserved)", () => {
|
|
63
|
+
setGameState({ game_mode_on: true, theme: "grayscale", tui_display_mode: "minimal" });
|
|
64
|
+
const s = setGameState({ game_mode_on: false });
|
|
65
|
+
assert.equal(s.game_mode_on, false);
|
|
66
|
+
assert.equal(s.theme, "grayscale");
|
|
67
|
+
assert.equal(s.tui_display_mode, "minimal");
|
|
68
|
+
});
|
|
69
|
+
it("stays a single row (no duplicate id=1)", () => {
|
|
70
|
+
setGameState({ game_mode_on: true });
|
|
71
|
+
setGameState({ game_mode_on: false });
|
|
72
|
+
setGameState({ theme: "retro" });
|
|
73
|
+
const s = getGameState();
|
|
74
|
+
assert.ok(s); // single state, last-write-wins
|
|
75
|
+
});
|
|
76
|
+
});
|