feat(farkel): overhaul turn scoring with combined cycle logic and explain panel

- Replace turn.kept running sum with priorKept + selectionScore model so the
  banked total always reflects all set-aside dice in the current cycle, enabling
  higher N-of-a-kind bonuses when extending sets.
- Update bestScoring and hasScoring to consider setAsideDice; use brute-force
  subset search (feasible for ≤6 dice) instead of greedy strategy for correctness.
- Add breakdownScoring helper to decompose a combined dice set into labeled groups.
- Add updateExplainPanel showing "Prior rolls", scoring groups, and total with
  "(need 500)" note when off-board requirement isn't met; format scores >999 as K,DDD.
- Refactor selectionScore/selectionValid to score the combined set (setAsideDice + selection).
- Use bestScoring(rolled, setAsideDice) in onScoreAll and AI for optimal picks.
- Fix scratch paper preview to use priorKept + selectionScore consistently.
- Add hot dice handling: carry cycle score into priorKept and reset setAsideDice.
- Slots: guard card artwork loading to avoid errors when texture is missing; adjust bulb positions accordingly.
This commit is contained in:
Brian Fertig 2026-06-13 12:25:25 -06:00
parent d82a8d676f
commit 519dd617d2
8 changed files with 193 additions and 95 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Binary file not shown.

View File

