pi-mega-compact 0.7.9 → 0.8.1
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 +170 -4
- 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-events/context-handler.js +5 -3
- package/dist/extensions/mega-game-cmds.js +119 -0
- package/dist/extensions/mega-game-cmds.test.js +118 -0
- package/dist/extensions/mega-runtime/state.js +181 -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 +157 -4
- 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-events/context-handler.ts +4 -2
- package/extensions/mega-game-cmds.test.ts +143 -0
- package/extensions/mega-game-cmds.ts +142 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +184 -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,147 @@
|
|
|
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
|
+
|
|
17
|
+
/** A game_achievements row. */
|
|
18
|
+
export interface AchievementRow {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
hidden: number;
|
|
23
|
+
icon: string | null;
|
|
24
|
+
unlocked_at: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The fixed set of valid achievement ids (mirrors ACHIEVEMENT_DEFS). */
|
|
28
|
+
const VALID_IDS = new Set(ACHIEVEMENT_DEFS.map((d) => d.id));
|
|
29
|
+
|
|
30
|
+
/** Intermediate row shape returned by node:sqlite (a Record<string,SQLOutputValue>);
|
|
31
|
+
* coerced to AchievementRow via toAchievementRow. */
|
|
32
|
+
type DbAchRow = {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
hidden: number;
|
|
37
|
+
icon: string | null;
|
|
38
|
+
unlocked_at: number | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Coerce a raw DB row into the typed AchievementRow shape. */
|
|
42
|
+
function toAchievementRow(r: DbAchRow): AchievementRow {
|
|
43
|
+
return {
|
|
44
|
+
id: r.id,
|
|
45
|
+
title: r.title,
|
|
46
|
+
description: r.description,
|
|
47
|
+
hidden: r.hidden,
|
|
48
|
+
icon: r.icon,
|
|
49
|
+
unlocked_at: r.unlocked_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List all achievement rows, ordered so hidden+locked (the Opie easter egg
|
|
55
|
+
* before it triggers) sort LAST and visible rows sort by id ASC. Parameterized
|
|
56
|
+
* n/a (no user input reaches this SQL — PREVENT-002 safe).
|
|
57
|
+
*/
|
|
58
|
+
export function listAchievements(stateDir: string = getStateDir()): AchievementRow[] {
|
|
59
|
+
const db = openStore(stateDir);
|
|
60
|
+
const rows = db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT id, title, description, hidden, icon, unlocked_at
|
|
63
|
+
FROM game_achievements
|
|
64
|
+
ORDER BY (hidden=1 AND unlocked_at IS NULL) DESC, id ASC`,
|
|
65
|
+
)
|
|
66
|
+
.all() as DbAchRow[];
|
|
67
|
+
return rows.map(toAchievementRow);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Get a single achievement row by id (parameterized). Returns null if absent. */
|
|
71
|
+
export function getAchievement(stateDir: string = getStateDir(), id: string): AchievementRow | null {
|
|
72
|
+
const db = openStore(stateDir);
|
|
73
|
+
const r = db
|
|
74
|
+
.prepare(
|
|
75
|
+
`SELECT id, title, description, hidden, icon, unlocked_at
|
|
76
|
+
FROM game_achievements WHERE id = ?`,
|
|
77
|
+
)
|
|
78
|
+
.get(id) as DbAchRow | undefined;
|
|
79
|
+
return r ? toAchievementRow(r) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if the achievement has been unlocked (unlocked_at IS NOT NULL). */
|
|
83
|
+
export function isUnlocked(stateDir: string = getStateDir(), id: string): boolean {
|
|
84
|
+
const db = openStore(stateDir);
|
|
85
|
+
const row = db
|
|
86
|
+
.prepare(`SELECT unlocked_at FROM game_achievements WHERE id = ?`)
|
|
87
|
+
.get(id) as { unlocked_at: number | null } | undefined;
|
|
88
|
+
return row?.unlocked_at != null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unlock an achievement (set unlocked_at = now) only if it is currently
|
|
93
|
+
* locked (unlocked_at IS NULL). Returns true if this call newly unlocked it,
|
|
94
|
+
* false if it was already unlocked or the id is unknown. Throws on an
|
|
95
|
+
* unknown id (mirrors recordScore's validation discipline).
|
|
96
|
+
*/
|
|
97
|
+
export function unlockAchievement(stateDir: string = getStateDir(), id: string): boolean {
|
|
98
|
+
if (!VALID_IDS.has(id)) {
|
|
99
|
+
throw new Error(`unlockAchievement: unknown achievement id ${String(id)}`);
|
|
100
|
+
}
|
|
101
|
+
const db = openStore(stateDir);
|
|
102
|
+
const res = db
|
|
103
|
+
.prepare(
|
|
104
|
+
`UPDATE game_achievements SET unlocked_at = ? WHERE id = ? AND unlocked_at IS NULL`,
|
|
105
|
+
)
|
|
106
|
+
.run(Date.now(), id);
|
|
107
|
+
return res.changes > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Orchestrator: evaluate all achievements against the live leaderboards and
|
|
112
|
+
* persist any newly-unlocked ones. Non-pure (SQLite I/O) but best-effort +
|
|
113
|
+
* non-fatal (G6) — returns [] on any failure. Returns the TITLES (not ids)
|
|
114
|
+
* of the newly-unlocked achievements so callers can fire a one-time toast.
|
|
115
|
+
*/
|
|
116
|
+
export function evaluateAndUnlockAchievements(stateDir: string = getStateDir()): string[] {
|
|
117
|
+
try {
|
|
118
|
+
const dedupe = leaderboard(stateDir, "dedupe");
|
|
119
|
+
const turns = leaderboard(stateDir, "turns");
|
|
120
|
+
const cache = leaderboard(stateDir, "cache");
|
|
121
|
+
const megaCache = leaderboard(stateDir, "mega_cache");
|
|
122
|
+
const reposCount = leaderboard(stateDir, "repos")[0]?.value ?? 0;
|
|
123
|
+
const alreadyUnlocked = listAchievements(stateDir)
|
|
124
|
+
.filter((r) => r.unlocked_at != null)
|
|
125
|
+
.map((r) => r.id);
|
|
126
|
+
const { newlyUnlocked } = evaluateAchievements(
|
|
127
|
+
{ dedupe, turns, cache, megaCache, reposCount },
|
|
128
|
+
alreadyUnlocked,
|
|
129
|
+
);
|
|
130
|
+
const titles: string[] = [];
|
|
131
|
+
for (const id of newlyUnlocked) {
|
|
132
|
+
const ok = unlockAchievement(stateDir, id);
|
|
133
|
+
if (ok) {
|
|
134
|
+
const def = ACHIEVEMENT_DEFS.find((d) => d.id === id);
|
|
135
|
+
if (def) titles.push(def.title);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return titles;
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// leaderboard is imported lazily-style at top to avoid a circular init: it lives in
|
|
145
|
+
// game-scores.ts (same submodule layer) and is parameterized (PREVENT-002).
|
|
146
|
+
import { leaderboard } from "./game-scores.js";
|
|
147
|
+
import { evaluateAchievements } from "../../game/scoring.js";
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* schema.ts — table creation, migrations, `ensureColumn`, PRAGMA setup.
|
|
3
3
|
*/
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
|
|
5
6
|
|
|
6
7
|
const SCHEMA_VERSION = 2;
|
|
7
8
|
|
|
@@ -231,6 +232,41 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
231
232
|
first_seen_seq INTEGER NOT NULL,
|
|
232
233
|
created_at INTEGER NOT NULL
|
|
233
234
|
);
|
|
235
|
+
|
|
236
|
+
-- S30 game mode: global toggle state (single row, id=1). Holds the
|
|
237
|
+
-- game-mode on/off switch, the active visual-effect theme id, and the TUI
|
|
238
|
+
-- widget display mode. Global across all repos; written by /mega-game and
|
|
239
|
+
-- the dashboard settings strip (S32). Local SQLite (PREVENT-PI-004).
|
|
240
|
+
CREATE TABLE IF NOT EXISTS game_state (
|
|
241
|
+
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
242
|
+
game_mode_on INTEGER NOT NULL DEFAULT 0,
|
|
243
|
+
theme TEXT NOT NULL DEFAULT 'transparent',
|
|
244
|
+
tui_display_mode TEXT NOT NULL DEFAULT 'full'
|
|
245
|
+
CHECK(tui_display_mode IN ('full','minimal'))
|
|
246
|
+
);
|
|
247
|
+
-- S33 game mode: per-repo leaderboard metrics. One row per recorded event
|
|
248
|
+
-- (turn_end / session_compact); leaderboard() derives rankings. 'repos' is
|
|
249
|
+
-- derived (COUNT DISTINCT, never recorded). Local SQLite (PREVENT-PI-004).
|
|
250
|
+
CREATE TABLE IF NOT EXISTS game_scores (
|
|
251
|
+
repo_root TEXT NOT NULL,
|
|
252
|
+
metric TEXT NOT NULL CHECK(metric IN ('cache','dedupe','turns','repos','mega_cache')),
|
|
253
|
+
ts INTEGER NOT NULL,
|
|
254
|
+
value REAL NOT NULL,
|
|
255
|
+
meta TEXT,
|
|
256
|
+
PRIMARY KEY(repo_root, metric, ts)
|
|
257
|
+
) WITHOUT ROWID;
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_game_scores_metric_ts ON game_scores(metric, ts);
|
|
259
|
+
|
|
260
|
+
-- S35 game mode: achievements (9 rows, seeded idempotently on first open).
|
|
261
|
+
-- hidden=1 AND unlocked_at IS NULL => render NOTHING (no teaser). Local SQLite.
|
|
262
|
+
CREATE TABLE IF NOT EXISTS game_achievements (
|
|
263
|
+
id TEXT PRIMARY KEY,
|
|
264
|
+
title TEXT NOT NULL,
|
|
265
|
+
description TEXT NOT NULL,
|
|
266
|
+
hidden INTEGER NOT NULL DEFAULT 0 CHECK(hidden IN (0,1)),
|
|
267
|
+
icon TEXT,
|
|
268
|
+
unlocked_at INTEGER NULL
|
|
269
|
+
) WITHOUT ROWID;
|
|
234
270
|
`);
|
|
235
271
|
// Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
|
|
236
272
|
// pre-existing table, so new columns added to context_chunks after a store was
|
|
@@ -249,6 +285,17 @@ export function initSchema(db: DatabaseSync): void {
|
|
|
249
285
|
// S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
|
|
250
286
|
// treated as stale → flat fallback (safe).
|
|
251
287
|
ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
|
|
288
|
+
// S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
|
|
289
|
+
// NOTHING so a re-open never clobbers an already-unlocked row's
|
|
290
|
+
// unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
|
|
291
|
+
const seedAch = db.prepare(
|
|
292
|
+
`INSERT INTO game_achievements (id, title, description, hidden, icon)
|
|
293
|
+
VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`,
|
|
294
|
+
);
|
|
295
|
+
for (const d of ACHIEVEMENT_DEFS) {
|
|
296
|
+
seedAch.run(d.id, d.title, d.description, d.hidden ? 1 : 0, d.icon);
|
|
297
|
+
}
|
|
298
|
+
|
|
252
299
|
const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
|
|
253
300
|
| { value: string }
|
|
254
301
|
| undefined;
|