From a1f1b41987691c052323164df040b13d011a9f9b Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Sun, 4 Jan 2026 15:10:34 +0100 Subject: [PATCH] feat: Implement initial real-time multiplayer game server and client-side UI for core game functionality. --- src/server-deno/application/GameService.ts | 49 +++++++++++++------ src/server-deno/domain/types.ts | 1 + .../presentation/WebSocketServer.ts | 6 ++- src/server-deno/public/index.html | 12 +++-- src/server-deno/public/js/dom.js | 1 + src/server-deno/public/js/render.js | 18 +++++++ src/server-deno/public/js/ui.js | 7 +++ 7 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/server-deno/application/GameService.ts b/src/server-deno/application/GameService.ts index 13479b2..1f23502 100644 --- a/src/server-deno/application/GameService.ts +++ b/src/server-deno/application/GameService.ts @@ -15,7 +15,7 @@ export class GameService { private readonly trackService: TrackService, private readonly audioStreaming: AudioStreamingService, private readonly answerCheck: AnswerCheckService, - ) {} + ) { } /** * Start a game in a room @@ -35,7 +35,7 @@ export class GameService { // 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'); } @@ -56,7 +56,7 @@ export class GameService { */ async drawNextTrack(room: RoomModel): Promise { const track = room.drawTrack(); - + if (!track) { // No more tracks - end game room.state.endGame(); @@ -162,7 +162,7 @@ export class GameService { */ placeCard(room: RoomModel, playerId: ID, year: number, position: number): boolean { const player = room.getPlayer(playerId); - + if (!player) { throw new ValidationError('Player not found'); } @@ -267,7 +267,7 @@ export class GameService { 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.` ); @@ -288,12 +288,16 @@ export class GameService { * - 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 + * + * TOKEN PURCHASE: + * - If useTokens is true and player has >= 3 tokens, deduct 3 tokens and force correct placement */ async placeInTimeline( room: RoomModel, playerId: ID, - slot: number - ): Promise<{ correct: boolean }> { + slot: number, + useTokens: boolean = false + ): Promise<{ correct: boolean; tokensUsed: boolean }> { const track = room.state.currentTrack; if (!track) { throw new Error('No current track'); @@ -310,21 +314,38 @@ export class GameService { 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 player wants to use tokens to guarantee correct placement + let tokensUsed = false; + const TOKEN_COST = 3; + const playerTokens = room.state.tokens[playerId] || 0; + + if (useTokens && playerTokens >= TOKEN_COST) { + // Deduct tokens and force correct placement + room.state.tokens[playerId] = playerTokens - TOKEN_COST; + tokensUsed = true; + logger.info( + `Player ${playerId} used ${TOKEN_COST} tokens to guarantee correct placement. Remaining tokens: ${room.state.tokens[playerId]}` + ); + } + // Check if placement is correct let correct = false; - if (track.year != null) { + if (tokensUsed) { + // Tokens used - placement is always correct + correct = true; + } else 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}`); } @@ -342,7 +363,7 @@ export class GameService { 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( @@ -351,12 +372,12 @@ export class GameService { } else { // Discard the track room.discard.push(track); - + logger.info( `Player ${playerId} incorrectly placed track. No score increase.` ); } - return { correct }; + return { correct, tokensUsed }; } } diff --git a/src/server-deno/domain/types.ts b/src/server-deno/domain/types.ts index 7eae198..e4e3456 100644 --- a/src/server-deno/domain/types.ts +++ b/src/server-deno/domain/types.ts @@ -104,6 +104,7 @@ export interface GuessResult { type: 'title' | 'artist' | 'year' | 'placement'; score?: number; // Similarity score for partial matches answer?: string; // The correct answer + tokensUsed?: boolean; // Whether tokens were used for guaranteed placement } /** diff --git a/src/server-deno/presentation/WebSocketServer.ts b/src/server-deno/presentation/WebSocketServer.ts index 5b1a881..6bb3e02 100644 --- a/src/server-deno/presentation/WebSocketServer.ts +++ b/src/server-deno/presentation/WebSocketServer.ts @@ -482,8 +482,11 @@ export class WebSocketServer { return; } + // Check if player wants to use tokens for guaranteed placement + const useTokens = !!msg.useTokens; + try { - const result = await this.gameService.placeInTimeline(room, player.id, slot); + const result = await this.gameService.placeInTimeline(room, player.id, slot, useTokens); // Store the result room.state.lastResult = { @@ -491,6 +494,7 @@ export class WebSocketServer { correct: result.correct, guess: null, type: 'placement', + tokensUsed: result.tokensUsed, }; // Move to reveal phase - DON'T auto-skip, wait for user to click next diff --git a/src/server-deno/public/index.html b/src/server-deno/public/index.html index b350b48..885a5d1 100644 --- a/src/server-deno/public/index.html +++ b/src/server-deno/public/index.html @@ -343,14 +343,18 @@

Position

Wähle die Position und klicke Platzieren.

-