feat(mastermind): add Exact Placement scoring mode

Introduces a per-peg feedback system where each marker corresponds to the specific slot's result (exact, partial, or none) instead of aggregate counts. Added `scoreGuessPerPeg` logic, updated UI rendering and sound triggers to handle per-peg data, and added a scene toggle for switching between Standard and Exact Placement modes.
This commit is contained in:
Brian Fertig 2026-05-31 12:44:38 -06:00
parent 2c5bcbd478
commit 0bb32359b2
4 changed files with 143 additions and 22 deletions

View File

@ -65,6 +65,7 @@ export default class MastermindGame extends Phaser.Scene {
this.skill = this.opponents[0]?.skill ?? 3;
// Fixed classic configuration: 4 pegs, 6 colours, duplicates, 10 guesses.
this.config = makeConfig(data.codeConfig ?? { pegs: 4, colors: 6, duplicates: true, maxGuesses: 10 });
this.secretRevealType = data.secretRevealType ?? 'standard';
this.phase = 'setup'; // 'setup' | 'duel' | 'over'
this.busy = false;
this.draft = [];
@ -329,20 +330,36 @@ export default class MastermindGame extends Phaser.Scene {
// One feedback marker. Order is exact (green) first, then partial (yellow),
// then dim blanks.
// In "Exact Placement" mode, each marker corresponds to the per-peg result.
makeFeedbackMarker(cx, y, i, entry) {
const pegs = this.config.pegs;
const startX = cx + 95;
const fx = startX + (i % 4) * (FB_R * 2 + 6);
const fy = y - (pegs > 4 ? (i < 4 ? 8 : -8) : 0);
let mk;
if (i < entry.exact) {
mk = this.add.circle(fx, fy, FB_R, C.exact);
mk.setStrokeStyle(2, 0xffffff, 0.8);
} else if (i < entry.exact + entry.partial) {
mk = this.add.circle(fx, fy, FB_R, C.partial, 0);
mk.setStrokeStyle(3, C.partial, 1);
// Per-peg mode (Exact Placement): each slot has its own result.
if (entry.perPeg) {
const result = entry.perPeg[i];
if (result === 'exact') {
mk = this.add.circle(fx, fy, FB_R, C.exact);
mk.setStrokeStyle(2, 0xffffff, 0.8);
} else if (result === 'partial') {
mk = this.add.circle(fx, fy, FB_R, C.partial, 0);
mk.setStrokeStyle(3, C.partial, 1);
} else {
mk = this.add.circle(fx, fy, FB_R - 3, C.dim, 0.6);
}
} else {
mk = this.add.circle(fx, fy, FB_R - 3, C.dim, 0.6);
// Standard count-based mode.
if (i < entry.exact) {
mk = this.add.circle(fx, fy, FB_R, C.exact);
mk.setStrokeStyle(2, 0xffffff, 0.8);
} else if (i < entry.exact + entry.partial) {
mk = this.add.circle(fx, fy, FB_R, C.partial, 0);
mk.setStrokeStyle(3, C.partial, 1);
} else {
mk = this.add.circle(fx, fy, FB_R - 3, C.dim, 0.6);
}
}
return mk;
}
@ -367,8 +384,13 @@ export default class MastermindGame extends Phaser.Scene {
const mk = this.makeFeedbackMarker(cx, y, i, entry);
layer.add(mk);
this.popIn(mk, 0);
if (i < entry.exact) playSound(this, SFX.MASTERMIND_MATCH);
else if (i < entry.exact + entry.partial) playSound(this, SFX.MASTERMIND_COLOR);
if (entry.perPeg) {
if (entry.perPeg[i] === 'exact') playSound(this, SFX.MASTERMIND_MATCH);
else if (entry.perPeg[i] === 'partial') playSound(this, SFX.MASTERMIND_COLOR);
} else {
if (i < entry.exact) playSound(this, SFX.MASTERMIND_MATCH);
else if (i < entry.exact + entry.partial) playSound(this, SFX.MASTERMIND_COLOR);
}
i++;
this.time.delayedCall(500, step);
};
@ -548,7 +570,7 @@ export default class MastermindGame extends Phaser.Scene {
const rowIndex = this.gs.playerGuesses.length;
this.flyPegs(LEFT_CX, guess, this.rowY(rowIndex), PEG_R, () => {
applyGuess(this.gs, 'player', guess);
applyGuess(this.gs, 'player', guess, this.secretRevealType);
// Pegs are already flown in; hold the feedback back for a staggered reveal.
this.renderPanels('left', { pegsPop: false, hideFeedbackRow: rowIndex });
this.revealFeedback(this.leftLayer, LEFT_CX, rowIndex, this.gs.playerGuesses[rowIndex], () => {
@ -569,7 +591,7 @@ export default class MastermindGame extends Phaser.Scene {
this.time.delayedCall(nextThinkDelay(this.skill), () => {
this.scramble(rowIndex, guess, () => {
applyGuess(this.gs, 'ai', guess);
applyGuess(this.gs, 'ai', guess, this.secretRevealType);
this.renderPanels('right', { hideFeedbackRow: rowIndex });
this.glitch(160);
this.revealFeedback(this.rightLayer, RIGHT_CX, rowIndex, this.gs.aiGuesses[rowIndex], () => {

View File

@ -33,6 +33,40 @@ export function randomCode(config) {
return pool.slice(0, pegs);
}
// Per-peg scoring for "Exact Placement" mode. Evaluates each slot in order:
// 'exact' = right color & position, 'partial' = right color elsewhere,
// 'none' = color not in code. Each code position can only be matched once.
export function scoreGuessPerPeg(guess, code) {
const n = guess.length;
const result = new Array(n);
const codeUsed = new Array(n).fill(false);
// First pass: exact matches consume the matching code position.
for (let i = 0; i < n; i++) {
if (guess[i] === code[i]) {
result[i] = 'exact';
codeUsed[i] = true;
}
}
// Second pass: for non-exact slots, look for the colour in unused code positions.
for (let i = 0; i < n; i++) {
if (result[i] === 'exact') continue;
for (let j = 0; j < n; j++) {
if (!codeUsed[j] && guess[i] === code[j]) {
result[i] = 'partial';
codeUsed[j] = true;
break;
}
}
if (result[i] !== 'partial') {
result[i] = 'none';
}
}
return result;
}
// Standard count-based scoring. Exact matches first, then partials from the
// leftover color tallies on each side.
export function scoreGuess(guess, code) {
@ -93,20 +127,28 @@ export function setPlayerCode(state, code) {
return state;
}
function record(state, side, guess) {
function record(state, side, guess, secretRevealType) {
const target = side === 'player' ? state.aiCode : state.playerCode;
const fb = scoreGuess(guess, target);
const entry = { guess: guess.slice(), exact: fb.exact, partial: fb.partial };
let fb;
if (secretRevealType === 'exact') {
fb = { perPeg: scoreGuessPerPeg(guess, target) };
} else {
fb = scoreGuess(guess, target);
}
const entry = Object.assign({ guess: guess.slice() }, fb);
(side === 'player' ? state.playerGuesses : state.aiGuesses).push(entry);
return entry;
}
// Apply a guess for `side` ('player'|'ai'), update history, and resolve the
// game-over / winner state. Returns the feedback entry that was recorded.
export function applyGuess(state, side, guess) {
const entry = record(state, side, guess);
export function applyGuess(state, side, guess, secretRevealType) {
const entry = record(state, side, guess, secretRevealType);
const pegs = state.config.pegs;
const cracked = entry.exact === pegs;
const exactCount = entry.perPeg
? entry.perPeg.filter((p) => p === 'exact').length
: entry.exact;
const cracked = exactCount === pegs;
if (cracked) {
state.winner = side;
@ -126,7 +168,12 @@ export function applyGuess(state, side, guess) {
}
function bestExact(guesses) {
return guesses.reduce((m, g) => Math.max(m, g.exact), 0);
return guesses.reduce((m, g) => {
if (g.perPeg) {
return Math.max(m, g.perPeg.filter((p) => p === 'exact').length);
}
return Math.max(m, g.exact);
}, 0);
}
function resolveExhausted(state) {

View File

@ -16,7 +16,8 @@ export default class GameRoomScene extends Phaser.Scene {
this.expansion = data.expansion ?? 'base';
this.scenario = data.scenario ?? null;
this.deckMode = data.deckMode ?? 'standard';
this.wordLength = data.wordLength ?? 4;
this.wordLength = data.wordLength ?? 4;
this.secretRevealType = data.secretRevealType ?? 'standard';
}
create() {
@ -31,7 +32,8 @@ export default class GameRoomScene extends Phaser.Scene {
expansion: this.expansion,
scenario: this.scenario,
deckMode: this.deckMode,
wordLength: this.wordLength,
wordLength: this.wordLength,
secretRevealType: this.secretRevealType,
});
return;
}

View File

@ -37,6 +37,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this.selectedSecretRevealType = 'standard';
this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
}
@ -128,6 +129,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (this.gameDef.slug === 'wordladder') this.buildWordLengthSection(340, 1013);
if (this.gameDef.slug === 'mastermind') this.buildSecretRevealTypeSection(340, 1013);
if (!isWordGame) {
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
@ -862,6 +865,52 @@ export default class OpponentSelectScene extends Phaser.Scene {
});
}
// ── Mastermind: secret reveal type toggle ─────────────────────────────────
buildSecretRevealTypeSection(centerX, centerY) {
const options = [
{ id: 'standard', label: 'Standard Rules' },
{ id: 'exact', label: 'Exact Placement' },
];
const pillW = 150, pillH = 40, pillGap = 12;
const totalW = options.length * pillW + (options.length - 1) * pillGap;
const labelY = centerY - 28;
const pillY = centerY + 10;
const labelText = this.add.text(centerX, labelY, 'Secret Reveal Type', {
fontFamily: '"Julius Sans One"',
fontSize: '20px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
this.children.moveBelow(labelBg, labelText);
this._secretRevealTypeBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedSecretRevealType === opt.id;
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(labelBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._secretRevealTypeBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedSecretRevealType ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedSecretRevealType = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedSecretRevealType !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedSecretRevealType !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._secretRevealTypeBtns.push({ bg, id: opt.id });
});
}
// ── Generic option section builder ─────────────────────────────────────────
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
@ -986,8 +1035,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
expansion: this.selectedExpansion,
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
? this.selectedScenario : null,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
secretRevealType: this.selectedSecretRevealType,
});
}
}