feat: Implement initial real-time multiplayer game server and client-side UI for core game functionality.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s

This commit is contained in:
2026-01-04 15:10:34 +01:00
parent 8ca744cd5b
commit a1f1b41987
7 changed files with 75 additions and 19 deletions

View File

@@ -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<Track | null> {
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 };
}
}

View File

@@ -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
}
/**

View File

@@ -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

View File

@@ -343,14 +343,18 @@
<h3 class="text-lg font-semibold">Position</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Wähle die Position und klicke Platzieren.</p>
<div class="flex flex-wrap items-center gap-3">
<div id="placeArea"
class="hidden flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto">
<div id="placeArea" class="hidden flex flex-wrap items-stretch sm:items-center gap-2 w-full">
<select id="slotSelect"
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 w-full sm:w-auto"></select>
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 flex-1 min-w-0"></select>
<button id="placeBtn"
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600 w-full sm:w-auto">
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600 shrink-0">
Platzieren
</button>
<button id="placeWithTokensBtn"
class="h-10 px-3 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium flex items-center justify-center gap-1 whitespace-nowrap shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nutze 3 Tokens um die Karte automatisch richtig zu platzieren">
🪙 3 Tokens
</button>
</div>
<div id="nextArea" class="hidden w-full sm:w-auto">
<button id="nextBtn"

View File

@@ -17,6 +17,7 @@ export const $revealBanner = el("revealBanner");
export const $placeArea = el("placeArea");
export const $slotSelect = el("slotSelect");
export const $placeBtn = el("placeBtn");
export const $placeWithTokensBtn = el("placeWithTokensBtn");
export const $mediaControls = el("mediaControls");
export const $playBtn = el("playBtn");
export const $pauseBtn = el("pauseBtn");

View File

@@ -10,6 +10,7 @@ import {
$nextArea,
$np,
$placeArea,
$placeWithTokensBtn,
$readyChk,
$revealBanner,
$room,
@@ -212,6 +213,8 @@ export function renderRoom(room) {
if ($placeArea && $slotSelect) {
if (canGuess) {
const tl = room.state.timeline?.[state.playerId] || [];
// Preserve selected slot across re-renders
const previousValue = $slotSelect.value;
$slotSelect.innerHTML = '';
for (let i = 0; i <= tl.length; i++) {
const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null;
@@ -226,6 +229,21 @@ export function renderRoom(room) {
opt.textContent = label;
$slotSelect.appendChild(opt);
}
// Restore previous selection if still valid
if (previousValue && parseInt(previousValue, 10) <= tl.length) {
$slotSelect.value = previousValue;
}
// Enable/disable the "use tokens" button based on token count
if ($placeWithTokensBtn) {
const myTokens = room.state.tokens?.[state.playerId] ?? 0;
const TOKEN_COST = 3;
const canUseTokens = myTokens >= TOKEN_COST;
$placeWithTokensBtn.disabled = !canUseTokens;
$placeWithTokensBtn.title = canUseTokens
? 'Nutze 3 Tokens um die Karte automatisch richtig zu platzieren'
: `Du brauchst mindestens ${TOKEN_COST} Tokens (aktuell: ${myTokens})`;
}
} else {
// Clear options when not guessing
$slotSelect.innerHTML = '';

View File

@@ -11,6 +11,7 @@ import {
$nextBtn,
$pauseBtn,
$placeBtn,
$placeWithTokensBtn,
$readyChk,
$room,
$roomCode,
@@ -188,6 +189,12 @@ export function wireUi() {
sendMsg({ type: "place_guess", slot });
});
// Use tokens to place card correctly
wire($placeWithTokensBtn, "click", () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: "place_guess", slot, useTokens: true });
});
wire($playBtn, "click", () => sendMsg({ type: "resume_play" }));
wire($pauseBtn, "click", () => sendMsg({ type: "pause" }));
wire($nextBtn, "click", () => sendMsg({ type: "skip_track" }));