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:
parent
d903dd57ca
commit
a1f4588deb
92
assets/extract_tunes.py
Normal file
92
assets/extract_tunes.py
Normal 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
1
public/tunes.json
Normal file
File diff suppressed because one or more lines are too long
@ -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));
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user