Refactor: Remove audio processing and game state management modules
This commit is contained in:
179
src/server-deno/domain/models/GameState.ts
Normal file
179
src/server-deno/domain/models/GameState.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
src/server-deno/domain/models/Player.ts
Normal file
79
src/server-deno/domain/models/Player.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
143
src/server-deno/domain/models/Room.ts
Normal file
143
src/server-deno/domain/models/Room.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/server-deno/domain/models/mod.ts
Normal file
6
src/server-deno/domain/models/mod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Domain model exports
|
||||
*/
|
||||
export { PlayerModel } from './Player.ts';
|
||||
export { GameStateModel } from './GameState.ts';
|
||||
export { RoomModel } from './Room.ts';
|
||||
149
src/server-deno/domain/types.ts
Normal file
149
src/server-deno/domain/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user