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
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user