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.
Files changed (79) hide show
  1. package/dist/extensions/dashboard-server/html.js +333 -66
  2. package/dist/extensions/dashboard-server/html.test.js +41 -0
  3. package/dist/extensions/dashboard-server/server.js +161 -1
  4. package/dist/extensions/dashboard-server/server.test.js +120 -0
  5. package/dist/extensions/dashboard-server-s32.test.js +181 -0
  6. package/dist/extensions/mega-compact.js +3 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +49 -0
  8. package/dist/extensions/mega-events/compact-handlers.js +29 -0
  9. package/dist/extensions/mega-game-cmds.js +106 -0
  10. package/dist/extensions/mega-game-cmds.test.js +113 -0
  11. package/dist/extensions/mega-runtime/state.js +167 -1
  12. package/dist/extensions/mega-runtime/state.test.js +171 -0
  13. package/dist/extensions/mega-runtime/widget.js +93 -20
  14. package/dist/extensions/mega-runtime/widget.test.js +160 -0
  15. package/dist/src/config/themes.js +84 -0
  16. package/dist/src/config/themes.test.js +94 -0
  17. package/dist/src/game/scoring.js +105 -0
  18. package/dist/src/game/scoring.test.js +98 -0
  19. package/dist/src/store/sqlite/game-achievements.js +111 -0
  20. package/dist/src/store/sqlite/game-achievements.test.js +67 -0
  21. package/dist/src/store/sqlite/game-scores.js +105 -0
  22. package/dist/src/store/sqlite/game-scores.test.js +106 -0
  23. package/dist/src/store/sqlite/game-state.js +54 -0
  24. package/dist/src/store/sqlite/game-state.test.js +76 -0
  25. package/dist/src/store/sqlite/schema.js +44 -0
  26. package/dist/src/store/sqlite.js +3 -0
  27. package/extensions/dashboard-server/html.test.ts +50 -0
  28. package/extensions/dashboard-server/html.ts +334 -66
  29. package/extensions/dashboard-server/server.test.ts +131 -0
  30. package/extensions/dashboard-server/server.ts +148 -1
  31. package/extensions/dashboard-server-s32.test.ts +195 -0
  32. package/extensions/mega-compact.ts +3 -0
  33. package/extensions/mega-events/agent-handlers.ts +48 -0
  34. package/extensions/mega-events/compact-handlers.ts +28 -0
  35. package/extensions/mega-game-cmds.test.ts +137 -0
  36. package/extensions/mega-game-cmds.ts +122 -0
  37. package/extensions/mega-runtime/state.test.ts +171 -0
  38. package/extensions/mega-runtime/state.ts +170 -0
  39. package/extensions/mega-runtime/widget.test.ts +185 -0
  40. package/extensions/mega-runtime/widget.ts +119 -18
  41. package/package.json +1 -1
  42. package/src/config/themes.test.ts +116 -0
  43. package/src/config/themes.ts +124 -0
  44. package/src/game/scoring.test.ts +103 -0
  45. package/src/game/scoring.ts +158 -0
  46. package/src/store/sqlite/game-achievements.test.ts +80 -0
  47. package/src/store/sqlite/game-achievements.ts +147 -0
  48. package/src/store/sqlite/game-scores.test.ts +132 -0
  49. package/src/store/sqlite/game-scores.ts +168 -0
  50. package/src/store/sqlite/game-state.test.ts +89 -0
  51. package/src/store/sqlite/game-state.ts +87 -0
  52. package/src/store/sqlite/schema.ts +47 -0
  53. package/src/store/sqlite.ts +3 -0
  54. package/dist/extensions/dashboard-server/helpers.js +0 -37
  55. package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
  56. package/dist/extensions/dashboard-server/html/body-open.js +0 -23
  57. package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
  58. package/dist/extensions/dashboard-server/html/head-open.js +0 -16
  59. package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
  60. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
  61. package/dist/extensions/dashboard-server/html/script.js +0 -259
  62. package/dist/extensions/dashboard-server/html/styles.js +0 -103
  63. package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
  64. package/dist/extensions/dashboard-server/html-template.js +0 -41
  65. package/dist/src/store/sqlite/connection.js +0 -35
  66. package/dist/src/store/sqlite/index-store.js +0 -167
  67. package/dist/src/store/sqlite/memory.js +0 -54
  68. package/dist/src/store/sqlite/minhash-lsh.js +0 -47
  69. package/dist/src/store/sqlite/sessions.js +0 -39
  70. package/dist/src/store/sqlite/transaction.js +0 -19
  71. package/dist/src/vectorStore/add.js +0 -260
  72. package/dist/src/vectorStore/dedup.js +0 -52
  73. package/dist/src/vectorStore/index.js +0 -10
  74. package/dist/src/vectorStore/queries.js +0 -83
  75. package/dist/src/vectorStore/search.js +0 -95
  76. package/dist/src/vectorStore/session.js +0 -19
  77. package/dist/src/vectorStore/store.js +0 -105
  78. package/dist/src/vectorStore/types.js +0 -6
  79. package/dist/src/vectorStore/utils.js +0 -23
