diff --git a/public/src/games/skipbo/SkipBoGame.js b/public/src/games/skipbo/SkipBoGame.js index 481854e..9014aa0 100644 --- a/public/src/games/skipbo/SkipBoGame.js +++ b/public/src/games/skipbo/SkipBoGame.js @@ -9,6 +9,7 @@ import { DISCARD_PILE_COUNT, HAND_SIZE, STOCK_SIZE, + applyAutoPass, applyDiscard, applyPlay, buildPileTopValue, @@ -16,6 +17,7 @@ import { createInitialState, discardTop, getValidPlays, + isPlayerStuck, nextRequired, stockTop, } from './SkipBoLogic.js'; @@ -666,10 +668,8 @@ export default class SkipBoGame extends Phaser.Scene { this.gs = next; this.renderAll(); this.animating = false; - if (this.gs.phase === 'gameover') { - this.endGame(); - return; - } + if (this.gs.phase === 'gameover') { this.endGame(); return; } + if (this.checkForStuck()) return; // After a successful play it's still the same player's turn until they // discard. If AI's turn, continue. if (this.gs.currentPlayer !== 0) { @@ -688,6 +688,7 @@ export default class SkipBoGame extends Phaser.Scene { this.renderAll(); this.animating = false; if (this.gs.phase === 'gameover') { this.endGame(); return; } + if (this.checkForStuck()) return; if (this.gs.currentPlayer !== 0) { this.runAITurn(); } else { @@ -775,6 +776,7 @@ export default class SkipBoGame extends Phaser.Scene { this.renderAll(); this.animating = false; if (this.gs.phase === 'gameover') { this.endGame(); return; } + if (this.checkForStuck()) return; this.time.delayedCall(420, () => this.stepAI()); }); } else { @@ -785,6 +787,7 @@ export default class SkipBoGame extends Phaser.Scene { this.renderAll(); this.animating = false; if (this.gs.phase === 'gameover') { this.endGame(); return; } + if (this.checkForStuck()) return; if (this.gs.currentPlayer === 0) { this.setStatus('Your turn'); } else { @@ -794,11 +797,48 @@ export default class SkipBoGame extends Phaser.Scene { } } + // ── Stuck / auto-pass ─────────────────────────────────────────────────── + + checkForStuck() { + if (!isPlayerStuck(this.gs)) return false; + const seat = this.gs.currentPlayer; + const name = seat === 0 + ? (auth.user?.username ?? 'You') + : (this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`); + const subject = seat === 0 ? 'You have' : `${name} has`; + this.setStatus(`${subject} no moves — passing turn.`); + this.animating = true; + this.time.delayedCall(1400, () => { + this.gs = applyAutoPass(this.gs); + this.renderAll(); + this.animating = false; + if (this.gs.phase === 'gameover') { this.endGame(); return; } + if (this.checkForStuck()) return; + if (this.gs.currentPlayer !== 0) { + this.runAITurn(); + } else { + this.setStatus('Your turn'); + } + }); + return true; + } + // ── Endgame ───────────────────────────────────────────────────────────── endGame() { this.gameOver = true; const winnerSeat = this.gs.winner; + + if (winnerSeat === -1) { + this.setStatus("It's a draw!"); + for (let s = 1; s < this.gs.players.length; s++) { + this.opponentPortraits[s]?.playEmotion?.('loss'); + } + this.recordHistory(false); + this.showGameOverPanel("It's a draw!", false, null, 'No player could make a move.'); + return; + } + const youWon = winnerSeat === 0; const name = winnerSeat === 0 ? (auth.user?.username ?? 'You') @@ -815,10 +855,12 @@ export default class SkipBoGame extends Phaser.Scene { } this.recordHistory(youWon); - this.showGameOverPanel(msg, youWon, name); + this.showGameOverPanel(msg, youWon, name, youWon + ? 'You emptied your stock first.' + : `${name} emptied their stock first.`); } - showGameOverPanel(msg, youWon, name) { + showGameOverPanel(msg, youWon, name, subtitle) { const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) .setInteractive().setDepth(D.modal); const panelW = 720; @@ -829,9 +871,7 @@ export default class SkipBoGame extends Phaser.Scene { fontFamily: 'Righteous', fontSize: '42px', color: youWon ? COLORS.goldHex : COLORS.textHex, }).setOrigin(0.5).setDepth(D.modal); - this.add.text(CX, CY - 10, youWon - ? 'You emptied your stock first.' - : `${name} emptied their stock first.`, { + this.add.text(CX, CY - 10, subtitle, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.modal); diff --git a/public/src/games/skipbo/SkipBoLogic.js b/public/src/games/skipbo/SkipBoLogic.js index 40ae13c..35ba28b 100644 --- a/public/src/games/skipbo/SkipBoLogic.js +++ b/public/src/games/skipbo/SkipBoLogic.js @@ -85,9 +85,10 @@ export function createInitialState({ playerCount, seed, startingPlayer = 0 } = { completedPile: [], currentPlayer: startingPlayer, phase: 'play', // 'play' | 'gameover' - winner: null, + winner: null, // seat index, or -1 for draw seed: seed ?? null, turnCount: 0, + consecutiveStuckTurns: 0, }; // Top of every stock starts face-up. We model "face-up" implicitly: stock @@ -140,6 +141,7 @@ export function cloneState(state) { winner: state.winner, seed: state.seed, turnCount: state.turnCount, + consecutiveStuckTurns: state.consecutiveStuckTurns, }; } @@ -304,11 +306,48 @@ export function applyDiscard(state, handIdx, discardIdx) { // Advance turn next.currentPlayer = (seat + 1) % next.players.length; next.turnCount += 1; + next.consecutiveStuckTurns = 0; // normal discard resets the stuck counter refillHand(next, next.currentPlayer); return next; } +/** + * Returns true if the current player is completely stuck: + * their hand is empty, the draw pile is exhausted (including completed pile), + * and they have no valid plays from stock or discard tops. + */ +export function isPlayerStuck(state) { + if (state.phase !== 'play') return false; + const seat = state.currentPlayer; + const player = state.players[seat]; + const canDraw = state.drawPile.length > 0 || state.completedPile.length > 0; + if (player.hand.length > 0 || canDraw) return false; + return !hasAnyPlay(state); +} + +/** + * Auto-passes the current player's turn when they are stuck. + * Increments consecutiveStuckTurns; if every player is stuck in a row + * the game ends as a draw (winner === -1). + */ +export function applyAutoPass(state) { + if (state.phase !== 'play') return state; + const next = cloneState(state); + next.consecutiveStuckTurns += 1; + + if (next.consecutiveStuckTurns >= next.players.length) { + next.phase = 'gameover'; + next.winner = -1; // draw + return next; + } + + next.currentPlayer = (next.currentPlayer + 1) % next.players.length; + next.turnCount += 1; + refillHand(next, next.currentPlayer); + return next; +} + // Convenience for UI — count cards in each pile per seat for the always-on // HUD. The top stock card is always visible separately. export function snapshotCounts(state) {