Refactor: Remove audio processing and game state management modules

This commit is contained in:
2025-10-15 23:33:40 +02:00
parent 56d7511bd6
commit 58c668de63
69 changed files with 5836 additions and 1319 deletions

View File

@@ -0,0 +1,179 @@
import type { GamePhase, GameState, GameStatus, GuessResult, ID, TimelinePosition } from '../types.ts';
/**
* Game state domain model
* Encapsulates all game state and provides methods to manipulate it
*/
export class GameStateModel implements GameState {
status: GameStatus;
phase: GamePhase;
turnOrder: ID[];
currentGuesser: ID | null;
currentTrack: any;
timeline: Record<ID, TimelinePosition[]>;
tokens: Record<ID, number>;
ready: Record<ID, boolean>;
spectators: Record<ID, boolean>;
lastResult: GuessResult | null;
trackStartAt: number | null;
paused: boolean;
pausedPosSec: number;
goal: number;
playlist: string | null;
titleArtistAwardedThisRound: Record<ID, boolean>;
constructor(goal = 10) {
this.status = 'lobby' as GameStatus;
this.phase = 'guess' as GamePhase;
this.turnOrder = [];
this.currentGuesser = null;
this.currentTrack = null;
this.timeline = {};
this.tokens = {};
this.ready = {};
this.spectators = {};
this.lastResult = null;
this.trackStartAt = null;
this.paused = false;
this.pausedPosSec = 0;
this.goal = goal;
this.playlist = null;
this.titleArtistAwardedThisRound = {};
}
/**
* Initialize player in game state
*/
addPlayer(playerId: ID, isHost = false): void {
this.ready[playerId] = isHost; // Host is ready by default
this.timeline[playerId] = [];
this.tokens[playerId] = 0;
}
/**
* Remove player from game state
*/
removePlayer(playerId: ID): void {
delete this.ready[playerId];
delete this.timeline[playerId];
delete this.tokens[playerId];
delete this.spectators[playerId];
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
}
/**
* Set player ready status
*/
setReady(playerId: ID, ready: boolean): void {
this.ready[playerId] = ready;
}
/**
* Check if all players are ready
*/
areAllReady(): boolean {
const playerIds = Object.keys(this.ready).filter((id) => !this.spectators[id]);
return playerIds.length > 0 && playerIds.every((id) => this.ready[id]);
}
/**
* Start the game
*/
startGame(playerIds: ID[]): void {
this.status = 'playing' as GameStatus;
this.turnOrder = this.shuffleArray([...playerIds.filter((id) => !this.spectators[id])]);
this.currentGuesser = this.turnOrder[0] || null;
}
/**
* End the game
*/
endGame(): void {
this.status = 'ended' as GameStatus;
this.currentTrack = null;
this.currentGuesser = null;
}
/**
* Move to next player's turn
*/
nextTurn(): ID | null {
if (!this.currentGuesser || this.turnOrder.length === 0) {
return this.turnOrder[0] || null;
}
const currentIndex = this.turnOrder.indexOf(this.currentGuesser);
const nextIndex = (currentIndex + 1) % this.turnOrder.length;
this.currentGuesser = this.turnOrder[nextIndex];
return this.currentGuesser;
}
/**
* Award tokens to a player
*/
awardTokens(playerId: ID, amount: number): void {
this.tokens[playerId] = (this.tokens[playerId] || 0) + amount;
}
/**
* Add card to player's timeline
*/
addToTimeline(playerId: ID, year: number, position: number): void {
if (!this.timeline[playerId]) {
this.timeline[playerId] = [];
}
this.timeline[playerId].push({ year, position });
this.timeline[playerId].sort((a, b) => a.position - b.position);
}
/**
* Check if player has won
*/
hasPlayerWon(playerId: ID): boolean {
return (this.timeline[playerId]?.length || 0) >= this.goal;
}
/**
* Get winner (if any)
*/
getWinner(): ID | null {
for (const playerId of Object.keys(this.timeline)) {
if (this.hasPlayerWon(playerId)) {
return playerId;
}
}
return null;
}
/**
* Set spectator status
*/
setSpectator(playerId: ID, spectator: boolean): void {
this.spectators[playerId] = spectator;
if (spectator) {
// Remove from turn order if spectating
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
}
}
/**
* Reset round state (after track is complete)
* Note: currentTrack and trackStartAt are set separately by the caller
*/
resetRound(): void {
this.phase = 'guess' as GamePhase;
this.lastResult = null;
this.titleArtistAwardedThisRound = {};
}
/**
* Utility: Shuffle array
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}

View File

@@ -0,0 +1,79 @@
import type { ID, Player } from '../types.ts';
/**
* Player domain model
* Represents a player in the game with their connection state
*/
export class PlayerModel implements Player {
id: ID;
sessionId: ID;
name: string;
connected: boolean;
roomId: ID | null;
spectator?: boolean;
constructor(
id: ID,
sessionId: ID,
name?: string,
connected = true,
roomId: ID | null = null,
) {
this.id = id;
this.sessionId = sessionId;
this.name = name || `Player-${id.slice(0, 4)}`;
this.connected = connected;
this.roomId = roomId;
}
/**
* Update player's connection status
*/
setConnected(connected: boolean): void {
this.connected = connected;
}
/**
* Update player's name
*/
setName(name: string): void {
if (name && name.trim().length > 0) {
this.name = name.trim();
}
}
/**
* Join a room
*/
joinRoom(roomId: ID): void {
this.roomId = roomId;
}
/**
* Leave current room
*/
leaveRoom(): void {
this.roomId = null;
}
/**
* Toggle spectator mode
*/
setSpectator(spectator: boolean): void {
this.spectator = spectator;
}
/**
* Create a safe representation for serialization
*/
toJSON(): Player {
return {
id: this.id,
sessionId: this.sessionId,
name: this.name,
connected: this.connected,
roomId: this.roomId,
spectator: this.spectator,
};
}
}

View File

@@ -0,0 +1,143 @@
import type { ID, Player, Room, Track } from '../types.ts';
import { GameStateModel } from './GameState.ts';
/**
* Room domain model
* Represents a game room with players and game state
*/
export class RoomModel implements Room {
id: ID;
name: string;
hostId: ID;
players: Map<ID, Player>;
deck: Track[];
discard: Track[];
state: GameStateModel;
constructor(id: ID, name: string, host: Player, goal = 10) {
this.id = id;
this.name = name || `Room ${id}`;
this.hostId = host.id;
this.players = new Map([[host.id, host]]);
this.deck = [];
this.discard = [];
this.state = new GameStateModel(goal);
// Initialize host in game state (not ready by default)
this.state.addPlayer(host.id, false);
}
/**
* Add a player to the room
*/
addPlayer(player: Player): boolean {
if (this.players.has(player.id)) {
return false;
}
this.players.set(player.id, player);
this.state.addPlayer(player.id);
return true;
}
/**
* Remove a player from the room
*/
removePlayer(playerId: ID): boolean {
const removed = this.players.delete(playerId);
if (removed) {
this.state.removePlayer(playerId);
}
return removed;
}
/**
* Get player by ID
*/
getPlayer(playerId: ID): Player | undefined {
return this.players.get(playerId);
}
/**
* Check if player is in room
*/
hasPlayer(playerId: ID): boolean {
return this.players.has(playerId);
}
/**
* Check if player is host
*/
isHost(playerId: ID): boolean {
return this.hostId === playerId;
}
/**
* Transfer host to another player
*/
transferHost(newHostId: ID): boolean {
if (!this.players.has(newHostId)) {
return false;
}
this.hostId = newHostId;
return true;
}
/**
* Get all connected players
*/
getConnectedPlayers(): Player[] {
return Array.from(this.players.values()).filter((p) => p.connected);
}
/**
* Get all players (including disconnected)
*/
getAllPlayers(): Player[] {
return Array.from(this.players.values());
}
/**
* Set deck of tracks
*/
setDeck(tracks: Track[]): void {
this.deck = [...tracks];
}
/**
* Draw next track from deck
*/
drawTrack(): Track | null {
const track = this.deck.shift();
if (track) {
this.discard.push(track);
return track;
}
return null;
}
/**
* Check if deck is empty
*/
isDeckEmpty(): boolean {
return this.deck.length === 0;
}
/**
* Get room summary for serialization
*/
toSummary() {
return {
id: this.id,
name: this.name,
hostId: this.hostId,
players: this.getAllPlayers().map((p) => ({
id: p.id,
name: p.name,
connected: p.connected,
ready: this.state.ready[p.id] || false,
spectator: this.state.spectators[p.id] || false,
})),
state: this.state,
};
}
}

View File

@@ -0,0 +1,6 @@
/**
* Domain model exports
*/
export { PlayerModel } from './Player.ts';
export { GameStateModel } from './GameState.ts';
export { RoomModel } from './Room.ts';

View File

@@ -0,0 +1,149 @@
/**
* Core domain types for the Hitstar game
*/
/**
* Unique identifier type
*/
export type ID = string;
/**
* Game status enum
*/
export enum GameStatus {
LOBBY = 'lobby',
PLAYING = 'playing',
ENDED = 'ended',
}
/**
* Game phase during active play
*/
export enum GamePhase {
GUESS = 'guess',
REVEAL = 'reveal',
}
/**
* Track metadata
*/
export interface Track {
id: string;
file: string;
title: string;
artist: string;
year: number | null;
url?: string; // Token-based streaming URL
}
/**
* Player in a room
*/
export interface Player {
id: ID;
sessionId: ID;
name: string;
connected: boolean;
roomId: ID | null;
spectator?: boolean;
}
/**
* Player timeline position (for card placement)
*/
export interface TimelinePosition {
trackId: string;
year: number | null;
title: string;
artist: string;
}
/**
* Game state
*/
export interface GameState {
status: GameStatus;
phase: GamePhase;
turnOrder: ID[]; // Player IDs in turn order
currentGuesser: ID | null;
currentTrack: Track | null;
timeline: Record<ID, TimelinePosition[]>; // Player ID -> their timeline cards
tokens: Record<ID, number>; // Player ID -> token/coin count
ready: Record<ID, boolean>; // Player ID -> ready status in lobby
spectators: Record<ID, boolean>; // Player ID -> spectator flag
lastResult: GuessResult | null;
trackStartAt: number | null; // Timestamp when track started playing
paused: boolean;
pausedPosSec: number;
goal: number; // Win condition (e.g., 10 cards)
playlist: string | null; // Selected playlist ID
titleArtistAwardedThisRound: Record<ID, boolean>; // Track if title+artist token awarded this round
}
/**
* Room containing players and game state
*/
export interface Room {
id: ID;
name: string;
hostId: ID;
players: Map<ID, Player>;
deck: Track[]; // Unplayed tracks
discard: Track[]; // Played tracks
state: GameState;
}
/**
* Result of a guess attempt
*/
export interface GuessResult {
playerId: ID;
playerName?: string;
guess?: string | null;
correct: boolean;
type: 'title' | 'artist' | 'year' | 'placement';
score?: number; // Similarity score for partial matches
answer?: string; // The correct answer
}
/**
* Playlist metadata
*/
export interface Playlist {
id: string;
name: string;
trackCount: number;
}
/**
* Years index structure (loaded from years.json)
*/
export interface YearsIndex {
byFile: Record<string, YearMetadata>;
}
/**
* Metadata for a single track in years.json
*/
export interface YearMetadata {
year: number | null;
title?: string;
artist?: string;
}
/**
* Audio token metadata
*/
export interface AudioToken {
path: string;
mime: string;
size: number;
}
/**
* Cover art data
*/
export interface CoverArt {
mime: string;
buf: Uint8Array;
}