Tier 2: the Trainspotter's Notebook — real records from discogs_full

assets/extract_tunes.py pulls 523 canon tunes (repress-count ranked, club pool
12"-only) into public/tunes.json across shop/fair/club genre pools; top ~8% of
each chunk flagged grail ("CERTIFIED CLASSIC", gold flash, 2000 pts). Level
clear reveals a real record (artist — title, label, year), the announcer speaks
it, and first IDs land in a persistent localStorage notebook (+1000). Title
screen shows notebook progress. Also: overlay/sub text got backgrounds for
readability, and prompt() is guarded for webviews that lack it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-16 17:17:34 +10:00
parent d903dd57ca
commit a1f4588deb
3 changed files with 171 additions and 9 deletions

92
assets/extract_tunes.py Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Extract the trainspotting tune pools from local discogs_full -> public/tunes.json.
Canon = most-pressed masters (repress count across 10.4M releases = mass-owned classic).
The club pool additionally requires the main release to be a 12" — keeps it club wax,
not Madonna albums tagged House. Grail flag = top ~8% of each chunk (certified classics,
gold flash in-game). Null label renders as "White Label", which is exactly right.
Read-only against discogs_full (canonical). release.master_id is indexed; each chunk
query returns in ~1s.
python3 assets/extract_tunes.py
"""
import json
import subprocess
from pathlib import Path
PSQL = "/opt/homebrew/opt/postgresql@16/bin/psql"
OUT = Path(__file__).resolve().parent.parent / "public" / "tunes.json"
# pool -> chunks of (tag, is_genre, year_lo, year_hi, n, twelve_inch_only)
POOLS = {
"shop": [ # RECORD SHOP: disco / soul / funk
("Disco", False, 1968, 1989, 70, False),
("Soul", False, 1962, 1985, 70, False),
("Funk", False, 1968, 1985, 70, False),
],
"fair": [ # RECORD FAIR: hip hop / jazz-funk
("Hip Hop", True, 1979, 2005, 100, False),
("Jazz-Funk", False, 1968, 1995, 70, False),
],
"club": [ # CLUB NIGHT: house / techno / jungle, 12"s only
("House", False, 1985, 2005, 70, True),
("Techno", False, 1985, 2005, 70, True),
("Jungle", False, 1990, 2000, 50, True),
],
}
GRAIL_FRACTION = 0.08 # top of each ranked chunk = certified classic
def q(sql):
r = subprocess.run([PSQL, "-d", "discogs_full", "--csv", "-t", "-c", sql],
capture_output=True, text=True, check=True)
import csv, io
return list(csv.reader(io.StringIO(r.stdout)))
def fetch(tag, is_genre, ylo, yhi, limit, twelve):
join = ("JOIN master_genre mg ON mg.master_id=m.id AND mg.genre=" if is_genre
else "JOIN master_style ms ON ms.master_id=m.id AND ms.style=")
twelve_sql = ("""AND EXISTS (SELECT 1 FROM release_format rf
WHERE rf.release_id=m.main_release AND rf.descriptions LIKE '%12"%')"""
if twelve else "")
rows = q(f"""
SELECT m.title, m.year, COUNT(r.id) AS presses,
(SELECT ma.artist_name FROM master_artist ma WHERE ma.master_id=m.id
ORDER BY ma.position NULLS LAST LIMIT 1),
(SELECT rl.label_name FROM release_label rl WHERE rl.release_id=m.main_release LIMIT 1)
FROM master m {join}'{tag}'
JOIN release r ON r.master_id=m.id
WHERE m.year BETWEEN {ylo} AND {yhi} {twelve_sql}
GROUP BY m.id ORDER BY presses DESC LIMIT {limit}""")
grails = max(1, int(len(rows) * GRAIL_FRACTION))
out = []
for i, r in enumerate(rows):
if len(r) < 5 or not r[0] or not r[3]:
continue
out.append({
"a": r[3], "t": r[0],
"l": r[4] or "White Label",
"y": int(r[1]) if r[1] else None,
**({"g": 1} if i < grails else {}),
})
return out
pools = {}
for pool, chunks in POOLS.items():
seen = set()
tunes = []
for chunk in chunks:
for tune in fetch(*chunk):
key = (tune["a"].lower(), tune["t"].lower())
if key in seen:
continue
seen.add(key)
tunes.append(tune)
pools[pool] = tunes
print(f"{pool}: {len(tunes)} tunes ({sum(1 for t in tunes if t.get('g'))} grails)")
OUT.write_text(json.dumps(pools, ensure_ascii=False, separators=(",", ":")))
print(f"wrote {OUT} ({OUT.stat().st_size // 1024}KB, {sum(len(v) for v in pools.values())} total)")

1
public/tunes.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -45,10 +45,13 @@ const ENEMY_TYPES = {
// Level themes cycle with level index; mix = weighted generator spawn table.
const THEMES = [
{ name: 'RECORD SHOP', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
{ name: 'RECORD FAIR', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
{ name: 'CLUB NIGHT', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
{ name: 'RECORD SHOP', pool: 'shop', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
{ name: 'RECORD FAIR', pool: 'fair', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
{ name: 'CLUB NIGHT', pool: 'club', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
];
const ID_SCORE = 100; // re-identifying a tune you already know
const FIRST_ID_SCORE = 1000; // new notebook entry
const GRAIL_ID_SCORE = 2000; // first ID of a certified classic
export default class GameScene extends Phaser.Scene {
constructor() {
@ -59,6 +62,7 @@ export default class GameScene extends Phaser.Scene {
// BASE_URL makes assets resolve under the deploy subpath (partly.party/vinylgauntlet/)
const base = import.meta.env.BASE_URL;
for (const key of TEXTURE_KEYS) this.load.image(key, `${base}sprites/${key}.png`);
this.load.json('tunes', `${base}tunes.json`); // real records from discogs_full
}
create() {
@ -115,6 +119,8 @@ export default class GameScene extends Phaser.Scene {
fontSize: '40px',
color: '#ff00ff',
align: 'center',
backgroundColor: '#000000c0',
padding: { x: 16, y: 8 },
})
.setOrigin(0.5)
.setScrollFactor(0)
@ -125,6 +131,8 @@ export default class GameScene extends Phaser.Scene {
fontSize: '20px',
color: '#ffffff',
align: 'center',
backgroundColor: '#000000c0',
padding: { x: 14, y: 8 },
})
.setOrigin(0.5)
.setScrollFactor(0)
@ -631,6 +639,43 @@ export default class GameScene extends Phaser.Scene {
else if (this.vibe < 500) this.say(LINES.lowVibe);
}
// --- the Trainspotter's Notebook (localStorage) ---
tuneKey(tune) {
return `${tune.a}|${tune.t}`;
}
loadNotebook() {
try {
return new Set(JSON.parse(localStorage.getItem('vg_notebook')) || []);
} catch {
return new Set();
}
}
notebookTotals() {
const pools = this.cache.json.get('tunes');
if (!pools) return null;
const total = Object.values(pools).reduce((n, p) => n + p.length, 0);
return { spotted: this.loadNotebook().size, total };
}
// Pick a tune from this theme's pool, preferring ones not yet in the notebook.
spotTune() {
const pools = this.cache.json.get('tunes');
const pool = pools && pools[this.theme.pool];
if (!pool || !pool.length) return null;
const notebook = this.loadNotebook();
const fresh = pool.filter((t) => !notebook.has(this.tuneKey(t)));
const pick = Phaser.Utils.Array.GetRandom(fresh.length ? fresh : pool);
const first = !notebook.has(this.tuneKey(pick));
if (first) {
notebook.add(this.tuneKey(pick));
localStorage.setItem('vg_notebook', JSON.stringify([...notebook]));
}
return { ...pick, first };
}
levelClear() {
if (this.state !== 'PLAYING') return;
this.state = 'LEVEL_CLEAR';
@ -639,10 +684,25 @@ export default class GameScene extends Phaser.Scene {
this.barBg.setVisible(false);
this.barFill.setVisible(false);
const next = (this.levelIndex + 1) % LEVELS.length;
this.showOverlay('TUNE IDENTIFIED');
this.sub.setText(`next: ${THEMES[next % THEMES.length].name}`).setVisible(true);
this.say(LINES.levelClear);
this.time.delayedCall(1500, () => {
const tune = this.spotTune();
if (tune) {
this.score += tune.first ? (tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE) : ID_SCORE;
if (tune.g) this.cameras.main.flash(600, 255, 215, 0); // gold flash for a classic
this.showOverlay(tune.g ? 'CERTIFIED CLASSIC' : 'TUNE IDENTIFIED');
const nb = this.notebookTotals();
this.sub
.setText(
`${tune.a}${tune.t}\n(${tune.l}, ${tune.y || '????'})\n\n` +
`${tune.first ? `+${tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE} NEW ENTRY` : 'already in the notebook'}` +
(nb ? ` notebook ${nb.spotted}/${nb.total}` : ''),
)
.setVisible(true);
announce('tune', `Tune identified. ${tune.a}. ${tune.t}.`, this.time.now);
} else {
this.showOverlay('TUNE IDENTIFIED');
this.say(LINES.levelClear);
}
this.time.delayedCall(2800, () => {
this.physics.resume();
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
this.hideOverlay();
@ -666,7 +726,12 @@ export default class GameScene extends Phaser.Scene {
const scores = this.loadScores();
if (scores.length >= 5 && this.score <= scores[scores.length - 1].score) return;
// ponytail: window.prompt beats building a text-entry widget; revisit if it grates
const raw = window.prompt(`High score: ${this.score}! Your initials:`, 'AAA');
let raw = 'YOU';
try {
raw = window.prompt(`High score: ${this.score}! Your initials:`, 'AAA');
} catch {
/* some webviews lack prompt() — default initials */
}
const initials = (raw || 'AAA').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 3) || 'AAA';
scores.push({ initials, score: this.score });
scores.sort((a, b) => b.score - a.score);
@ -691,8 +756,12 @@ export default class GameScene extends Phaser.Scene {
const hs = this.loadScores()
.map((s, i) => `${i + 1}. ${s.initials} ${s.score}`)
.join('\n');
const nb = this.notebookTotals();
const nbLine = nb && nb.spotted ? `\n\nNOTEBOOK ${nb.spotted}/${nb.total} tunes spotted` : '';
this.showOverlay('VINYL GAUNTLET');
this.sub.setText(hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.').setVisible(true);
this.sub
.setText((hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.') + nbLine)
.setVisible(true);
this.classButtons.forEach((b) => b.setVisible(true));
}