Files
hitstar/src/server-deno/application/GameService.ts

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 };
}
}