From e020f2a4b6505122dc762a6bb29bbc25f949c7ab Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 20 Dec 2025 22:10:19 +1100 Subject: [PATCH] First cut of Mahjong scorecard --- site/mahjong/guide.html | 100 +++++++++++++++++++ site/mahjong/index.html | 81 ++++++++++++++++ site/mahjong/main.js | 209 ++++++++++++++++++++++++++++++++++++++++ site/mahjong/models.js | 152 +++++++++++++++++++++++++++++ site/mahjong/style.css | 14 +++ 5 files changed, 556 insertions(+) create mode 100644 site/mahjong/guide.html create mode 100644 site/mahjong/index.html create mode 100644 site/mahjong/main.js create mode 100644 site/mahjong/models.js create mode 100644 site/mahjong/style.css diff --git a/site/mahjong/guide.html b/site/mahjong/guide.html new file mode 100644 index 0000000..43ceeb7 --- /dev/null +++ b/site/mahjong/guide.html @@ -0,0 +1,100 @@ + + + + + + Clocks - Tools + + + + +
+
+

Mahjong Score Card

+

Score entry guide

+
+
+
+

Use the following notation to enter player scores.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NotationMeaningScore
mMahjong
psPung of simples
ptPung of terminals
phPung of honours
xpsExposed pung of simples
xptExposed pung of terminals
xphExposed pung of honours
ksKong of simples
ktKong of terminals
khKong of honours
xksExposed kong of simples
xktExposed kong of terminals
xkhExposed kong of honours
pdPair of dragons
pwPair of winds (either player or prevailing)
bBonus (flower or season)
+
+ + \ No newline at end of file diff --git a/site/mahjong/index.html b/site/mahjong/index.html new file mode 100644 index 0000000..1c070f4 --- /dev/null +++ b/site/mahjong/index.html @@ -0,0 +1,81 @@ + + + + + + Mahjong Score Card - Tools + + + + +
+
+

Mahjong Score Card

+
+
+ + + + + + + \ No newline at end of file diff --git a/site/mahjong/main.js b/site/mahjong/main.js new file mode 100644 index 0000000..49609ff --- /dev/null +++ b/site/mahjong/main.js @@ -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; + } +}); \ No newline at end of file diff --git a/site/mahjong/models.js b/site/mahjong/models.js new file mode 100644 index 0000000..9180afe --- /dev/null +++ b/site/mahjong/models.js @@ -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; +} \ No newline at end of file diff --git a/site/mahjong/style.css b/site/mahjong/style.css new file mode 100644 index 0000000..7a26656 --- /dev/null +++ b/site/mahjong/style.css @@ -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; +} \ No newline at end of file