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,
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);

View File

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