363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
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<void> {
|
|
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<Track | null> {
|
|
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 };
|
|
}
|
|
}
|