@fangorn-network/westmarch 0.0.1-dev

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.
@@ -0,0 +1,341 @@
1
+ // Your Steam library as a taste, without asking Steam.
2
+ //
3
+ // WHY THIS FILE IS NOT AN API CLIENT
4
+ // ----------------------------------
5
+ // The obvious way to seed a game recommender is Steam's Web API: issue a key,
6
+ // call GetOwnedGames, receive the library. It works, and it means the first
7
+ // thing this product does — before it has recommended anything — is tell Valve
8
+ // that you asked. For a recommender whose entire argument is that your taste
9
+ // stays yours, that is the wrong first move.
10
+ //
11
+ // Steam already wrote your playtime to your own disk. `localconfig.vdf`, in the
12
+ // Steam install under `userdata/<accountid>/config/`, holds one entry per app
13
+ // you have launched with `Playtime` in minutes and `LastPlayed` as a unix
14
+ // timestamp. It is on the machine of everyone who has Steam installed, it needs
15
+ // no key, no login and no network, and reading it is not a request anybody can
16
+ // log.
17
+ //
18
+ // So the consumer path is a file the reader hands over, parsed where it is
19
+ // handed over — in the tab, in this module, going nowhere. In a browser that is
20
+ // a drop target; in Node it is a path. Same parser, same mapping, and the
21
+ // deployment story is "you already have this file" rather than "create an API
22
+ // key".
23
+ //
24
+ // WHAT PLAYTIME IS ALLOWED TO MEAN
25
+ // --------------------------------
26
+ // `taste.js` takes likes and dislikes, weights them by RECENCY on a half-life,
27
+ // and infers a heading from the order. It does not take weights, and this file
28
+ // does not add any: playtime decides MEMBERSHIP, `LastPlayed` decides order.
29
+ // Sorting by hours instead would tell the kernel that a game you adored in 2021
30
+ // is where you are now, which is the one thing the half-life exists to deny.
31
+ //
32
+ // The three-way split is the part that has to be defensible, because a wrong
33
+ // dislike actively steers recommendations away from things you would like —
34
+ // `taste.js` applies GAMMA 0.6 to rejections, which is deliberately strong:
35
+ //
36
+ // played a lot → a like. You kept coming back.
37
+ // launched, bounced → a dislike. You tried it and stopped, which is the
38
+ // only negative signal a library actually contains.
39
+ // never launched → NEITHER. An unplayed game in a library is a sale, a
40
+ // bundle or a backlog, and reading it as a rejection
41
+ // would punish you for things you have not got to yet.
42
+ //
43
+ // That last distinction is why `lastPlayed` is checked separately from
44
+ // `playtime` rather than folded into one threshold.
45
+
46
+ /** Minutes of play at or above which a game counts as a like. Two hours is the
47
+ * refund window: past it you made a decision to keep playing. */
48
+ export const LIKE_MINUTES = 120;
49
+
50
+ /** Minutes below which a LAUNCHED game counts as a bounce. Twenty minutes is
51
+ * about a tutorial — long enough that "I never actually opened it" is not the
52
+ * explanation, short enough that "I kept playing" is not either. */
53
+ export const BOUNCE_MINUTES = 20;
54
+
55
+ // ── VDF ─────────────────────────────────────────────────────────────────────
56
+ // Valve's KeyValues: `"key" "value"` pairs and `"key" { … }` blocks, tab
57
+ // indented, with `//` line comments. A few hundred bytes of parser, and no
58
+ // dependency — the alternative is a package that reads a file format that has
59
+ // not changed since 2003.
60
+
61
+ /**
62
+ * VDF text → a nested plain object. Duplicate keys: last wins.
63
+ *
64
+ * Values keep their string form. Everything in this file that wants a number
65
+ * coerces at the point of use, because a VDF holds `"0"` and `""` for the same
66
+ * idea and a parser that guessed would hide that from the caller.
67
+ */
68
+ export function parseVdf(text) {
69
+ const s = String(text ?? "");
70
+ let i = 0;
71
+
72
+ const skip = () => {
73
+ for (;;) {
74
+ while (i < s.length && /\s/.test(s[i])) i++;
75
+ if (s[i] === "/" && s[i + 1] === "/") {
76
+ while (i < s.length && s[i] !== "\n") i++;
77
+ } else return;
78
+ }
79
+ };
80
+
81
+ // A token is either a quoted string (with backslash escapes) or a bare run.
82
+ const token = () => {
83
+ skip();
84
+ if (i >= s.length) return null;
85
+ const c = s[i];
86
+ if (c === "{" || c === "}") { i++; return c; }
87
+ if (c === '"') {
88
+ i++;
89
+ let out = "";
90
+ while (i < s.length && s[i] !== '"') {
91
+ if (s[i] === "\\" && i + 1 < s.length) { out += s[++i]; i++; }
92
+ else out += s[i++];
93
+ }
94
+ i++; // closing quote
95
+ return { str: out };
96
+ }
97
+ let out = "";
98
+ while (i < s.length && !/[\s{}"]/.test(s[i])) out += s[i++];
99
+ return out ? { str: out } : null;
100
+ };
101
+
102
+ const block = () => {
103
+ const obj = {};
104
+ for (;;) {
105
+ const k = token();
106
+ if (k === null || k === "}") return obj;
107
+ if (k === "{") continue; // stray brace — tolerate
108
+ const v = token();
109
+ if (v === null) return obj;
110
+ obj[k.str] = v === "{" ? block() : (typeof v === "object" ? v.str : "");
111
+ }
112
+ };
113
+
114
+ const root = {};
115
+ for (;;) {
116
+ const k = token();
117
+ if (k === null) break;
118
+ if (k === "}" || k === "{") continue;
119
+ const v = token();
120
+ if (v === null) break;
121
+ root[k.str] = v === "{" ? block() : (typeof v === "object" ? v.str : "");
122
+ }
123
+ return root;
124
+ }
125
+
126
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
127
+
128
+ /**
129
+ * Find the `apps` map anywhere in a parsed config, by SHAPE rather than by path.
130
+ *
131
+ * The documented location is UserLocalConfigStore ▸ Software ▸ Valve ▸ Steam ▸
132
+ * apps, but the casing of those segments is not stable across Steam versions
133
+ * (`valve` and `Valve` both occur in the wild, in the same install), and a
134
+ * hardcoded path that silently misses returns an empty library rather than an
135
+ * error. So: walk the tree, and take any object whose children are numeric keys
136
+ * carrying a Playtime or LastPlayed. Nothing else in the file looks like that.
137
+ */
138
+ function findApps(node, depth = 0) {
139
+ if (!node || typeof node !== "object" || depth > 8) return null;
140
+ let best = null;
141
+ for (const [key, val] of Object.entries(node)) {
142
+ if (!val || typeof val !== "object") continue;
143
+ if (key.toLowerCase() === "apps") {
144
+ const hit = Object.entries(val).filter(
145
+ ([k, v]) => /^\d+$/.test(k) && v && typeof v === "object" &&
146
+ ("Playtime" in v || "LastPlayed" in v));
147
+ // Prefer the richest such block — `sharedconfig.vdf` has an `apps`
148
+ // holding only tags, and an install with two of them should not be
149
+ // decided by key order.
150
+ if (hit.length && (!best || hit.length > best.length)) best = hit;
151
+ }
152
+ const deeper = findApps(val, depth + 1);
153
+ if (deeper && (!best || deeper.length > best.length)) best = deeper;
154
+ }
155
+ return best;
156
+ }
157
+
158
+ /**
159
+ * `localconfig.vdf` text → the library, oldest-played first.
160
+ *
161
+ * Oldest first because that is the order `taste.js` wants: it weights by
162
+ * position with the newest LAST, so handing it this array directly makes the
163
+ * half-life mean what it says. Entries Steam has recorded but never timestamped
164
+ * sort to the front, where they carry the least weight — which is also where
165
+ * something you have never launched belongs.
166
+ */
167
+ export function steamLibrary(vdfText) {
168
+ const apps = findApps(parseVdf(vdfText)) ?? [];
169
+ return apps
170
+ .map(([appid, v]) => ({
171
+ appid: Number(appid),
172
+ // Steam splits time across `Playtime` and `PlaytimeDisconnected`
173
+ // (offline sessions). Only counting the first understates anyone
174
+ // who plays on a laptop.
175
+ playtime: num(v.Playtime) + num(v.PlaytimeDisconnected),
176
+ lastPlayed: num(v.LastPlayed),
177
+ }))
178
+ .filter((g) => g.appid > 0)
179
+ .sort((a, b) => a.lastPlayed - b.lastPlayed);
180
+ }
181
+
182
+ /** How a single library entry is read. Exported so a caller can show the
183
+ * reader why a game landed where it did, and so the thresholds are testable
184
+ * without building a whole taste. */
185
+ export function verdictFor({ playtime = 0, lastPlayed = 0 } = {}) {
186
+ if (playtime >= LIKE_MINUTES) return "like";
187
+ if (lastPlayed > 0 && playtime < BOUNCE_MINUTES) return "bounce";
188
+ return "neither";
189
+ }
190
+
191
+ const defaultTitle = (r) => r?.name ?? r?.title ?? r?.id ?? "";
192
+
193
+ /**
194
+ * A library plus a corpus → the `{likes, dislikes}` `taste()` takes.
195
+ *
196
+ * Rows are matched on `appid`, which is the games corpus's declared identity
197
+ * field, coerced because a shard may carry it as a number or a string. A row's
198
+ * vector is copied straight off the row — nothing is embedded, so this is
199
+ * instant and works with no model loaded.
200
+ *
201
+ * Returns the unmatched appids too. A library is always bigger than any corpus
202
+ * (DLC, delisted games, tools, and whatever the crawl has not reached yet), and
203
+ * a seed that silently dropped half of someone's hours would be indistinguishable
204
+ * from one that worked.
205
+ */
206
+ export function seedTaste(library, rows, { title = defaultTitle } = {}) {
207
+ const byId = new Map();
208
+ for (const r of rows ?? []) {
209
+ const a = Number(r?.appid);
210
+ if (Number.isFinite(a) && a > 0 && !byId.has(a)) byId.set(a, r);
211
+ }
212
+
213
+ const likes = [], dislikes = [], unmatched = [];
214
+ let played = 0;
215
+ for (const g of library ?? []) {
216
+ const row = byId.get(g.appid);
217
+ if (!row) { unmatched.push(g); continue; }
218
+ if (g.playtime > 0) played += g.playtime;
219
+ const entry = { id: row.id ?? `steam:app:${g.appid}`, title: title(row),
220
+ vector: row.vector, appid: g.appid, playtime: g.playtime,
221
+ lastPlayed: g.lastPlayed };
222
+ const v = verdictFor(g);
223
+ if (v === "like" && row.vector) likes.push(entry);
224
+ else if (v === "bounce" && row.vector) dislikes.push(entry);
225
+ }
226
+ return {
227
+ likes, dislikes, unmatched,
228
+ matched: library ? library.length - unmatched.length : 0,
229
+ hours: Math.round(played / 60),
230
+ };
231
+ }
232
+
233
+ /** A one-line account of what a seed actually used, for the reader who is
234
+ * about to be recommended things because of it. */
235
+ export const summarize = (seed) =>
236
+ `${seed.matched} of ${seed.matched + seed.unmatched.length} games matched the corpus · ` +
237
+ `${seed.hours} hours · ${seed.likes.length} liked, ${seed.dislikes.length} bounced off`;
238
+
239
+ // ── self-check: `node consume/steam.js` ─────────────────────────────────────
240
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
241
+ // A localconfig.vdf, cut down but structurally exact — including the mixed
242
+ // casing of the Valve/valve segment that defeats a hardcoded path.
243
+ const VDF = `
244
+ "UserLocalConfigStore"
245
+ {
246
+ "Software"
247
+ {
248
+ "valve"
249
+ {
250
+ "Steam"
251
+ {
252
+ "apps"
253
+ {
254
+ "730"
255
+ {
256
+ "LastPlayed" "1788064199"
257
+ "Playtime" "4240"
258
+ }
259
+ "381210"
260
+ {
261
+ "LastPlayed" "1787000000"
262
+ "Playtime" "47500"
263
+ "PlaytimeDisconnected" "280"
264
+ }
265
+ "570"
266
+ {
267
+ "LastPlayed" "1525632471"
268
+ "Playtime" "6"
269
+ }
270
+ "12345"
271
+ {
272
+ "Playtime" "0"
273
+ }
274
+ "999999"
275
+ {
276
+ "LastPlayed" "1600000000"
277
+ "Playtime" "3000"
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }`;
284
+
285
+ const lib = steamLibrary(VDF);
286
+ if (lib.length !== 5) throw new Error(`five apps, got ${lib.length}`);
287
+ // Oldest first — the order taste.js weights by position expects.
288
+ if (lib[lib.length - 1].appid !== 730) throw new Error("newest-played must sort LAST");
289
+ if (lib[0].appid !== 12345) throw new Error("an entry with no LastPlayed sorts first, where it counts least");
290
+ const dbd = lib.find((g) => g.appid === 381210);
291
+ if (dbd.playtime !== 47500 + 280) throw new Error("offline playtime must count — it is the same hours");
292
+
293
+ // The three-way split, which is the only judgement this file makes.
294
+ if (verdictFor({ playtime: 4240, lastPlayed: 1 }) !== "like") throw new Error("70 hours is a like");
295
+ if (verdictFor({ playtime: 6, lastPlayed: 1 }) !== "bounce") throw new Error("launched and quit at 6 minutes is a bounce");
296
+ if (verdictFor({ playtime: 0, lastPlayed: 0 }) !== "neither") throw new Error("never launched is NOT a rejection");
297
+ if (verdictFor({ playtime: 60, lastPlayed: 1 }) !== "neither") throw new Error("an hour is neither — it is ambiguous and must stay so");
298
+
299
+ // Mapping onto a corpus.
300
+ const row = (appid, name, vector) => ({ id: `steam:app:${appid}`, appid, name, vector: Float32Array.from(vector) });
301
+ const rows = [row(730, "Counter-Strike 2", [1, 0]), row(381210, "Dead by Daylight", [0.9, 0.1]),
302
+ row(570, "Dota 2", [0, 1]), row(12345, "Unplayed Thing", [0, 1])];
303
+
304
+ const seed = seedTaste(lib, rows);
305
+ if (seed.likes.map((l) => l.appid).join() !== "381210,730") throw new Error(`likes wrong / mis-ordered: ${seed.likes.map((l) => l.appid)}`);
306
+ if (seed.dislikes.length !== 1 || seed.dislikes[0].appid !== 570) throw new Error("the bounce must be the only dislike");
307
+ if (seed.likes.some((l) => l.appid === 12345) || seed.dislikes.some((d) => d.appid === 12345))
308
+ throw new Error("an unplayed game must not enter the taste at all");
309
+ if (seed.matched !== 4 || seed.unmatched[0].appid !== 999999)
310
+ throw new Error("a library game absent from the corpus must be reported, not silently dropped");
311
+ if (seed.hours !== Math.round((4240 + 47780 + 6) / 60)) throw new Error(`hours wrong: ${seed.hours}`);
312
+
313
+ // A shard may carry appid as a string; a seed that missed on that would
314
+ // match nothing and look exactly like a library with no games in the corpus.
315
+ const strRows = rows.map((r) => ({ ...r, appid: String(r.appid) }));
316
+ if (seedTaste(lib, strRows).likes.length !== 2) throw new Error("appid must match across string/number");
317
+
318
+ // A row with no vector cannot enter a taste — it would be a like the kernel
319
+ // cannot average.
320
+ if (seedTaste(lib, [{ id: "x", appid: 730, name: "no vector" }]).likes.length)
321
+ throw new Error("a vectorless row must not become a like");
322
+
323
+ // …and the whole point: this seed drives taste.js unchanged.
324
+ const { taste, recommend } = await import("./taste.js");
325
+ const t = taste(seed.likes, seed.dislikes);
326
+ if (!t) throw new Error("a real library must produce a taste");
327
+ const pool = [row(1, "another shooter", [0.95, 0.05]), row(2, "another moba", [0.05, 0.95])];
328
+ if (recommend(pool, t)[0].row.name !== "another shooter")
329
+ throw new Error("a taste seeded from Steam must rank an unseen pool by it");
330
+
331
+ // Degenerate inputs return nothing rather than throwing — this parser is
332
+ // pointed at a file a stranger dropped on a page.
333
+ if (steamLibrary("").length || steamLibrary("garbage {{{").length) throw new Error("junk must parse to an empty library, not throw");
334
+ if (seedTaste([], rows).likes.length || seedTaste(null, null).matched !== 0) throw new Error("an empty library is an empty seed");
335
+ if (parseVdf('"a" "b" // trailing comment\n').a !== "b") throw new Error("comments must be skipped");
336
+ if (parseVdf('"a" "say \\"hi\\""').a !== 'say "hi"') throw new Error("escaped quotes must survive");
337
+
338
+ console.log(`steam.js self-check ok — VDF parsed by shape not path, offline hours counted, ` +
339
+ `newest-played sorts last for the half-life, unplayed is not a rejection, ` +
340
+ `${summarize(seed)}`);
341
+ }