603 lines
22 KiB
JavaScript
603 lines
22 KiB
JavaScript
// Mahjong (Hong Kong style) — pure game engine. No Phaser, no timers.
|
||
// Deterministic given a seed so AI self-play is reproducible.
|
||
//
|
||
// One session is one full East round: every player deals at least once; the
|
||
// dealer repeats after a dealer win or a wall-exhausted draw (goulash). Chow
|
||
// claims come only from the player to the left; pung/kong/win from anyone.
|
||
// Flowers and seasons are revealed and replaced immediately. Kong and bonus
|
||
// replacement tiles draw from the back of the live wall (no reserved dead
|
||
// wall). Deliberately out of scope: robbing the kong, rare limit hands other
|
||
// than Thirteen Orphans, and sacred-discard etiquette rules.
|
||
//
|
||
// Hands are stored merged: while a player is awaiting discard their hand
|
||
// holds 14 − 3·melds tiles (the drawn tile already in it, with state.drawnTile
|
||
// recording which kind arrived); otherwise 13 − 3·melds.
|
||
|
||
import {
|
||
buildWall, isSuited, isHonor, isBonus, rankOf,
|
||
WIND_KINDS, DRAGON_KINDS, FIRST_BONUS,
|
||
FAAN_BY_ID, MIN_FAAN, LIMIT_FAAN, basePoints,
|
||
} from './MahjongData.js';
|
||
|
||
// ── seeded RNG (mulberry32) ──────────────────────────────────────────────────
|
||
function mulberry32(seed) {
|
||
let a = seed >>> 0;
|
||
return function () {
|
||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
// ── small helpers ────────────────────────────────────────────────────────────
|
||
export function counts34(tiles) {
|
||
const c = new Array(34).fill(0);
|
||
for (const k of tiles) c[k]++;
|
||
return c;
|
||
}
|
||
|
||
const sortHand = (hand) => hand.sort((a, b) => a - b);
|
||
|
||
export const seatWindOf = (state, seat) => (seat - state.dealer + 4) % 4;
|
||
|
||
// ── state ────────────────────────────────────────────────────────────────────
|
||
export function createInitialState({ names = [], skills = {}, seed } = {}) {
|
||
const rng = mulberry32((seed ?? (Date.now() & 0x7fffffff)) | 0);
|
||
const players = [];
|
||
for (let seat = 0; seat < 4; seat++) {
|
||
players.push({
|
||
name: names[seat] ?? `Player ${seat + 1}`,
|
||
seat,
|
||
score: 0,
|
||
skill: skills[seat] ?? 3,
|
||
isAI: seat !== 0,
|
||
hand: [],
|
||
melds: [], // { type: 'chow'|'pung'|'kong', kinds, concealed, from }
|
||
bonus: [], // revealed flowers / seasons
|
||
});
|
||
}
|
||
const state = {
|
||
players,
|
||
wall: [], wallPos: 0, wallEnd: 143,
|
||
dealer: 0,
|
||
roundWind: 0, // East round only — one full round per session
|
||
handNumber: 1,
|
||
turn: 0,
|
||
phase: 'awaitDiscard', // awaitDiscard | awaitClaims | handOver | gameOver
|
||
drawnTile: null,
|
||
lastDrawWasKongReplacement: false,
|
||
lastDiscard: null, // { kind, from }
|
||
discards: [[], [], [], []],
|
||
result: null,
|
||
winners: [],
|
||
_rng: rng,
|
||
};
|
||
dealHand(state);
|
||
return state;
|
||
}
|
||
|
||
export function cloneState(s) {
|
||
const rngState = s._rng; // functions don't clone; lookahead must not draw tiles
|
||
const copy = JSON.parse(JSON.stringify({ ...s, _rng: undefined }));
|
||
copy._rng = rngState;
|
||
return copy;
|
||
}
|
||
|
||
export function dealHand(state) {
|
||
const rng = state._rng;
|
||
const wall = buildWall();
|
||
for (let i = wall.length - 1; i > 0; i--) {
|
||
const j = Math.floor(rng() * (i + 1));
|
||
[wall[i], wall[j]] = [wall[j], wall[i]];
|
||
}
|
||
state.wall = wall;
|
||
state.wallPos = 0;
|
||
state.wallEnd = wall.length - 1;
|
||
state.discards = [[], [], [], []];
|
||
state.result = null;
|
||
state.lastDiscard = null;
|
||
state.drawnTile = null;
|
||
state.lastDrawWasKongReplacement = false;
|
||
for (const p of state.players) { p.hand = []; p.melds = []; p.bonus = []; }
|
||
|
||
// 13 tiles each (dealer first), then reveal and replace bonus tiles in the
|
||
// same order. The wall cannot run out during the deal.
|
||
for (let i = 0; i < 4; i++) {
|
||
const p = state.players[(state.dealer + i) % 4];
|
||
for (let n = 0; n < 13; n++) p.hand.push(state.wall[state.wallPos++]);
|
||
}
|
||
for (let i = 0; i < 4; i++) {
|
||
const p = state.players[(state.dealer + i) % 4];
|
||
for (let h = 0; h < p.hand.length; h++) {
|
||
while (isBonus(p.hand[h])) {
|
||
p.bonus.push(p.hand[h]);
|
||
p.hand[h] = state.wall[state.wallEnd--];
|
||
}
|
||
}
|
||
sortHand(p.hand);
|
||
}
|
||
|
||
state.turn = state.dealer;
|
||
drawForCurrent(state); // dealer's 14th tile
|
||
return state;
|
||
}
|
||
|
||
// Draw from the front of the wall for the player on turn. Bonus tiles are
|
||
// revealed and replaced from the back. Ends the hand in a draw when the wall
|
||
// is exhausted.
|
||
export function drawForCurrent(state) {
|
||
if (state.wallPos > state.wallEnd) return endInDraw(state);
|
||
const p = state.players[state.turn];
|
||
let k = state.wall[state.wallPos++];
|
||
while (isBonus(k)) {
|
||
p.bonus.push(k);
|
||
if (state.wallPos > state.wallEnd) return endInDraw(state);
|
||
k = state.wall[state.wallEnd--];
|
||
}
|
||
p.hand.push(k);
|
||
sortHand(p.hand);
|
||
state.drawnTile = k;
|
||
state.lastDrawWasKongReplacement = false;
|
||
state.phase = 'awaitDiscard';
|
||
return state;
|
||
}
|
||
|
||
// Replacement draw after a kong — from the back of the wall.
|
||
function drawReplacement(state) {
|
||
const p = state.players[state.turn];
|
||
for (;;) {
|
||
if (state.wallPos > state.wallEnd) return endInDraw(state);
|
||
const k = state.wall[state.wallEnd--];
|
||
if (isBonus(k)) { p.bonus.push(k); continue; }
|
||
p.hand.push(k);
|
||
sortHand(p.hand);
|
||
state.drawnTile = k;
|
||
state.lastDrawWasKongReplacement = true;
|
||
state.phase = 'awaitDiscard';
|
||
return state;
|
||
}
|
||
}
|
||
|
||
function endInDraw(state) {
|
||
state.drawnTile = null;
|
||
state.result = { type: 'draw' };
|
||
state.phase = 'handOver';
|
||
return state;
|
||
}
|
||
|
||
const removeFromHand = (hand, kind, n) => {
|
||
for (let i = 0; i < n; i++) hand.splice(hand.indexOf(kind), 1);
|
||
};
|
||
|
||
// Discard `kind` from the hand of the player on turn. Returns false if the
|
||
// tile isn't held.
|
||
export function discardTile(state, kind) {
|
||
if (state.phase !== 'awaitDiscard') return false;
|
||
const p = state.players[state.turn];
|
||
if (!p.hand.includes(kind)) return false;
|
||
removeFromHand(p.hand, kind, 1);
|
||
state.discards[state.turn].push(kind);
|
||
state.lastDiscard = { kind, from: state.turn };
|
||
state.drawnTile = null;
|
||
state.lastDrawWasKongReplacement = false;
|
||
state.phase = 'awaitClaims';
|
||
return true;
|
||
}
|
||
|
||
// What `seat` may do with the tile just discarded. Null when nothing.
|
||
// Chows only off the player to the left. Win is only offered when the hand
|
||
// would score at least MIN_FAAN.
|
||
export function claimOptionsFor(state, seat) {
|
||
if (state.phase !== 'awaitClaims' || !state.lastDiscard) return null;
|
||
const { kind, from } = state.lastDiscard;
|
||
if (seat === from) return null;
|
||
const p = state.players[seat];
|
||
const n = p.hand.filter((t) => t === kind).length;
|
||
const opts = { win: false, pung: n >= 2, kong: n >= 3, chows: [] };
|
||
if (seat === (from + 1) % 4 && isSuited(kind)) {
|
||
const r = rankOf(kind);
|
||
const has = (kk) => p.hand.includes(kk);
|
||
if (r >= 3 && has(kind - 2) && has(kind - 1)) opts.chows.push([kind - 2, kind - 1]);
|
||
if (r >= 2 && r <= 8 && has(kind - 1) && has(kind + 1)) opts.chows.push([kind - 1, kind + 1]);
|
||
if (r <= 7 && has(kind + 1) && has(kind + 2)) opts.chows.push([kind + 1, kind + 2]);
|
||
}
|
||
if (canWinWith(p.hand, p.melds, kind)) {
|
||
opts.win = evaluateWin(state, seat, kind, true).faan >= MIN_FAAN;
|
||
}
|
||
return (opts.win || opts.pung || opts.kong || opts.chows.length) ? opts : null;
|
||
}
|
||
|
||
// Resolve all claim intents on the current discard. intents is an array of
|
||
// { seat, claim } where claim is null (pass) or { type, tiles? }. Priority:
|
||
// win beats kong/pung beats chow; among multiple wins the seat nearest
|
||
// counter-clockwise from the discarder takes it (one-winner rule). With no
|
||
// claims the next player simply draws. Returns { applied } for animation.
|
||
export function resolveClaims(state, intents) {
|
||
if (state.phase !== 'awaitClaims' || !state.lastDiscard) return { applied: null };
|
||
const { kind, from } = state.lastDiscard;
|
||
const bySeat = new Map();
|
||
for (const i of intents) if (i && i.claim) bySeat.set(i.seat, i.claim);
|
||
|
||
let chosen = null;
|
||
for (let d = 1; d <= 3 && !chosen; d++) {
|
||
const s = (from + d) % 4;
|
||
if (bySeat.get(s)?.type === 'win') chosen = { seat: s, claim: bySeat.get(s) };
|
||
}
|
||
if (!chosen) {
|
||
for (const [s, c] of bySeat) {
|
||
if (c.type === 'kong' || c.type === 'pung') { chosen = { seat: s, claim: c }; break; }
|
||
}
|
||
}
|
||
if (!chosen) {
|
||
for (const [s, c] of bySeat) if (c.type === 'chow') { chosen = { seat: s, claim: c }; break; }
|
||
}
|
||
|
||
if (!chosen) {
|
||
state.lastDiscard = null;
|
||
state.turn = (from + 1) % 4;
|
||
drawForCurrent(state);
|
||
return { applied: null };
|
||
}
|
||
|
||
const p = state.players[chosen.seat];
|
||
const c = chosen.claim;
|
||
if (c.type === 'win') {
|
||
declareWin(state, chosen.seat, { byDiscard: true });
|
||
} else if (c.type === 'pung') {
|
||
state.discards[from].pop();
|
||
removeFromHand(p.hand, kind, 2);
|
||
p.melds.push({ type: 'pung', kinds: [kind, kind, kind], concealed: false, from });
|
||
state.turn = chosen.seat;
|
||
state.drawnTile = null;
|
||
state.lastDiscard = null;
|
||
state.phase = 'awaitDiscard';
|
||
} else if (c.type === 'kong') {
|
||
state.discards[from].pop();
|
||
removeFromHand(p.hand, kind, 3);
|
||
p.melds.push({ type: 'kong', kinds: [kind, kind, kind, kind], concealed: false, from });
|
||
state.turn = chosen.seat;
|
||
state.lastDiscard = null;
|
||
drawReplacement(state);
|
||
} else { // chow
|
||
state.discards[from].pop();
|
||
const [a, b] = c.tiles;
|
||
removeFromHand(p.hand, a, 1);
|
||
removeFromHand(p.hand, b, 1);
|
||
p.melds.push({ type: 'chow', kinds: [a, b, kind].sort((x, y) => x - y), concealed: false, from });
|
||
state.turn = chosen.seat;
|
||
state.drawnTile = null;
|
||
state.lastDiscard = null;
|
||
state.phase = 'awaitDiscard';
|
||
}
|
||
return { applied: chosen };
|
||
}
|
||
|
||
// Options for the player on turn while awaiting discard: self-drawn win and
|
||
// kong declarations. Self-win is only possible on a drawn tile (a hand
|
||
// completed by claiming would have been won off the discard instead).
|
||
export function selfActions(state) {
|
||
const res = { canWin: false, concealedKongs: [], addedKongs: [] };
|
||
if (state.phase !== 'awaitDiscard') return res;
|
||
const p = state.players[state.turn];
|
||
const c = counts34(p.hand);
|
||
for (let k = 0; k < 34; k++) if (c[k] === 4) res.concealedKongs.push(k);
|
||
for (const m of p.melds) {
|
||
if (m.type === 'pung' && p.hand.includes(m.kinds[0])) res.addedKongs.push(m.kinds[0]);
|
||
}
|
||
if (state.drawnTile !== null) {
|
||
const need = 4 - p.melds.length;
|
||
if (isThirteenOrphans(c) && p.melds.length === 0) {
|
||
res.canWin = evaluateWin(state, state.turn, state.drawnTile, false).faan >= MIN_FAAN;
|
||
} else if (decompose(c, need).length > 0) {
|
||
res.canWin = evaluateWin(state, state.turn, state.drawnTile, false).faan >= MIN_FAAN;
|
||
}
|
||
}
|
||
return res;
|
||
}
|
||
|
||
// Declare a concealed or added kong for the player on turn, then draw the
|
||
// replacement tile. (Exposed kongs of a discard go through resolveClaims.)
|
||
export function declareKong(state, { type, kind }) {
|
||
if (state.phase !== 'awaitDiscard') return false;
|
||
const p = state.players[state.turn];
|
||
if (type === 'concealed') {
|
||
if (p.hand.filter((t) => t === kind).length !== 4) return false;
|
||
removeFromHand(p.hand, kind, 4);
|
||
p.melds.push({ type: 'kong', kinds: [kind, kind, kind, kind], concealed: true, from: null });
|
||
} else if (type === 'added') {
|
||
const m = p.melds.find((x) => x.type === 'pung' && x.kinds[0] === kind);
|
||
if (!m || !p.hand.includes(kind)) return false;
|
||
removeFromHand(p.hand, kind, 1);
|
||
m.type = 'kong';
|
||
m.kinds.push(kind);
|
||
} else {
|
||
return false;
|
||
}
|
||
drawReplacement(state);
|
||
return true;
|
||
}
|
||
|
||
// Declare a win for `seat` — self-drawn or off the current discard. Applies
|
||
// payments and ends the hand.
|
||
export function declareWin(state, seat, { byDiscard }) {
|
||
const winTile = byDiscard ? state.lastDiscard.kind : state.drawnTile;
|
||
const res = evaluateWin(state, seat, winTile, byDiscard);
|
||
const discarder = byDiscard ? state.lastDiscard.from : null;
|
||
if (byDiscard) state.discards[discarder].pop();
|
||
for (let s = 0; s < 4; s++) state.players[s].score += res.payments[s];
|
||
state.result = {
|
||
type: 'win', winner: seat, byDiscard, discarder, winTile,
|
||
faanList: res.faanList, faan: res.faan, base: res.base, payments: res.payments,
|
||
};
|
||
state.lastDiscard = null;
|
||
state.phase = 'handOver';
|
||
return state;
|
||
}
|
||
|
||
// Advance to the next hand. The dealer repeats after a dealer win or a drawn
|
||
// hand; otherwise the deal passes on, and once the fourth dealer's turn ends
|
||
// the round — and the session — is over.
|
||
export function startNextHand(state) {
|
||
if (state.phase !== 'handOver') return false;
|
||
const r = state.result;
|
||
const repeat = r.type === 'draw' || (r.type === 'win' && r.winner === state.dealer);
|
||
if (!repeat) {
|
||
if (state.dealer === 3) {
|
||
state.phase = 'gameOver';
|
||
state.winners = computeWinners(state);
|
||
return true;
|
||
}
|
||
state.dealer += 1;
|
||
}
|
||
state.handNumber += 1;
|
||
dealHand(state);
|
||
return true;
|
||
}
|
||
|
||
function computeWinners(state) {
|
||
let max = -Infinity;
|
||
state.players.forEach((p) => { if (p.score > max) max = p.score; });
|
||
return state.players.filter((p) => p.score === max).map((p) => p.seat);
|
||
}
|
||
|
||
export function isGameOver(state) { return state.phase === 'gameOver'; }
|
||
export function getWinners(state) { return state.winners; }
|
||
|
||
// ── hand evaluation ──────────────────────────────────────────────────────────
|
||
|
||
// All ways to read `counts` as `need` sets plus a pair. Returns an array of
|
||
// { pair, sets: [{ type: 'chow'|'pung', kind }] } — possibly with duplicate
|
||
// readings, which is harmless since the evaluator takes the max-faan one.
|
||
export function decompose(counts, need = 4) {
|
||
const total = counts.reduce((a, b) => a + b, 0);
|
||
if (total !== need * 3 + 2) return [];
|
||
const out = [];
|
||
const c = counts.slice();
|
||
const sets = [];
|
||
let pairKind = -1;
|
||
const rec = (start) => {
|
||
let k = start;
|
||
while (k < 34 && c[k] === 0) k++;
|
||
if (k >= 34) { out.push({ pair: pairKind, sets: sets.slice() }); return; }
|
||
if (c[k] >= 3) {
|
||
c[k] -= 3; sets.push({ type: 'pung', kind: k });
|
||
rec(k);
|
||
sets.pop(); c[k] += 3;
|
||
}
|
||
if (isSuited(k) && rankOf(k) <= 7 && c[k + 1] > 0 && c[k + 2] > 0) {
|
||
c[k]--; c[k + 1]--; c[k + 2]--; sets.push({ type: 'chow', kind: k });
|
||
rec(k);
|
||
sets.pop(); c[k]++; c[k + 1]++; c[k + 2]++;
|
||
}
|
||
};
|
||
for (let p = 0; p < 34; p++) {
|
||
if (c[p] < 2) continue;
|
||
c[p] -= 2; pairKind = p;
|
||
rec(0);
|
||
c[p] += 2;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const ORPHAN_KINDS = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
|
||
|
||
export function isThirteenOrphans(counts) {
|
||
let total = 0, dup = 0;
|
||
for (const k of ORPHAN_KINDS) {
|
||
if (counts[k] === 0) return false;
|
||
if (counts[k] >= 2) dup++;
|
||
total += counts[k];
|
||
}
|
||
return total === 14 && dup === 1;
|
||
}
|
||
|
||
// Would adding `kind` to this concealed hand complete it?
|
||
export function canWinWith(hand, melds, kind) {
|
||
const c = counts34(hand);
|
||
c[kind]++;
|
||
if (melds.length === 0 && isThirteenOrphans(c)) return true;
|
||
return decompose(c, 4 - melds.length).length > 0;
|
||
}
|
||
|
||
// Every kind a 13 − 3·melds hand is waiting on.
|
||
export function winningTiles(hand, melds) {
|
||
const out = [];
|
||
for (let k = 0; k < 34; k++) if (canWinWith(hand, melds, k)) out.push(k);
|
||
return out;
|
||
}
|
||
|
||
// Standard-form shanten (sets·2 + partials + pair maximisation), min'd with
|
||
// Thirteen Orphans when fully concealed. −1 means a complete hand. Used by
|
||
// the AI; counts must hold 13 − 3·meldCount tiles (or 14 − 3·meldCount when
|
||
// measuring a hand that still has to discard — the result is then the
|
||
// shanten after its best discard).
|
||
export function shanten(counts, meldCount = 0) {
|
||
const maxSets = 4 - meldCount;
|
||
const c = counts.slice(0, 34);
|
||
let bestValue = 0;
|
||
const rec = (start, sets, partials, pair) => {
|
||
// partials beyond block capacity (4 sets total incl. melds) don't count
|
||
const value = sets * 2 + Math.min(partials, maxSets - sets) + (pair ? 1 : 0);
|
||
if (value > bestValue) bestValue = value;
|
||
// even turning everything left into sets cannot beat the best found
|
||
if (value + (maxSets - sets) * 2 + (pair ? 0 : 1) <= bestValue) return;
|
||
let k = start;
|
||
while (k < 34 && c[k] === 0) k++;
|
||
if (k >= 34) return;
|
||
if (sets < maxSets) {
|
||
if (c[k] >= 3) {
|
||
c[k] -= 3;
|
||
rec(k, sets + 1, partials, pair);
|
||
c[k] += 3;
|
||
}
|
||
if (isSuited(k) && rankOf(k) <= 7 && c[k + 1] > 0 && c[k + 2] > 0) {
|
||
c[k]--; c[k + 1]--; c[k + 2]--;
|
||
rec(k, sets + 1, partials, pair);
|
||
c[k]++; c[k + 1]++; c[k + 2]++;
|
||
}
|
||
}
|
||
if (!pair && c[k] >= 2) {
|
||
c[k] -= 2;
|
||
rec(k, sets, partials, true);
|
||
c[k] += 2;
|
||
}
|
||
if (sets + partials < maxSets) {
|
||
if (c[k] >= 2) {
|
||
c[k] -= 2;
|
||
rec(k, sets, partials + 1, pair);
|
||
c[k] += 2;
|
||
}
|
||
if (isSuited(k) && rankOf(k) <= 8 && c[k + 1] > 0) {
|
||
c[k]--; c[k + 1]--;
|
||
rec(k, sets, partials + 1, pair);
|
||
c[k]++; c[k + 1]++;
|
||
}
|
||
if (isSuited(k) && rankOf(k) <= 7 && c[k + 2] > 0) {
|
||
c[k]--; c[k + 2]--;
|
||
rec(k, sets, partials + 1, pair);
|
||
c[k]++; c[k + 2]++;
|
||
}
|
||
}
|
||
c[k]--; // leave this tile as a floater
|
||
rec(k, sets, partials, pair);
|
||
c[k]++;
|
||
};
|
||
rec(0, 0, 0, false);
|
||
let best = maxSets * 2 - bestValue;
|
||
if (meldCount === 0) {
|
||
let kinds = 0, hasDup = false;
|
||
for (const k of ORPHAN_KINDS) {
|
||
if (counts[k] >= 1) kinds++;
|
||
if (counts[k] >= 2) hasDup = true;
|
||
}
|
||
const sh13 = 13 - kinds - (hasDup ? 1 : 0);
|
||
if (sh13 < best) best = sh13;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ── faan scoring ─────────────────────────────────────────────────────────────
|
||
|
||
const row = (id, times = 1) => {
|
||
const r = FAAN_BY_ID[id];
|
||
const out = [];
|
||
for (let i = 0; i < times; i++) out.push({ id: r.id, label: r.label, faan: r.faan });
|
||
return out;
|
||
};
|
||
|
||
// Faan rows for one reading of the hand (concealed decomposition + melds).
|
||
function patternRows(dec, meldSets, seatWindKind, roundWindKind) {
|
||
const sets = dec.sets.concat(meldSets);
|
||
const rows = [];
|
||
|
||
// suit purity over every set and the pair (chows are always suited)
|
||
const kinds = [dec.pair, ...sets.map((s) => s.kind)];
|
||
const suits = new Set();
|
||
let hasHonor = false;
|
||
for (const k of kinds) {
|
||
if (isSuited(k)) suits.add(Math.floor(k / 9));
|
||
else hasHonor = true;
|
||
}
|
||
for (const s of sets) if (s.type === 'chow') suits.add(Math.floor(s.kind / 9));
|
||
if (suits.size === 0) rows.push(...row('all-honors'));
|
||
else if (suits.size === 1) rows.push(...row(hasHonor ? 'mixed-one-suit' : 'pure-one-suit'));
|
||
|
||
if (sets.every((s) => s.type === 'chow') && isSuited(dec.pair)) rows.push(...row('common-hand'));
|
||
if (sets.every((s) => s.type === 'pung')) rows.push(...row('all-pungs'));
|
||
|
||
const dragonPungs = sets.filter((s) => s.type === 'pung' && DRAGON_KINDS.includes(s.kind)).length;
|
||
const dragonPair = DRAGON_KINDS.includes(dec.pair);
|
||
if (dragonPungs === 3) rows.push(...row('great-dragons'));
|
||
else if (dragonPungs === 2 && dragonPair) rows.push(...row('small-dragons'));
|
||
else if (dragonPungs > 0) rows.push(...row('dragon-pung', dragonPungs));
|
||
|
||
const windPungKinds = sets.filter((s) => s.type === 'pung' && WIND_KINDS.includes(s.kind)).map((s) => s.kind);
|
||
const windPair = WIND_KINDS.includes(dec.pair);
|
||
if (windPungKinds.length === 4) rows.push(...row('great-winds'));
|
||
else if (windPungKinds.length === 3 && windPair) rows.push(...row('small-winds'));
|
||
else {
|
||
if (windPungKinds.includes(seatWindKind)) rows.push(...row('seat-wind'));
|
||
if (windPungKinds.includes(roundWindKind)) rows.push(...row('round-wind'));
|
||
}
|
||
return applyExcludes(rows);
|
||
}
|
||
|
||
function applyExcludes(rows) {
|
||
const excluded = new Set();
|
||
for (const r of rows) for (const ex of FAAN_BY_ID[r.id].excludes ?? []) excluded.add(ex);
|
||
return rows.filter((r) => !excluded.has(r.id));
|
||
}
|
||
|
||
// Score a completed hand for `seat`. winTile is added to the concealed hand
|
||
// for discard wins (it is already in the hand for self-draws). Returns
|
||
// { faanList, faan, base, payments } — payments is a zero-sum array of four
|
||
// score deltas: 2× base from the discarder, or 1× base from everyone on a
|
||
// self-draw.
|
||
export function evaluateWin(state, seat, winTile, byDiscard) {
|
||
const p = state.players[seat];
|
||
const counts = counts34(byDiscard ? p.hand.concat([winTile]) : p.hand);
|
||
const seatWindKind = WIND_KINDS[seatWindOf(state, seat)];
|
||
const roundWindKind = WIND_KINDS[state.roundWind];
|
||
const meldSets = p.melds.map((m) => ({ type: m.type === 'chow' ? 'chow' : 'pung', kind: m.kinds[0] }));
|
||
|
||
let bestRows = [];
|
||
let bestFaan = -1;
|
||
if (p.melds.length === 0 && isThirteenOrphans(counts)) {
|
||
bestRows = row('thirteen-orphans');
|
||
bestFaan = LIMIT_FAAN;
|
||
} else {
|
||
for (const dec of decompose(counts, 4 - p.melds.length)) {
|
||
const rows = patternRows(dec, meldSets, seatWindKind, roundWindKind);
|
||
const total = rows.reduce((a, r) => a + r.faan, 0);
|
||
if (total > bestFaan) { bestFaan = total; bestRows = rows; }
|
||
}
|
||
if (bestFaan < 0) { bestFaan = 0; bestRows = []; } // defensive; callers check canWinWith
|
||
}
|
||
|
||
const faanList = bestRows.slice();
|
||
if (!byDiscard) faanList.push(...row('self-draw'));
|
||
if (p.melds.every((m) => m.concealed)) faanList.push(...row('concealed'));
|
||
if (!byDiscard && state.wallPos > state.wallEnd) faanList.push(...row('last-tile'));
|
||
if (!byDiscard && state.lastDrawWasKongReplacement) faanList.push(...row('kong-draw'));
|
||
|
||
const seatWind = seatWindOf(state, seat);
|
||
const own = p.bonus.filter((b) => (b - FIRST_BONUS) % 4 === seatWind).length;
|
||
if (own > 0) faanList.push(...row('seat-flower', own));
|
||
if ([34, 35, 36, 37].every((k) => p.bonus.includes(k))) faanList.push(...row('flower-set'));
|
||
if ([38, 39, 40, 41].every((k) => p.bonus.includes(k))) faanList.push(...row('season-set'));
|
||
if (p.bonus.length === 0) faanList.push(...row('no-bonus'));
|
||
|
||
const faan = Math.min(faanList.reduce((a, r) => a + r.faan, 0), LIMIT_FAAN);
|
||
const base = basePoints(faan);
|
||
const payments = [0, 0, 0, 0];
|
||
if (byDiscard) {
|
||
payments[seat] = 2 * base;
|
||
payments[state.lastDiscard.from] = -2 * base;
|
||
} else {
|
||
payments[seat] = 3 * base;
|
||
for (let s = 0; s < 4; s++) if (s !== seat) payments[s] = -base;
|
||
}
|
||
return { faanList, faan, base, payments };
|
||
}
|