import type { GuessResult, ID, Track } from '../domain/types.ts'; import { GameStatus } from '../domain/types.ts'; import type { RoomModel } from '../domain/models/mod.ts'; import { AudioStreamingService } from '../infrastructure/mod.ts'; import { TrackService } from './TrackService.ts'; import { AnswerCheckService } from './AnswerCheckService.ts'; import { logger } from '../shared/logger.ts'; import { ValidationError } from '../shared/errors.ts'; /** * Game service for managing game logic and flow */ export class GameService { constructor( private readonly trackService: TrackService, private readonly audioStreaming: AudioStreamingService, private readonly answerCheck: AnswerCheckService, ) {} /** * Start a game in a room */ async startGame(room: RoomModel): Promise { if (room.state.status === ('playing' as GameStatus)) { throw new ValidationError('Game already in progress'); } if (!room.state.playlist) { throw new ValidationError('No playlist selected'); } if (!room.state.areAllReady()) { throw new ValidationError('Not all players are ready'); } // Load shuffled deck const tracks = await this.trackService.loadShuffledDeck(room.state.playlist); if (tracks.length === 0) { throw new ValidationError('No tracks available in selected playlist'); } room.setDeck(tracks); // Initialize game state const playerIds = Array.from(room.players.keys()).filter( (id) => !room.state.spectators[id] ); room.state.startGame(playerIds); logger.info(`Game started in room ${room.id} with ${playerIds.length} players`); } /** * Draw next track and prepare for guessing */ async drawNextTrack(room: RoomModel): Promise { const track = room.drawTrack(); if (!track) { // No more tracks - end game room.state.endGame(); return null; } // Create streaming token try { const token = await this.audioStreaming.createAudioToken(track.file); track.url = `/audio/t/${token}`; } catch (error) { logger.error(`Failed to create audio token for ${track.file}: ${error}`); // Fallback to name-based URL track.url = `/audio/${encodeURIComponent(track.file)}`; } // Update game state room.state.currentTrack = track; room.state.resetRound(); room.state.trackStartAt = Date.now() + 800; // Small delay for sync logger.info(`Track drawn in room ${room.id}: ${track.title} - ${track.artist}`); return track; } /** * Process a guess */ processGuess( room: RoomModel, playerId: ID, guess: string, type: 'title' | 'artist' | 'year', ): GuessResult { const player = room.getPlayer(playerId); const track = room.state.currentTrack; if (!player || !track) { throw new ValidationError('Invalid guess context'); } let result: GuessResult; switch (type) { case 'title': { const scoreResult = this.answerCheck.scoreTitle(guess, track.title); result = { playerId, playerName: player.name, guess, correct: scoreResult.match, type: 'title', score: scoreResult.score, answer: track.title, }; break; } case 'artist': { const scoreResult = this.answerCheck.scoreArtist(guess, track.artist); result = { playerId, playerName: player.name, guess, correct: scoreResult.match, type: 'artist', score: scoreResult.score, answer: track.artist, }; break; } case 'year': { const scoreResult = this.answerCheck.scoreYear(guess, track.year); result = { playerId, playerName: player.name, guess, correct: scoreResult.match, type: 'year', score: scoreResult.score, answer: track.year ? String(track.year) : 'Unknown', }; break; } default: throw new ValidationError('Invalid guess type'); } // NOTE: This method is legacy and no longer awards tokens. // Token awarding is now handled by: // - checkTitleArtistGuess() for title+artist tokens // - placeInTimeline() for placement tokens room.state.lastResult = result; return result; } /** * Place a card in player's timeline */ placeCard(room: RoomModel, playerId: ID, year: number, position: number): boolean { const player = room.getPlayer(playerId); if (!player) { throw new ValidationError('Player not found'); } room.state.addToTimeline(playerId, year, position); // Check for winner if (room.state.hasPlayerWon(playerId)) { room.state.endGame(); logger.info(`Player ${player.name} won in room ${room.id}!`); return true; } return false; } /** * Skip to next player's turn */ nextTurn(room: RoomModel): ID | null { return room.state.nextTurn(); } /** * Pause game */ pauseGame(room: RoomModel): void { if (!room.state.paused && room.state.trackStartAt) { const elapsed = (Date.now() - room.state.trackStartAt) / 1000; room.state.pausedPosSec = elapsed; room.state.paused = true; } } /** * Resume game */ resumeGame(room: RoomModel): void { if (room.state.paused) { room.state.trackStartAt = Date.now() - (room.state.pausedPosSec * 1000); room.state.paused = false; } } /** * End game */ endGame(room: RoomModel): void { room.state.endGame(); logger.info(`Game ended in room ${room.id}`); } /** * Get winner */ getWinner(room: RoomModel): ID | null { return room.state.getWinner(); } /** * Check title and artist guess * * SCORING SYSTEM: * - Tokens: Awarded ONLY for correctly guessing BOTH title AND artist * - 1 token per round (can only get once per track) * - Used as currency/bonus points * * - Score: Number of correctly placed tracks in timeline (timeline.length) * - Increases only when placement is correct * - This is the main win condition */ async checkTitleArtistGuess( room: RoomModel, playerId: ID, guessTitle: string, guessArtist: string ): Promise<{ titleCorrect: boolean; artistCorrect: boolean; awarded: boolean; alreadyAwarded: boolean; }> { const track = room.state.currentTrack; if (!track) { throw new Error('No current track'); } // Check if title+artist token already awarded this round const alreadyAwarded = room.state.titleArtistAwardedThisRound[playerId] || false; // Score the guesses const titleResult = this.answerCheck.scoreTitle(guessTitle, track.title); const artistResult = this.answerCheck.scoreArtist(guessArtist, track.artist); const titleCorrect = titleResult.match; const artistCorrect = artistResult.match; const bothCorrect = titleCorrect && artistCorrect; // Award 1 token if BOTH title and artist are correct, and not already awarded this round let awarded = false; if (bothCorrect && !alreadyAwarded) { room.state.tokens[playerId] = (room.state.tokens[playerId] || 0) + 1; room.state.titleArtistAwardedThisRound[playerId] = true; awarded = true; logger.info( `Player ${playerId} correctly guessed title AND artist. Awarded 1 token for title+artist.` ); } return { titleCorrect, artistCorrect, awarded, alreadyAwarded: alreadyAwarded && bothCorrect, }; } /** * Place track in timeline * * SCORING SYSTEM: * - Correct placement: Adds track to timeline, increasing score (timeline.length) * - Incorrect placement: Track is discarded, no score increase * - NO tokens awarded here - tokens are only from title+artist guesses */ async placeInTimeline( room: RoomModel, playerId: ID, slot: number ): Promise<{ correct: boolean }> { const track = room.state.currentTrack; if (!track) { throw new Error('No current track'); } const timeline = room.state.timeline[playerId] || []; const n = timeline.length; // Validate slot if (slot < 0 || slot > n) { slot = n; } logger.info(`Timeline before placement: ${JSON.stringify(timeline.map(t => ({ year: t.year, title: t.title })))}`); logger.info(`Placing track: ${track.title} (${track.year}) at slot ${slot} of ${n}`); // Check if placement is correct let correct = false; if (track.year != null) { if (n === 0) { correct = true; // First card is always correct } else { const leftYear = slot > 0 ? timeline[slot - 1]?.year : null; const rightYear = slot < n ? timeline[slot]?.year : null; // Allow equal years (>=, <=) so cards from the same year can be placed anywhere relative to each other const leftOk = leftYear == null || track.year >= leftYear; const rightOk = rightYear == null || track.year <= rightYear; correct = leftOk && rightOk; // Debug logging logger.info(`Placement check - Track year: ${track.year}, Slot: ${slot}/${n}, Left: ${leftYear}, Right: ${rightYear}, LeftOk: ${leftOk}, RightOk: ${rightOk}, Correct: ${correct}`); } } else { logger.warn(`Track has no year: ${track.title}`); } // Update timeline if correct (score is the timeline length) if (correct) { const newTimeline = [...timeline]; newTimeline.splice(slot, 0, { trackId: track.id, year: track.year, title: track.title, artist: track.artist, }); room.state.timeline[playerId] = newTimeline; // Score increases automatically (it's the timeline length) // NO token awarded here - tokens are only from title+artist guesses logger.info( `Player ${playerId} correctly placed track in timeline. Score is now ${newTimeline.length}.` ); } else { // Discard the track room.discard.push(track); logger.info( `Player ${playerId} incorrectly placed track. No score increase.` ); } return { correct }; } }