feat: implement auto-pass and draw handling for SkipBo

- Add `isPlayerStuck` and `applyAutoPass` logic to SkipBoLogic.
- Handle stuck players by automatically passing their turn.
- Detect draw conditions when all players are stuck consecutively.
- Update UI to display appropriate messages for auto-passes and draws.
- Pass subtitle text to `showGameOverPanel` for clearer win/loss/draw context.
This commit is contained in:
Brian Fertig 2026-05-17 11:33:01 -06:00
parent 70573a8ddd
commit 792fd35fd4
2 changed files with 89 additions and 10 deletions

View File

@ -9,6 +9,7 @@ import {
DISCARD_PILE_COUNT, DISCARD_PILE_COUNT,
HAND_SIZE, HAND_SIZE,
STOCK_SIZE, STOCK_SIZE,
applyAutoPass,
applyDiscard, applyDiscard,
applyPlay, applyPlay,
buildPileTopValue, buildPileTopValue,
@ -16,6 +17,7 @@ import {
createInitialState, createInitialState,
discardTop, discardTop,
getValidPlays, getValidPlays,
isPlayerStuck,
nextRequired, nextRequired,
stockTop, stockTop,
} from './SkipBoLogic.js'; } from './SkipBoLogic.js';
@ -666,10 +668,8 @@ export default class SkipBoGame extends Phaser.Scene {
this.gs = next; this.gs = next;
this.renderAll(); this.renderAll();
this.animating = false; this.animating = false;
if (this.gs.phase === 'gameover') { if (this.gs.phase === 'gameover') { this.endGame(); return; }
this.endGame(); if (this.checkForStuck()) return;
return;
}
// After a successful play it's still the same player's turn until they // After a successful play it's still the same player's turn until they
// discard. If AI's turn, continue. // discard. If AI's turn, continue.
if (this.gs.currentPlayer !== 0) { if (this.gs.currentPlayer !== 0) {
@ -688,6 +688,7 @@ export default class SkipBoGame extends Phaser.Scene {
this.renderAll(); this.renderAll();
this.animating = false; this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; } if (this.gs.phase === 'gameover') { this.endGame(); return; }
if (this.checkForStuck()) return;
if (this.gs.currentPlayer !== 0) { if (this.gs.currentPlayer !== 0) {
this.runAITurn(); this.runAITurn();
} else { } else {
@ -775,6 +776,7 @@ export default class SkipBoGame extends Phaser.Scene {
this.renderAll(); this.renderAll();
this.animating = false; this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; } if (this.gs.phase === 'gameover') { this.endGame(); return; }
if (this.checkForStuck()) return;
this.time.delayedCall(420, () => this.stepAI()); this.time.delayedCall(420, () => this.stepAI());
}); });
} else { } else {
@ -785,6 +787,7 @@ export default class SkipBoGame extends Phaser.Scene {
this.renderAll(); this.renderAll();
this.animating = false; this.animating = false;
if (this.gs.phase === 'gameover') { this.endGame(); return; } if (this.gs.phase === 'gameover') { this.endGame(); return; }
if (this.checkForStuck()) return;
if (this.gs.currentPlayer === 0) { if (this.gs.currentPlayer === 0) {
this.setStatus('Your turn'); this.setStatus('Your turn');
} else { } 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 ─────────────────────────────────────────────────────────────
endGame() { endGame() {
this.gameOver = true; this.gameOver = true;
const winnerSeat = this.gs.winner; 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 youWon = winnerSeat === 0;
const name = winnerSeat === 0 const name = winnerSeat === 0
? (auth.user?.username ?? 'You') ? (auth.user?.username ?? 'You')
@ -815,10 +855,12 @@ export default class SkipBoGame extends Phaser.Scene {
} }
this.recordHistory(youWon); 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) const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setInteractive().setDepth(D.modal); .setInteractive().setDepth(D.modal);
const panelW = 720; const panelW = 720;
@ -829,9 +871,7 @@ export default class SkipBoGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '42px', fontFamily: 'Righteous', fontSize: '42px',
color: youWon ? COLORS.goldHex : COLORS.textHex, color: youWon ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal); }).setOrigin(0.5).setDepth(D.modal);
this.add.text(CX, CY - 10, youWon this.add.text(CX, CY - 10, subtitle, {
? 'You emptied your stock first.'
: `${name} emptied their stock first.`, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.modal); }).setOrigin(0.5).setDepth(D.modal);

View File

@ -85,9 +85,10 @@ export function createInitialState({ playerCount, seed, startingPlayer = 0 } = {
completedPile: [], completedPile: [],
currentPlayer: startingPlayer, currentPlayer: startingPlayer,
phase: 'play', // 'play' | 'gameover' phase: 'play', // 'play' | 'gameover'
winner: null, winner: null, // seat index, or -1 for draw
seed: seed ?? null, seed: seed ?? null,
turnCount: 0, turnCount: 0,
consecutiveStuckTurns: 0,
}; };
// Top of every stock starts face-up. We model "face-up" implicitly: stock // Top of every stock starts face-up. We model "face-up" implicitly: stock
@ -140,6 +141,7 @@ export function cloneState(state) {
winner: state.winner, winner: state.winner,
seed: state.seed, seed: state.seed,
turnCount: state.turnCount, turnCount: state.turnCount,
consecutiveStuckTurns: state.consecutiveStuckTurns,
}; };
} }
@ -304,11 +306,48 @@ export function applyDiscard(state, handIdx, discardIdx) {
// Advance turn // Advance turn
next.currentPlayer = (seat + 1) % next.players.length; next.currentPlayer = (seat + 1) % next.players.length;
next.turnCount += 1; next.turnCount += 1;
next.consecutiveStuckTurns = 0; // normal discard resets the stuck counter
refillHand(next, next.currentPlayer); refillHand(next, next.currentPlayer);
return next; 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 // Convenience for UI — count cards in each pile per seat for the always-on
// HUD. The top stock card is always visible separately. // HUD. The top stock card is always visible separately.
export function snapshotCounts(state) { export function snapshotCounts(state) {