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.
+
+
+
+
+ | Notation |
+ Meaning |
+ Score |
+
+
+
+
+ m |
+ Mahjong |
+
+
+ ps |
+ Pung of simples |
+
+
+ pt |
+ Pung of terminals |
+
+
+ ph |
+ Pung of honours |
+
+
+ xps |
+ Exposed pung of simples |
+
+
+ xpt |
+ Exposed pung of terminals |
+
+
+ xph |
+ Exposed pung of honours |
+
+
+ ks |
+ Kong of simples |
+
+
+ kt |
+ Kong of terminals |
+
+
+ kh |
+ Kong of honours |
+
+
+ xks |
+ Exposed kong of simples |
+
+
+ xkt |
+ Exposed kong of terminals |
+
+
+ xkh |
+ Exposed kong of honours |
+
+
+ pd |
+ Pair of dragons |
+
+
+ pw |
+ Pair of winds (either player or prevailing) |
+
+
+ b |
+ Bonus (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
+
+
+
+
+
+
+ | AB (E) |
+ CD (S) |
+ EF (W) |
+ GH (N) |
+
+
+
+
+ | 20 🏆 |
+ 15 |
+ 12 |
+ 13 |
+
+
+ | 20 🏆 |
+ 15 |
+ 12 |
+ 13 |
+
+
+
+
+
+
+
Prevailing: E
+
+
+
+
New Game
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
End Round
+
+
+
+
+
+
+
+
+
+
+
\ 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