@@ -0,0 +1,103 @@
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
+ import type { LeaderboardRow } from "../store/sqlite/game-scores.js";
8
+
9
+ describe("scoring (S33)", () => {
10
+ it("turnLevel: floor(log2(n+1))+1 sequence", () => {
11
+ assert.equal(turnLevel(0), 1);
12
+ assert.equal(turnLevel(1), 2);
13
+ assert.equal(turnLevel(2), 2);
14
+ assert.equal(turnLevel(3), 3);
15
+ assert.equal(turnLevel(4), 3);
16
+ assert.equal(turnLevel(7), 4);
17
+ assert.equal(turnLevel(15), 5);
18
+ // defensive: non-finite / negative inputs collapse to level 1
19
+ assert.equal(turnLevel(-1), 1);
20
+ assert.equal(turnLevel(NaN), 1);
21
+ });
22
+
23
+ it("isMegaCache: 100% is the boundary", () => {
24
+ assert.equal(isMegaCache(99.9), false);
25
+ assert.equal(isMegaCache(100), true);
26
+ assert.equal(isMegaCache(150), true);
27
+ assert.equal(isMegaCache(0), false);
28
+ assert.equal(isMegaCache(NaN), false);
29
+ });
30
+
31
+ it("cacheScore: 0 lookups -> 0 (not NaN), else hits/lookups*100", () => {
32
+ assert.equal(cacheScore(0, 0), 0);
33
+ assert.equal(cacheScore(7, 10), 70);
34
+ assert.equal(cacheScore(0, 5), 0);
35
+ assert.equal(cacheScore(10, 10), 100);
36
+ // non-finite inputs are guarded (no NaN leaks)
37
+ assert.equal(cacheScore(1, NaN), 0);
38
+ assert.equal(cacheScore(NaN, 5), 0);
39
+ });
40
+ });
41
+
42
+ function achRow(value: number, opts: { ts?: number; meta?: unknown } = {}): LeaderboardRow {
43
+ return { repo_root: "/repo/a", value, ts: opts.ts ?? 1_700_000_000_000, meta: opts.meta ?? null };
44
+ }
45
+
46
+ describe("evaluateAchievements (S35)", () => {
47
+ const empty = { dedupe: [] as LeaderboardRow[], turns: [] as LeaderboardRow[], cache: [] as LeaderboardRow[], megaCache: [] as LeaderboardRow[], reposCount: 0 };
48
+ it("ACHIEVEMENT_DEFS: 9 achievements, exactly 1 hidden", () => {
49
+ assert.equal(ACHIEVEMENT_DEFS.length, 9);
50
+ assert.equal(ACHIEVEMENT_DEFS.filter((d) => d.hidden).length, 1);
51
+ });
52
+ it("first_compact: >=1 dedupe row", () => {
53
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, []);
54
+ assert.ok(r.unlocked.includes("first_compact"));
55
+ assert.ok(r.newlyUnlocked.includes("first_compact"));
56
+ });
57
+ it("compact_streak: max meta.compactCount >= 5", () => {
58
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1, { meta: { compactCount: 5 } })] }, []);
59
+ assert.ok(r.unlocked.includes("compact_streak"));
60
+ });
61
+ it("turn_veteran: max turns >= 25", () => {
62
+ const r = evaluateAchievements({ ...empty, turns: [achRow(25)] }, []);
63
+ assert.ok(r.unlocked.includes("turn_veteran"));
64
+ });
65
+ it("level_five: turnLevel(maxTurns) >= 5 (15 turns)", () => {
66
+ const r = evaluateAchievements({ ...empty, turns: [achRow(15)] }, []);
67
+ assert.ok(r.unlocked.includes("level_five"));
68
+ });
69
+ it("dedupe_master: sum dedupe >= 100", () => {
70
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(60), achRow(40)] }, []);
71
+ assert.ok(r.unlocked.includes("dedupe_master"));
72
+ });
73
+ it("repo_explorer: reposCount >= 3", () => {
74
+ const r = evaluateAchievements({ ...empty, reposCount: 3 }, []);
75
+ assert.ok(r.unlocked.includes("repo_explorer"));
76
+ });
77
+ it("night_owl: a row whose local hour is in [0,5)", () => {
78
+ const d = new Date(); d.setHours(2, 0, 0, 0);
79
+ const r = evaluateAchievements({ ...empty, cache: [achRow(50, { ts: d.getTime() })] }, []);
80
+ assert.ok(r.unlocked.includes("night_owl"));
81
+ });
82
+ it("flawless: maxCache >= 100 AND no mega_cache overshoot", () => {
83
+ const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [] }, []);
84
+ assert.ok(r.unlocked.includes("flawless"));
85
+ });
86
+ it("flawless NOT when a mega_cache overshoot row (>100) exists", () => {
87
+ const r = evaluateAchievements({ ...empty, cache: [achRow(100)], megaCache: [achRow(101)] }, []);
88
+ assert.ok(!r.unlocked.includes("flawless"));
89
+ });
90
+ it("opie_wild_ride: any mega_cache trophy row exists", () => {
91
+ const r = evaluateAchievements({ ...empty, megaCache: [achRow(100)] }, []);
92
+ assert.ok(r.unlocked.includes("opie_wild_ride"));
93
+ });
94
+ it("newlyUnlocked excludes ids already unlocked", () => {
95
+ const r = evaluateAchievements({ ...empty, dedupe: [achRow(1)] }, ["first_compact"]);
96
+ assert.ok(r.unlocked.includes("first_compact"));
97
+ assert.ok(!r.newlyUnlocked.includes("first_compact"));
98
+ });
99
+ it("maxCompactCount: 0 when meta absent, else the max", () => {
100
+ assert.equal(maxCompactCount([achRow(1)]), 0);
101
+ assert.equal(maxCompactCount([achRow(1, { meta: { compactCount: 7 } })]), 7);
102
+ });
103
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * scoring.ts — pure game-mode scoring helpers (S33) + achievements eval (S35).
3
+ *
4
+ * Pi-agnostic: no pi runtime types, no I/O, no dependencies. Every helper is a
5
+ * pure function so it can be unit-tested in isolation and reused by the
6
+ * extension event handlers (src/store stays free of pi types too).
7
+ */
8
+ import type { LeaderboardRow } from "../store/sqlite/game-scores.js";
9
+
10
+ /** The set of leaderboard metrics tracked in the `game_scores` table. */
11
+ export type GameMetric = "cache" | "dedupe" | "turns" | "repos" | "mega_cache";
12
+
13
+ /** Canonical allow-list of game metrics (mirrors the schema CHECK constraint). */
14
+ export const METRICS: readonly GameMetric[] = [
15
+ "cache",
16
+ "dedupe",
17
+ "turns",
18
+ "repos",
19
+ "mega_cache",
20
+ ];
21
+
22
+ /**
23
+ * Player level as a function of completed turns.
24
+ * Sequence: n=0→1, 1→2, 2→2, 3→3, 4→3, 7→4, 15→5, ...
25
+ * i.e. floor(log2(n+1)) + 1 (one level per doubling of turns). Defensive: any
26
+ * non-finite / negative input collapses to level 1 (never throws, never NaN).
27
+ */
28
+ export function turnLevel(n: number): number {
29
+ if (!Number.isFinite(n) || n < 0) return 1;
30
+ return Math.floor(Math.log2(n + 1)) + 1;
31
+ }
32
+
33
+ /**
34
+ * MEGA CACHE trigger test: the cache hit-rate (real ratio, may exceed 100%)
35
+ * has crossed the 100% threshold. Used to award the mega_cache trophy + arm
36
+ * the widget flare (the oopsie gag).
37
+ */
38
+ export function isMegaCache(pct: number): boolean {
39
+ return Number.isFinite(pct) && pct >= 100;
40
+ }
41
+
42
+ /**
43
+ * Cache-hit percentage (0..100+). Returns 0 when there are no lookups so the
44
+ * ratio can never be NaN / Infinity (QA3). Safe against non-finite inputs.
45
+ */
46
+ export function cacheScore(hits: number, lookups: number): number {
47
+ if (!Number.isFinite(hits) || !Number.isFinite(lookups)) return 0;
48
+ return lookups > 0 ? (hits / lookups) * 100 : 0;
49
+ }
50
+
51
+ // ── S35: achievement definitions + evaluation ─────────────────────────────
52
+
53
+ /** A single achievement definition (the seed row shape, minus unlocked_at). */
54
+ export interface AchievementDef {
55
+ id: string;
56
+ title: string;
57
+ description: string;
58
+ hidden: boolean;
59
+ icon: string;
60
+ }
61
+
62
+ /**
63
+ * The 9 achievements (8 visible + 1 hidden easter egg = Opie's Wild Ride).
64
+ * Seeded idempotently into the `game_achievements` table on first open
65
+ * (schema.ts). Pure, pi-agnostic — no I/O.
66
+ */
67
+ export const ACHIEVEMENT_DEFS: readonly AchievementDef[] = [
68
+ { id: "first_compact", title: "First Compact", description: "Compact a conversation once.", hidden: false, icon: "\u{1F476}" },
69
+ { id: "compact_streak", title: "Compact Streak", description: "Compact 5 times in one session.", hidden: false, icon: "\u{1F525}" },
70
+ { id: "turn_veteran", title: "Turn Veteran", description: "Reach 25 turns in a repo.", hidden: false, icon: "\u{1F3C3}" },
71
+ { id: "level_five", title: "Level 5", description: "Reach player level 5.", hidden: false, icon: "\u{2B50}" },
72
+ { id: "dedupe_master", title: "Dedupe Master", description: "Collapse 100 chunks total.", hidden: false, icon: "\u{1F5DC}\u{FE0F}" },
73
+ { id: "repo_explorer", title: "Repo Explorer", description: "Use game mode across 3 repos.", hidden: false, icon: "\u{1F30D}" },
74
+ { id: "night_owl", title: "Night Owl", description: "Record a score after midnight (00:00–05:00 local).", hidden: false, icon: "\u{1F989}" },
75
+ { id: "flawless", title: "Flawless", description: "Hit exactly 100% cache with no overshoot.", hidden: false, icon: "\u{1F4AF}" },
76
+ { id: "opie_wild_ride", title: "Opie's Wild Ride", description: "Push the cache past 100%.", hidden: true, icon: "\u{1F3C6}" },
77
+ ];
78
+
79
+ /** Inputs to evaluateAchievements — the S33 leaderboard aggregates. */
80
+ export interface AchievementContext {
81
+ dedupe: LeaderboardRow[];
82
+ turns: LeaderboardRow[];
83
+ cache: LeaderboardRow[];
84
+ megaCache: LeaderboardRow[];
85
+ reposCount: number;
86
+ }
87
+
88
+ /** Result of evaluateAchievements. */
89
+ export interface AchievementEvalResult {
90
+ unlocked: string[];
91
+ newlyUnlocked: string[];
92
+ }
93
+
94
+ /**
95
+ * Max `compactCount` carried in a dedupe row's `meta` (S33 records
96
+ * `{ compactCount }` on each session_compact). Returns 0 when absent.
97
+ */
98
+ export function maxCompactCount(rows: LeaderboardRow[]): number {
99
+ return rows.reduce((m, r) => {
100
+ const meta = r.meta;
101
+ const cm =
102
+ meta && typeof meta === "object" && "compactCount" in meta
103
+ ? Number((meta as Record<string, unknown>).compactCount) || 0
104
+ : 0;
105
+ return Math.max(m, cm);
106
+ }, 0);
107
+ }
108
+
109
+ /**
110
+ * Pure evaluation of the 9 achievements against the leaderboard aggregates.
111
+ * `alreadyUnlocked` is the set of ids already persisted (so `newlyUnlocked`
112
+ * excludes them). No I/O, no pi types. Mirrors the S35.9 condition table.
113
+ */
114
+ export function evaluateAchievements(
115
+ ctx: AchievementContext,
116
+ alreadyUnlocked: readonly string[],
117
+ ): AchievementEvalResult {
118
+ const sumBy = (rows: LeaderboardRow[]) =>
119
+ rows.reduce((s, r) => s + (Number(r.value) || 0), 0);
120
+ const maxBy = (rows: LeaderboardRow[]) =>
121
+ rows.reduce((m, r) => Math.max(m, Number(r.value) || 0), 0);
122
+
123
+ const dedupeSum = sumBy(ctx.dedupe);
124
+ const maxTurns = maxBy(ctx.turns);
125
+ const maxCache = maxBy(ctx.cache);
126
+ const maxCompact = maxCompactCount(ctx.dedupe);
127
+
128
+ // Night owl: any row (across all metrics) recorded between 00:00–05:00 local.
129
+ const allTs = [
130
+ ...ctx.dedupe,
131
+ ...ctx.turns,
132
+ ...ctx.cache,
133
+ ...ctx.megaCache,
134
+ ];
135
+ const nightOwl = allTs.some((r) => {
136
+ const h = new Date(Number(r.ts) || 0).getHours();
137
+ return h >= 0 && h < 5;
138
+ });
139
+
140
+ // Flawless: cache hit 100%+, but NO mega_cache overshoot (>100).
141
+ const megaOvershoot = ctx.megaCache.some((r) => Number(r.value) > 100);
142
+
143
+ const conditions: Record<string, boolean> = {
144
+ first_compact: ctx.dedupe.length >= 1,
145
+ compact_streak: maxCompact >= 5,
146
+ turn_veteran: maxTurns >= 25,
147
+ level_five: turnLevel(maxTurns) >= 5,
148
+ dedupe_master: dedupeSum >= 100,
149
+ repo_explorer: ctx.reposCount >= 3,
150
+ night_owl: nightOwl,
151
+ flawless: maxCache >= 100 && !megaOvershoot,
152
+ opie_wild_ride: ctx.megaCache.length >= 1,
153
+ };
154
+
155
+ const unlocked = Object.keys(conditions).filter((id) => conditions[id]);
156
+ const newlyUnlocked = unlocked.filter((id) => !alreadyUnlocked.includes(id));
157
+ return { unlocked, newlyUnlocked };
158
+ }
@@ -0,0 +1,80 @@
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 {
12
+ listAchievements,
13
+ getAchievement,
14
+ isUnlocked,
15
+ unlockAchievement,
16
+ } from "./game-achievements.js";
17
+
18
+ describe("game-achievements (S35)", () => {
19
+ let dir: string;
20
+ before(() => {
21
+ dir = mkdtempSync(join(tmpdir(), "mc-ach-"));
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("seeds exactly 9 rows on first open (idempotent)", () => {
31
+ const rows = listAchievements(dir);
32
+ assert.equal(rows.length, 9);
33
+ // re-open: seeding is idempotent, still 9, unlocked_at still null
34
+ const again = listAchievements(dir);
35
+ assert.equal(again.length, 9);
36
+ assert.ok(again.every((r) => r.unlocked_at == null));
37
+ });
38
+
39
+ it("ordering: visible rows sorted by id asc; opie hidden+locked present", () => {
40
+ const rows = listAchievements(dir);
41
+ const opie = rows.find((r) => r.id === "opie_wild_ride");
42
+ assert.ok(opie, "opie_wild_ride present");
43
+ assert.equal(opie!.hidden, 1);
44
+ assert.equal(opie!.unlocked_at, null);
45
+ const visible = rows.filter((r) => !(r.hidden === 1 && r.unlocked_at == null));
46
+ const ids = visible.map((r) => r.id);
47
+ const sorted = [...ids].sort();
48
+ assert.deepEqual(ids, sorted, "visible rows ordered by id asc");
49
+ });
50
+
51
+ it("getAchievement returns a row by id, null for unknown", () => {
52
+ const r = getAchievement(dir, "first_compact");
53
+ assert.ok(r);
54
+ assert.equal(r!.title, "First Compact");
55
+ assert.equal(getAchievement(dir, "nope"), null);
56
+ });
57
+
58
+ it("isUnlocked false before unlock, true after", () => {
59
+ assert.equal(isUnlocked(dir, "first_compact"), false);
60
+ assert.equal(unlockAchievement(dir, "first_compact"), true);
61
+ assert.equal(isUnlocked(dir, "first_compact"), true);
62
+ });
63
+
64
+ it("unlock is idempotent (second call false, unlocked_at unchanged)", () => {
65
+ assert.equal(unlockAchievement(dir, "turn_veteran"), true);
66
+ const t0 = getAchievement(dir, "turn_veteran")!.unlocked_at;
67
+ assert.equal(unlockAchievement(dir, "turn_veteran"), false);
68
+ assert.equal(getAchievement(dir, "turn_veteran")!.unlocked_at, t0);
69
+ });
70
+
71
+ it("unlockAchievement throws on unknown id", () => {
72
+ assert.throws(() => unlockAchievement(dir, "bogus"));
73
+ });
74
+
75
+ it("parameterizes id (no SQL injection via crafted id)", () => {
76
+ // The accessors bind id as a param; a hostile id is a literal lookup, never executed.
77
+ assert.equal(getAchievement(dir, "x'); DROP TABLE game_achievements; --"), null);
78
+ assert.throws(() => unlockAchievement(dir, "x'); DROP TABLE game_achievements; --"));
79
+ });
80
+ });
@@ -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
+ });