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
|
||||
@@ -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,9 +314,26 @@ 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 {
|
||||
@@ -357,6 +378,6 @@ export class GameService {
|
||||
);
|
||||
}
|
||||
|
||||
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