@ -199,7 +199,7 @@
{
"id": "pharaohs-fortune-card",
"key": "slots-pharaohs-fortune-card",
"path": null
"path": "/assets/images/slots/slots-pharaohs-fortune-card.png"
},
{
"id": "abyssal-treasures-bubble",
@ -254,7 +254,7 @@
{
"id": "abyssal-treasures-card",
"key": "slots-abyssal-treasures-card",
"path": null
"path": "/assets/images/slots/slots-abyssal-treasures-card.png"
},
{
"id": "dragons-hoard-king",

View File

@ -6,10 +6,10 @@ import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { DICE, PIP_POS, PLAYER_COLOR_HEX, SCORING_REFERENCE } from './FarkelData.js';
import { DICE, PIP_POS, PLAYER_COLOR_HEX, SCORING_REFERENCE, ON_BOARD_MIN } from './FarkelData.js';
import {
createInitialState, rollDice, applySetAside, bank, farkleTurn,
scoreSelection, bestScoring, isGameOver, getWinners,
scoreSelection, bestScoring, breakdownScoring, isGameOver, getWinners,
} from './FarkelLogic.js';
import { decideReroll, nextThinkDelay } from './FarkelAI.js';
@ -45,6 +45,11 @@ const DEPTH = {
die: 10, dieSel: 11, ui: 20, toast: 60, modal: 70,
};
// ─── Display helper ────────────────────────────────────────────────────────────
function fmtPts(n) {
return n >= 1000 ? `${Math.floor(n / 1000)},${String(n % 1000).padStart(3, '0')}` : String(n);
}
export default class FarkelGame extends Phaser.Scene {
constructor() { super('FarkelGame'); }
@ -89,6 +94,7 @@ export default class FarkelGame extends Phaser.Scene {
this.buildTray();
this.buildDice();
this.buildShelf();
this.buildExplainPanel();
this.buildScoringPanel();
this.buildScratchPaper();
this.buildButtons();
@ -179,6 +185,38 @@ export default class FarkelGame extends Phaser.Scene {
}).setOrigin(0, 0.5).setDepth(DEPTH.die);
}
buildExplainPanel() {
const shelfLeft = TRAY_CX - TRAY_W / 2 + 4;
const shelfW = DICE * (SDIE + SDIE_GAP) + 12;
const shelfH = SDIE + 70;
const shelfY = SHELF_Y - SDIE / 2 - 46;
const ex = shelfLeft + shelfW + 10;
const ew = TRAY_CX + TRAY_W / 2 - ex - 4;
const ey = shelfY;
const eh = shelfH;
const bg = this.add.graphics().setDepth(DEPTH.die - 1);
bg.fillStyle(0x000000, 0.55);
bg.fillRoundedRect(ex, ey, ew, eh, 8);
bg.lineStyle(2, COLORS.accent, 0.6);
bg.strokeRoundedRect(ex, ey, ew, eh, 8);
this.add.text(ex + 14, ey + 16, 'If you bank:', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.die);
this._explainBounds = { ex, ew, ey, eh };
this._explainLines = [];
for (let i = 0; i < 4; i++) {
this._explainLines.push(
this.add.text(ex + 14, ey + 34 + i * 20, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0, 0).setDepth(DEPTH.die),
);
}
}
buildScoringPanel() {
const rows = SCORING_REFERENCE.length;
const rowH = 40;
@ -282,6 +320,7 @@ export default class FarkelGame extends Phaser.Scene {
this.updateStatus();
this.updateTurnTotal();
this.updateControls();
this.updateExplainPanel();
}
drawDie(g, x, y, size, face, { selected = false, locked = false } = {}) {
@ -422,13 +461,11 @@ export default class FarkelGame extends Phaser.Scene {
for (let seat = 0; seat < this.scratchRows.length; seat++) {
const p = this.gs.players[seat];
const row = this.scratchRows[seat];
const hasPending = this.gs.turn.kept > 0 || (this.gs.phase === 'awaitPick' && this.selectionScore() > 0);
const showTurn = (seat === cur && !isGameOver(this.gs) && hasPending);
if (showTurn) {
const total = (this.gs.phase === 'awaitPick')
? this.gs.turn.kept + this.selectionScore()
: this.gs.turn.kept;
row.score.setText(`${p.score} +${total}`);
if (seat === cur && !isGameOver(this.gs)) {
// selectionScore() returns the combined cycle score (setAsideDice + selection).
// Adding priorKept gives the full prospective bank total in every phase.
const preview = (this.gs.turn.priorKept ?? 0) + this.selectionScore();
row.score.setText(preview > 0 ? `${p.score} +${preview}` : String(p.score));
} else {
row.score.setText(String(p.score));
}
@ -477,10 +514,56 @@ export default class FarkelGame extends Phaser.Scene {
updateTurnTotal() {
const gs = this.gs;
const kept = gs.turn.kept;
const sel = (gs.phase === 'awaitPick') ? this.selectionScore() : 0;
const total = kept + sel;
this.turnTotalText.setText(total > 0 ? `Turn total: ${total}` : '');
const preview = (gs.turn.priorKept ?? 0) + this.selectionScore();
this.turnTotalText.setText(preview > 0 ? `Turn total: ${preview}` : '');
}
updateExplainPanel() {
const gs = this.gs;
const p = gs.players[gs.current];
const priorKept = gs.turn.priorKept ?? 0;
// Combined set: already set-aside dice + pending selection (if any)
const combined = [
...gs.turn.setAsideDice,
...(gs.phase === 'awaitPick' ? this.selectionValues() : []),
];
const groups = breakdownScoring(combined);
const cycleScore = groups.reduce((s, g) => s + g.points, 0);
const totalKept = priorKept + cycleScore;
const bankOk = p.onBoard || totalKept >= ON_BOARD_MIN;
if (groups.length === 0 && priorKept === 0) {
this._explainLines.forEach((t) => t.setText(''));
return;
}
// Gold = preview (selection pending), white = committed
const inPreview = gs.phase === 'awaitPick' && this.selected.size > 0 && this.selectionScore() > 0;
const detailColor = inPreview ? COLORS.goldHex : COLORS.textHex;
let li = 0;
const maxDetail = this._explainLines.length - 1; // reserve last line for total
if (priorKept > 0 && li < maxDetail) {
this._explainLines[li++].setText(`Prior rolls = ${fmtPts(priorKept)}`).setColor(COLORS.textHex);
}
for (const g of groups) {
if (li >= maxDetail) break;
this._explainLines[li++].setText(`${g.label} = ${fmtPts(g.points)}`).setColor(detailColor);
}
// Show total when there are multiple content lines, or when "need 500" applies
const contentLines = (priorKept > 0 ? 1 : 0) + groups.length;
if (totalKept > 0 && (contentLines > 1 || !bankOk) && li < this._explainLines.length) {
const note = bankOk ? '' : ' (need 500)';
this._explainLines[li++]
.setText(`Total = ${fmtPts(totalKept)}${note}`)
.setColor(bankOk ? COLORS.accentHex : COLORS.dangerHex);
}
for (let i = li; i < this._explainLines.length; i++) this._explainLines[i].setText('');
}
updateControls() {
@ -500,16 +583,21 @@ export default class FarkelGame extends Phaser.Scene {
// ── selection helpers ─────────────────────────────────────────────────────────
selectionValues() { return [...this.selected].map((i) => this.gs.turn.rolled[i]); }
// Returns the combined score of all set-aside dice this cycle plus the current
// selection. This IS the value that would become turn.kept if committed.
selectionScore() {
const combined = [...this.gs.turn.setAsideDice, ...this.selectionValues()];
if (combined.length === 0) return 0;
const r = scoreSelection(combined);
return r.valid ? r.points : 0;
}
// A selection is valid when the combined set (set-aside + selection) all score.
selectionValid() {
const values = this.selectionValues();
if (values.length === 0) return false;
return scoreSelection(values).valid;
}
selectionScore() {
const values = this.selectionValues();
if (values.length === 0) return 0;
const r = scoreSelection(values);
return r.valid ? r.points : 0;
return scoreSelection([...this.gs.turn.setAsideDice, ...values]).valid;
}
// ── input ──────────────────────────────────────────────────────────────────────
@ -523,29 +611,8 @@ export default class FarkelGame extends Phaser.Scene {
onScoreAll() {
if (this.busy || !this.isHumanTurn() || this.gs.phase !== 'awaitPick') return;
const rolled = this.gs.turn.rolled;
const kept = this.gs.turn.setAsideDice;
// Try all subsets of rolled dice, combine with kept, find best total score
let bestScore = 0;
let bestIndices = new Set();
for (let mask = 0; mask < (1 << rolled.length); mask++) {
const subset = [];
for (let i = 0; i < rolled.length; i++) {
if (mask & (1 << i)) subset.push(rolled[i]);
}
const combined = [...kept, ...subset];
const result = scoreSelection(combined);
if (result.valid && result.points > bestScore) {
bestScore = result.points;
bestIndices = new Set();
for (let i = 0; i < rolled.length; i++) {
if (mask & (1 << i)) bestIndices.add(i);
}
}
}
this.selected = bestIndices;
const { indices } = bestScoring(this.gs.turn.rolled, this.gs.turn.setAsideDice);
this.selected = new Set(indices);
playSound(this, SFX.PIECE_CLICK);
this.render();
}
@ -627,7 +694,7 @@ export default class FarkelGame extends Phaser.Scene {
this.portraitCtrls[seat]?.controller?.playEmotion?.('upset');
break;
}
const best = bestScoring(this.gs.turn.rolled);
const best = bestScoring(this.gs.turn.rolled, this.gs.turn.setAsideDice);
this.selected = new Set(best.indices);
this.render();
await this.delay(480);

View File

@ -75,42 +75,38 @@ export function scoreSelection(dice) {
return { valid: true, points: best };
}
// Greedy "take everything that scores" — returns the indices into `dice` to set
// aside and the points earned. Used by the AI and the human "Score all" helper.
export function bestScoring(dice) {
const counts = countsOf(dice);
const total = dice.length;
const candidates = [];
// Greedy: all 1s/5s plus every N-of-a-kind.
{
const take = [0, 0, 0, 0, 0, 0, 0];
let pts = 0;
for (let v = 1; v <= 6; v++) {
const c = counts[v];
if (c >= 3) { take[v] = c; pts += bestGroupValue(v, c); }
else if (v === 1) { take[v] = c; pts += c * 100; }
else if (v === 5) { take[v] = c; pts += c * 50; }
// Find the best subset of `dice` (rolled) to set aside given already set-aside
// dice from this turn. Brute-forces all 2^n subsets — feasible for n ≤ 6 (63
// iterations). Returns the indices of rolled dice and the resulting combined score.
export function bestScoring(dice, setAsideDice = []) {
const n = dice.length;
let bestPts = 0;
let bestIndices = [];
for (let mask = 1; mask < (1 << n); mask++) {
const subset = [];
const indices = [];
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) { subset.push(dice[i]); indices.push(i); }
}
const res = scoreSelection([...setAsideDice, ...subset]);
if (res.valid && res.points > bestPts) {
bestPts = res.points;
bestIndices = indices;
}
candidates.push({ take, pts });
}
// Straight / three pairs use all six dice.
if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) {
candidates.push({ take: [0, 1, 1, 1, 1, 1, 1], pts: 1500 });
}
if (total === 6 && [1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) {
candidates.push({ take: counts.slice(), pts: 1500 });
}
const best = candidates.reduce((a, b) => (b.pts > a.pts ? b : a));
const need = best.take.slice();
const indices = [];
dice.forEach((v, i) => { if (need[v] > 0) { indices.push(i); need[v]--; } });
return { indices, points: best.pts };
return { indices: bestIndices, points: bestPts };
}
export function hasScoring(dice) {
return bestScoring(dice).points > 0;
// A roll is NOT a farkle if any subset of the rolled dice, when combined with
// already set-aside dice from this turn, produces a valid scoring set.
export function hasScoring(dice, setAsideDice = []) {
const n = dice.length;
for (let mask = 1; mask < (1 << n); mask++) {
const subset = [];
for (let i = 0; i < n; i++) if (mask & (1 << i)) subset.push(dice[i]);
if (scoreSelection([...setAsideDice, ...subset]).valid) return true;
}
return false;
}
// ── state ────────────────────────────────────────────────────────────────────
@ -142,7 +138,7 @@ export function createInitialState({ playerCount = 4, names = [], skills = {}, s
}
function resetTurn(state) {
state.turn = { rolled: [], available: DICE, kept: 0, setAsideDice: [], hotDice: false };
state.turn = { rolled: [], available: DICE, kept: 0, priorKept: 0, setAsideDice: [], hotDice: false };
state.phase = 'awaitRoll';
}
@ -154,7 +150,7 @@ export function cloneState(s) {
}
// Roll the available dice into turn.rolled. Sets phase to awaitPick, or farkled
// when the roll scores nothing.
// when no subset of the rolled dice (combined with already set-aside dice) scores.
export function rollDice(state) {
const t = state.turn;
const rng = state._rng;
@ -162,30 +158,36 @@ export function rollDice(state) {
for (let i = 0; i < t.available; i++) out.push(1 + Math.floor(rng() * 6));
t.rolled = out;
t.hotDice = false;
state.phase = hasScoring(out) ? 'awaitPick' : 'farkled';
state.phase = hasScoring(out, t.setAsideDice) ? 'awaitPick' : 'farkled';
return state;
}
// Set aside the dice at the given indices (into turn.rolled). Adds their points
// to the turn total. Triggers hot dice when all six are used. Returns true on a
// valid selection.
// Set aside the dice at the given indices (into turn.rolled). The turn score is
// always the combined value of ALL set-aside dice this cycle (not a running sum),
// so extending the set can unlock higher N-of-a-kind bonuses. Triggers hot dice
// when all available dice have been used. Returns true on a valid selection.
export function applySetAside(state, indices) {
const t = state.turn;
const values = indices.map((i) => t.rolled[i]);
const res = scoreSelection(values);
const combined = [...t.setAsideDice, ...values];
const res = scoreSelection(combined);
if (!res.valid) return false;
t.kept += res.points;
t.setAsideDice.push(...values);
t.available -= values.length;
t.rolled = [];
if (t.available === 0) {
// Hot dice — every die scored, so roll all six again.
// Hot dice — carry this cycle's score into priorKept, then reset for new cycle.
t.priorKept += res.points;
t.kept = t.priorKept;
t.available = DICE;
t.setAsideDice = [];
t.hotDice = true;
} else {
t.kept = t.priorKept + res.points;
}
state.phase = 'awaitDecision';
return true;
}
@ -238,3 +240,29 @@ function computeWinners(state) {
export function isGameOver(state) { return state.phase === 'gameover'; }
export function getWinners(state) { return state.winners; }
// ── display helper ───────────────────────────────────────────────────────────
// Returns an array of { label, points } describing the scoring groups in `dice`.
export function breakdownScoring(dice) {
if (!dice || dice.length === 0) return [];
const total = dice.length;
const counts = countsOf(dice);
if (total === 6) {
if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] === 1)) return [{ label: 'Straight 16', points: 1500 }];
if ([1, 2, 3, 4, 5, 6].every((v) => counts[v] % 2 === 0)) return [{ label: 'Three Pairs', points: 1500 }];
}
const K = ['', '', '', 'Three', 'Four', 'Five', 'Six'];
const groups = [];
for (let v = 1; v <= 6; v++) {
const n = counts[v];
if (n === 0) continue;
if (n >= 3) {
groups.push({ label: `${K[n]} ${v}s`, points: bestGroupValue(v, n) });
} else if (v === 1) {
groups.push({ label: n === 1 ? 'Single 1' : 'Two 1s', points: n * 100 });
} else if (v === 5) {
groups.push({ label: n === 1 ? 'Single 5' : 'Two 5s', points: n * 50 });
}
}
return groups;
}

View File

@ -171,13 +171,15 @@ export default class SlotsGame extends Phaser.Scene {
const art = this.add.image(0, -6, cardArtKey).setDisplaySize(w - 16, h - 90);
card.add(art);
}
const name = this.add.text(0, -h / 2 + 52, m.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '30px', color: t.accentHex, align: 'center',
}).setOrigin(0.5).setShadow(0, 0, t.accentHex, 16);
const tag = this.add.text(0, -h / 2 + 86, m.tagline, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5);
card.add([name, tag]);
if (!this.textures.exists(cardArtKey)) {
const name = this.add.text(0, -h / 2 + 52, m.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '30px', color: t.accentHex, align: 'center',
}).setOrigin(0.5).setShadow(0, 0, t.accentHex, 16);
const tag = this.add.text(0, -h / 2 + 86, m.tagline, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5);
card.add([name, tag]);
}
// Era badge.
const era = this.add.text(w / 2 - 16, -h / 2 + 18, m.era.toUpperCase(), {
@ -233,8 +235,9 @@ export default class SlotsGame extends Phaser.Scene {
for (let b = 0; b < bulbs; b++) {
const lx = -w / 2 + 40 + (b * (w - 80)) / (bulbs - 1);
const on = Math.sin(bulbState.phase + b * 0.9) > 0;
const ly = this.textures.exists(cardArtKey) ? 100 : (-h / 2 + 110);
lights.fillStyle(on ? t.glow : 0x1a140c, on ? 0.95 : 0.8);
lights.fillCircle(lx, -h / 2 + 110, 5);
lights.fillCircle(lx, ly, 5);
}
};
drawBulbs();