First cut of Mahjong scorecard

This commit is contained in:
Leon Mika 2025-12-20 22:10:19 +11:00
parent e74906e0c4
commit e020f2a4b6
5 changed files with 556 additions and 0 deletions

100
site/mahjong/guide.html Normal file
View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clocks - Tools</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
>
<link rel="stylesheet" href="style.css">
</head>
<body class="container">
<header>
<hgroup>
<h1>Mahjong Score Card</h1>
<p>Score entry guide</p>
</hgroup>
</header>
<div>
<p>Use the following notation to enter player scores.</p>
<table>
<thead>
<tr>
<th>Notation</th>
<th>Meaning</th>
<th>Score</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>m</code></td>
<td>Mahjong</td>
</tr>
<tr>
<td><code>ps</code></td>
<td>Pung of simples</td>
</tr>
<tr>
<td><code>pt</code></td>
<td>Pung of terminals</td>
</tr>
<tr>
<td><code>ph</code></td>
<td>Pung of honours</td>
</tr>
<tr>
<td><code>xps</code></td>
<td>Exposed pung of simples</td>
</tr>
<tr>
<td><code>xpt</code></td>
<td>Exposed pung of terminals</td>
</tr>
<tr>
<td><code>xph</code></td>
<td>Exposed pung of honours</td>
</tr>
<tr>
<td><code>ks</code></td>
<td>Kong of simples</td>
</tr>
<tr>
<td><code>kt</code></td>
<td>Kong of terminals</td>
</tr>
<tr>
<td><code>kh</code></td>
<td>Kong of honours</td>
</tr>
<tr>
<td><code>xks</code></td>
<td>Exposed kong of simples</td>
</tr>
<tr>
<td><code>xkt</code></td>
<td>Exposed kong of terminals</td>
</tr>
<tr>
<td><code>xkh</code></td>
<td>Exposed kong of honours</td>
</tr>
<tr>
<td><code>pd</code></td>
<td>Pair of dragons</td>
</tr>
<tr>
<td><code>pw</code></td>
<td>Pair of winds (either player or prevailing)</td>
</tr>
<tr>
<td><code>b</code></td>
<td>Bonus (flower or season)</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

81
site/mahjong/index.html Normal file
View file

@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mahjong Score Card - Tools</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
>
<link rel="stylesheet" href="style.css">
</head>
<body class="container">
<header>
<hgroup>
<h1>Mahjong Score Card</h1>
</hgroup>
</header>
<div id="main-app" class="hidden"
data-controller="scorecard"
data-action="gamestatechanged@window->scorecard#handleGameState">
<table class="scorecard">
<thead>
<tr>
<th>AB (E)</th>
<th>CD (S)</th>
<th>EF (W)</th>
<th>GH (N)</th>
</tr>
</thead>
<tbody>
<tr>
<td>20 🏆</td>
<td>15</td>
<td>12</td>
<td>13</td>
</tr>
<tr>
<td>20 🏆</td>
<td>15</td>
<td>12</td>
<td>13</td>
</tr>
</tbody>
</table>
<div>
<button data-action="click->scorecard#endRound">End Round</button>
<button>End Game</button>
<div data-scorecard-target="prevailing">Prevailing: E</div>
</div>
</div>
<div id="player-entry" class="hidden" data-controller="newgame"
data-action="gamestatechanged@window->newgame#handleGameState">
<h3>New Game</h3>
<label for="player-1">Player 1 (East)</label>
<input type="text" data-newgame-target="playerName">
<label for="player-2">Player 2</label>
<input type="text" data-newgame-target="playerName">
<label for="player-3">Player 3</label>
<input type="text" data-newgame-target="playerName">
<label for="player-4">Player 4</label>
<input type="text" data-newgame-target="playerName">
<label for="player-5">Player 5</label>
<input type="text" data-newgame-target="playerName">
<button data-action="click->newgame#startGame">Start Game</button>
</div>
<div id="end-round" class="hidden" data-controller="endround"
data-action="gamestatechanged@window->endround#handleGameState">
<h3>End Round</h3>
<form data-endround-target="form"></form>
<p data-endround-target="wind"></p>
<button data-action="click->endround#nextRound">Next Round</button>
</div>
<script src="./main.js" type="module"></script>
</body>
</html>

209
site/mahjong/main.js Normal file
View file

