Initial build: fullhouse, live music bingo from a real Spotify playlist
All checks were successful
Docker / build-and-push (push) Successful in 1m18s
All checks were successful
Docker / build-and-push (push) Successful in 1m18s
Based on bingo-bango by f8al (MIT) — same core card-generation idea
("both facets": a square is either an exact song title or an artist,
and an artist square lights up on any of their tracks), reworked with
a real host/guest split, live multiplayer sessions, and cover-art
squares. f8al credited in the footer, README, and PROPOSAL.md.
Three ways to play: host a live session (server-side Spotify
connection, join code + QR, caller screen, auto-call from real
playback), join someone else's session (no login needed), or solo
(guest PKCE login or a pasted public playlist URL, one-off card).
Genuinely verified against a real Spotify account throughout, not
just built and assumed working — including two real bugs found and
fixed along the way (Spotify's playlist-tracks endpoint quietly
renamed to /items with a reshaped response; reading playlist tracks
needs real user auth even for public playlists, so the "no guest
login" public-playlist path routes through the host's connection
instead of a dead-end app-only token). Both Docker images built and
run together on a real network with the nginx proxy verified working
end-to-end, and auto-call confirmed detecting an actual track playing
live through Spotify Connect within one poll tick.
This commit is contained in:
140
client/src/cards/generate.ts
Normal file
140
client/src/cards/generate.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Card generation engine. The "both facets" idea — a square can hold
|
||||
// either a track title or an artist name, and playing a track marks
|
||||
// both the title square and any artist squares for that artist — is
|
||||
// f8al's design from bingo-bango (https://github.com/f8al/bingo-bango,
|
||||
// MIT licensed). This module is a fresh implementation of that shape,
|
||||
// not copied code, extended with a cover-art render mode the original
|
||||
// doesn't have.
|
||||
//
|
||||
// Pure, dependency-free, deterministic given a seed — same property
|
||||
// the original's engine has, load-bearing here too: a guest's card
|
||||
// has to reproduce identically from a shareable seed/URL.
|
||||
|
||||
export type PoolTrack = {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
export type SquareFacet = 'title' | 'artist' | 'free';
|
||||
|
||||
export type CardSquare = {
|
||||
facet: SquareFacet;
|
||||
text: string;
|
||||
image: string | null;
|
||||
/** Track ids that mark this square when called. A title square has
|
||||
* exactly one; an artist square can have several if the artist
|
||||
* appears more than once in the pool. */
|
||||
matchIds: string[];
|
||||
};
|
||||
|
||||
export type BingoCard = {
|
||||
seed: number;
|
||||
size: number;
|
||||
squares: CardSquare[]; // row-major, length size*size
|
||||
};
|
||||
|
||||
// Small, fast, deterministic PRNG (mulberry32) — no crypto needed, the
|
||||
// point is reproducibility from a seed, not unpredictability.
|
||||
function mulberry32(seed: number) {
|
||||
let a = seed;
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[], rand: () => number): T[] {
|
||||
const out = arr.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type CardOptions = {
|
||||
size: 3 | 4 | 5;
|
||||
freeSpace: boolean;
|
||||
/** Roughly how much of the grid is artist squares vs title squares —
|
||||
* 0.5 matches the original's ~50/50 split. */
|
||||
artistRatio?: number;
|
||||
};
|
||||
|
||||
export function generateCard(pool: PoolTrack[], seed: number, opts: CardOptions): BingoCard {
|
||||
const { size, freeSpace, artistRatio = 0.5 } = opts;
|
||||
const cellCount = size * size;
|
||||
const needed = cellCount - (freeSpace ? 1 : 0);
|
||||
if (pool.length < needed) {
|
||||
throw new Error(`pool has ${pool.length} tracks, need at least ${needed} for a ${size}x${size} card`);
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed);
|
||||
const chosen = shuffle(pool, rand).slice(0, needed);
|
||||
|
||||
// Group by artist so an artist square can list every matching track id.
|
||||
const byArtist = new Map<string, PoolTrack[]>();
|
||||
for (const t of pool) {
|
||||
const list = byArtist.get(t.artist) ?? [];
|
||||
list.push(t);
|
||||
byArtist.set(t.artist, list);
|
||||
}
|
||||
|
||||
const squares: CardSquare[] = chosen.map((track) => {
|
||||
const useArtist = rand() < artistRatio;
|
||||
if (useArtist) {
|
||||
const matches = byArtist.get(track.artist) ?? [track];
|
||||
return {
|
||||
facet: 'artist',
|
||||
text: track.artist,
|
||||
image: track.image,
|
||||
matchIds: matches.map((m) => m.id),
|
||||
};
|
||||
}
|
||||
return {
|
||||
facet: 'title',
|
||||
text: track.title,
|
||||
image: track.image,
|
||||
matchIds: [track.id],
|
||||
};
|
||||
});
|
||||
|
||||
if (freeSpace) {
|
||||
const center = Math.floor(cellCount / 2);
|
||||
squares.splice(center, 0, { facet: 'free', text: 'FREE', image: null, matchIds: [] });
|
||||
}
|
||||
|
||||
return { seed, size, squares };
|
||||
}
|
||||
|
||||
/** Which squares light up when a track is called. */
|
||||
export function markedIndices(card: BingoCard, calledTrackId: string): number[] {
|
||||
const out: number[] = [];
|
||||
card.squares.forEach((sq, i) => {
|
||||
if (sq.matchIds.includes(calledTrackId)) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True if any full row, column, or diagonal is fully covered. */
|
||||
export function hasBingo(card: BingoCard, markedSet: Set<number>): boolean {
|
||||
const { size } = card;
|
||||
const at = (r: number, c: number) => r * size + c;
|
||||
const isMarked = (r: number, c: number) => card.squares[at(r, c)].facet === 'free' || markedSet.has(at(r, c));
|
||||
|
||||
for (let r = 0; r < size; r++) {
|
||||
if (Array.from({ length: size }, (_, c) => isMarked(r, c)).every(Boolean)) return true;
|
||||
}
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (Array.from({ length: size }, (_, r) => isMarked(r, c)).every(Boolean)) return true;
|
||||
}
|
||||
if (Array.from({ length: size }, (_, i) => isMarked(i, i)).every(Boolean)) return true;
|
||||
if (Array.from({ length: size }, (_, i) => isMarked(i, size - 1 - i)).every(Boolean)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user