@ -0,0 +1,209 @@
import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
import { GameState, getGameState, setGameState, calculateScore } from "./models.js";
function emitGameStateEvent(details) {
// window.setTimeout(() => {
let ev = new CustomEvent("gamestatechanged", { detail: details });
window.dispatchEvent(ev);
// }, 1);
}
window.Stimulus = Application.start();
Stimulus.register("scorecard", class extends Controller {
static targets = ["prevailing"];
initialize() {
}
connect() {
// this._rebuildTable();
// this.element.classList.remove("hidden");
}
handleGameState(ev) {
switch (ev.detail.mode) {
case "endround":
this.element.classList.add("hidden");
break;
case "startgame":
case "nextround":
this._rebuildTable();
this.element.classList.remove("hidden");
break;
}
}
endRound(ev) {
ev.preventDefault();
console.log("end round");
emitGameStateEvent({mode: "endround"});
}
_rebuildTable() {
let gameState = getGameState();
let tbl = document.createElement("table");
tbl.classList.add("scorecard");
let thead = document.createElement("thead");
let thr = document.createElement("tr");
let headRows = gameState.players.map(player => {
let th = document.createElement("th");
th.textContent = player.name + " (" + player.wind + ")";
return th;
});
thr.append(...headRows);
thead.append(thr);
tbl.append(thead);
let tbody = document.createElement("tbody");
for (let r of gameState.rounds) {
let tr = document.createElement("tr");
for (let c of r) {
let td = document.createElement("td");
td.textContent = c.s;
if (c.w) {
td.classList.add("winner");
td.textContent += " 🏆";
}
tr.append(td);
}
tbody.append(tr);
}
tbl.append(tbody);
let tfoot = document.createElement("tfoot");
for (let t of gameState.playerTotals()) {
let td = document.createElement("td");
td.textContent = t;
tfoot.append(td);
}
tbl.append(tfoot);
this.element.querySelector("table.scorecard").replaceWith(tbl);
this.prevailingTarget.textContent = `Prevailing: ${gameState.prevaling}`;
}
});
Stimulus.register("newgame", class extends Controller {
static targets = ["playerName"];
connect() {
this.element.classList.remove("hidden");
}
handleGameState(ev) {
switch (ev.detail.mode) {
case "startgame":
case "endround":
case "nextround":
this.element.classList.add("hidden");
break;
}
}
startGame() {
let players = [];
this.playerNameTargets.forEach(el => {
if (el.value != "") {
players.push(el.value);
}
});
setGameState(GameState.newGame(players));
this.element.classList.add("hidden");
emitGameStateEvent({mode: "startgame"});
}
});
Stimulus.register("endround", class extends Controller {
static targets = ["form", "input", "wind"];
connect() {
// this.element.classList.remove("hidden");
}
handleGameState(ev) {
switch (ev.detail.mode) {
case "startgame":
case "nextround":
this.element.classList.add("hidden");
break;
case "endround":
this._prepForms();
this.element.classList.remove("hidden");
break;
}
}
updatePreview(ev) {
ev.preventDefault();
let scoreExprs = this._readScoreExprs();
let nextRound = getGameState().determineNextRound(scoreExprs);
for (let i in nextRound.roundScores) {
let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`);
previewElem.textContent = nextRound.roundScores[i].score;
if (parseInt(i) === nextRound.roundWinner) {
previewElem.textContent += " 🏆";
}
}
if (nextRound.windsBump) {
this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will be East next round`;
} else {
this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will remain East next round`;
}
}
nextRound(ev) {
ev.preventDefault();
let scoreExprs = this._readScoreExprs();
getGameState().startNextRound(scoreExprs);
emitGameStateEvent({mode: "nextround"});
}
_prepForms() {
let newFormElems = getGameState().players.map((player, i) => {
let outerDiv = document.createElement("div");
let label = document.createElement("label");
label.textContent = player.name + " (" + player.wind + ")";
label.setAttribute("for", `player-${i}`);
let div = document.createElement("div");
div.classList.add("score-input-group");
let input = document.createElement("input");
input.name = `player-${i}`;
input.dataset["endroundTarget"] = "input";
input.dataset["action"] = "keyup->endround#updatePreview";
input.dataset["player"] = i;
let preview = document.createElement("div");
preview.classList.add("preview");
preview.textContent = "0";
preview.dataset["player"] = i;
div.append(input, preview);
outerDiv.append(label, div);
return outerDiv;
});
this.formTarget.replaceChildren(...newFormElems);
}
_readScoreExprs() {
let scoreExprs = [];
for (let playerIndex in getGameState().players) {
let thisScoreExpr = this.formTarget.querySelector(`input[data-player="${playerIndex}"]`).value;
scoreExprs.push(thisScoreExpr);
}
return scoreExprs;
}
});

152
site/mahjong/models.js Normal file
View file

@ -0,0 +1,152 @@
const windDistributions = [
["E"],
["E", "W"],
["E", "S", "N"],
["E", "S", "W", "N"],
["E", "S", "W", "N", "X"],
];
const scoreTokens = {
'm': 1,
'ps': 1,
'pt': 1,
'ph': 1,
'xps': 1,
'xpt': 1,
'xph': 1,
'ks': 1,
'kt': 1,
'kh': 1,
'xks': 1,
'xkt': 1,
'xkh': 1,
'pd': 1,
'pw': 1,
'b': 1,
}
function parseTokens(str) {
// Split on whitespace and filter out empty strings
const tokens = str.split(/\s+/).filter(token => token.length > 0);
const pattern = /^([0-9]*)([a-z]+)$/;
const result = [];
for (const token of tokens) {
const match = token.match(pattern);
if (match) {
result.push({
number: match[1] ? parseInt(match[1], 10) : null,
letters: match[2]
});
}
}
return result;
}
export function calculateScore(str) {
const tokens = parseTokens(str);
let o = {score: 0, winner: false};
for (const token of tokens) {
if (!(token.letters in scoreTokens)) {
continue;
}
o.score += scoreTokens[token.letters] * (token.number || 1);
if (token.letters === 'm') {
o.winner = true;
}
}
return o;
}
export class GameState {
constructor(players, rounds) {
this.players = players;
this.rounds = rounds;
this.bumpWind = 0;
this.prevaling = "E";
}
static newGame(playerNames) {
let players = playerNames.map(name => { return { name: name } });
let windDistribution = windDistributions[players.length - 1];
for (let i = 0; i < players.length; i++) {
players[i].wind = windDistribution[i];
}
let rounds = [];
return new GameState(players, rounds);
}
determineNextRound(playerScoreExprs) {
let roundScores = playerScoreExprs.map(calculateScore);
let roundWinner = roundScores.findIndex(rs => rs.winner);
let currentEast = this.players.findIndex(p => p.wind === 'E');
let nextRoundEast = currentEast;
if (roundWinner !== currentEast) {
nextRoundEast = (currentEast + 1) % this.players.length;
}
let windsBump = roundWinner !== currentEast;
let nextPrevailing = this.prevaling;
if (windsBump) {
nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0];
}
return {
roundScores: roundScores,
roundWinner: roundWinner,
nextRoundEast: nextRoundEast,
windsBump: roundWinner !== currentEast,
nextPrevailing: nextPrevailing,
}
}
playerTotals() {
let scores = this.players.map(p => 0);
for (let i = 0; i < this.rounds.length; i++) {
for (let j = 0; j < this.rounds[i].length; j++) {
scores[j] += this.rounds[i][j].s;
}
}
return scores;
}
startNextRound(playerScoreExprs) {
let nr = this.determineNextRound(playerScoreExprs);
this.rounds.push(nr.roundScores.map((s, i) => {
if (i === nr.roundWinner) {
return { s: s.score, w: true };
}
return { s: s.score };
}));
let windDistribution = windDistributions[this.players.length - 1];
for (let i = 0; i < this.players.length; i++) {
let pi = (nr.nextRoundEast + i) % this.players.length;
this.players[pi].wind = windDistribution[i];
}
this.prevaling = nr.nextPrevailing;
if (nr.windsBump) {
this.bumpWind++;
}
}
}
let gameState = new GameState();
export function getGameState() {
return gameState;
}
export function setGameState(gs) {
gameState = gs;
}

14
site/mahjong/style.css Normal file
View file

@ -0,0 +1,14 @@
.hidden {
display: none;
}
.main-table {
table-layout: fixed;
}
.score-input-group {
display: grid;
gap: 15px;
grid-template-columns: 2fr 1fr;
align-items: baseline;
}