From f530894b753f307b855160869d2b69916f765fb9 Mon Sep 17 00:00:00 2001 From: Ryan Sullenberger Date: Sat, 14 Feb 2026 12:07:08 -0500 Subject: [PATCH 1/2] feat: add shareable URL support for hand calculator Encode calculator state (hand, melds, dora, context options) into URL query parameters using agari-core terminal input syntax. Opening a shared link auto-populates the UI and calculates the score. - Add urlState.ts with serialize/deserialize and hand notation parser - Add Share button next to Clear that copies link to clipboard - Auto-run score calculation when loading a complete hand from URL - Clear button strips query params from address bar - i18n support for share UI strings (en/ja) --- web/src/App.svelte | 3458 ++++++++++++++++++++----------------- web/src/lib/i18n/en.ts | 4 + web/src/lib/i18n/ja.ts | 4 + web/src/lib/i18n/types.ts | 4 + web/src/lib/urlState.ts | 338 ++++ 5 files changed, 2232 insertions(+), 1576 deletions(-) create mode 100644 web/src/lib/urlState.ts diff --git a/web/src/App.svelte b/web/src/App.svelte index 1ec2242..9c35033 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,1628 +1,1934 @@
-
-
-

- πŸ€„ - Agari -

-

{$t.tagline}

-
- - -
-
-
- -
- {#if wasmError} -
- ⚠️ {$t.failedToLoad} {wasmError} -
- {:else if !wasmLoaded} -
-
- {$t.loadingCalculator} -
- {:else} -
- -
-
-
-

{$t.buildYourHand}

- -
- - - - - -
- {$t.addMeld} - - - - +
+
+

+ πŸ€„ + Agari +

+

{$t.tagline}

+
+ +
+
+
- - {#if showMeldBuilder} -
-
- {#if meldBuilderType === 'chi'}{$t.buildingChi}{:else if meldBuilderType === 'pon'}{$t.buildingPon}{:else if meldBuilderType === 'kan'}{$t.buildingOpenKan}{:else}{$t.buildingClosedKan}{/if} - - {#if meldBuilderType === 'chi'} - {$t.hintChi} - {:else if meldBuilderType === 'pon'} - {$t.hintPon} - {:else} - {$t.hintKan} - {/if} - -
-
- {#each meldBuilderTiles as entry, index (entry.id)} - removeTileFromMeldBuilder(index)} - /> - {/each} - {#each Array((meldBuilderType === 'kan' || meldBuilderType === 'ankan' ? 4 : 3) - meldBuilderTiles.length) as _} -
- {/each} -
-
- - -
-
- {/if} -
- - - {#if melds.length > 0} -
-
-

{$t.calledMelds}

-
- {#each melds as meld, index (meld.id)} -
- - {meld.type === 'ankan' ? 'πŸ”’' : 'πŸ“’'} {meld.type} - -
- {#each meld.tiles as entry (entry.id)} - - {/each} -
- -
- {/each} -
-
-
- {/if} - - -
-
-

{$t.yourHand}

- {#if handTiles.length > 0} - {$t.selectWinningTileHint} - {/if} +
+ {#if wasmError} +
+ ⚠️ {$t.failedToLoad} {wasmError}
-
- {#each handTiles as entry, index (entry.id)} -
- - -
- {/each} - {#each Array(Math.max(0, maxHandTiles - handTiles.length)) as _} -
- {/each} + {:else if !wasmLoaded} +
+
+ {$t.loadingCalculator}
- {#if handTiles.length > 0 || melds.length > 0} -

{handString}{buildMeldNotation()}

- {/if} - - - {#if shantenResult && (handTiles.length > 0 || melds.length > 0)} - {#if shantenResult.success} -
- {#if shantenResult.shanten === -1} - βœ“ {$t.complete} - {:else if shantenResult.shanten === 0} - {$t.tenpai} - {:else} - {shantenResult.shanten}{$t.shanten} - {/if} - ({shantenResult.best_type}) -
- {:else if shantenResult.error} -
- Shanten: {shantenResult.error} -
- {/if} - {/if} -
- - -
- - - {#if showDoraSection} -
-
- {$t.dora} -
- {#each doraIndicators as entry, index (entry.id)} -
- + +
+
+
+

{$t.buildYourHand}

+
+ + +
+
+ + + - -
- {/each} - {#if doraIndicators.length < 5} - + + +
+ {$t.addMeld} + + + + +
+ + + {#if showMeldBuilder} +
+
+ {#if meldBuilderType === "chi"}{$t.buildingChi}{:else if meldBuilderType === "pon"}{$t.buildingPon}{:else if meldBuilderType === "kan"}{$t.buildingOpenKan}{:else}{$t.buildingClosedKan}{/if} + + {#if meldBuilderType === "chi"} + {$t.hintChi} + {:else if meldBuilderType === "pon"} + {$t.hintPon} + {:else} + {$t.hintKan} + {/if} + +
+
+ {#each meldBuilderTiles as entry, index (entry.id)} + + removeTileFromMeldBuilder( + index, + )} + /> + {/each} + {#each Array((meldBuilderType === "kan" || meldBuilderType === "ankan" ? 4 : 3) - meldBuilderTiles.length) as _} +
+ {/each} +
+
+ + +
+
+ {/if} +
+ + + {#if melds.length > 0} +
+
+

{$t.calledMelds}

+
+ {#each melds as meld, index (meld.id)} +
+ + {meld.type === "ankan" + ? "πŸ”’" + : "πŸ“’"} + {meld.type} + +
+ {#each meld.tiles as entry (entry.id)} + + {/each} +
+ +
+ {/each} +
+
+
{/if} -
-
- {#if isRiichi} -
- {$t.uraDora} -
- {#each uraDoraIndicators as entry, index (entry.id)} -
- - + +
+
+

{$t.yourHand}

+ {#if handTiles.length > 0} + {$t.selectWinningTileHint} + {/if}
- {/each} - {#if uraDoraIndicators.length < 5} +
+ {#each handTiles as entry, index (entry.id)} +
+ + +
+ {/each} + {#each Array(Math.max(0, maxHandTiles - handTiles.length)) as _} +
+ {/each} +
+ {#if handTiles.length > 0 || melds.length > 0} +

+ {handString}{buildMeldNotation()} +

+ {/if} + + + {#if shantenResult && (handTiles.length > 0 || melds.length > 0)} + {#if shantenResult.success} +
+ {#if shantenResult.shanten === -1} + βœ“ {$t.complete} + {:else if shantenResult.shanten === 0} + {$t.tenpai} + {:else} + {shantenResult.shanten}{$t.shanten} + {/if} + ({shantenResult.best_type}) +
+ {:else if shantenResult.error} +
+ Shanten: {shantenResult.error} +
+ {/if} + {/if} +
+ + +
- {/if} + class="dora-toggle" + onclick={() => (showDoraSection = !showDoraSection)} + > + {$t.doraIndicators} + β–Ό + + + {#if showDoraSection} +
+
+ {$t.dora} +
+ {#each doraIndicators as entry, index (entry.id)} +
+ + +
+ {/each} + {#if doraIndicators.length < 5} + + {/if} +
+
+ + {#if isRiichi} +
+ {$t.uraDora} +
+ {#each uraDoraIndicators as entry, index (entry.id)} +
+ + +
+ {/each} + {#if uraDoraIndicators.length < 5} + + {/if} +
+
+ {/if} + + {#if akaCount > 0} +
+ {$t.akadoraInHand} + {akaCount} +
+ {/if} +
+ {/if} + + + {#if showDoraPicker} + { + addDoraIndicator(tile); + showDoraPicker = false; + }} + onClose={() => (showDoraPicker = false)} + disabledTiles={doraDisabledTiles} + /> + {/if} + + + {#if showUraDoraPicker} + { + addUraDoraIndicator(tile); + showUraDoraPicker = false; + }} + onClose={() => (showUraDoraPicker = false)} + disabledTiles={doraDisabledTiles} + /> + {/if}
-
- {/if} - - {#if akaCount > 0} -
- {$t.akadoraInHand} {akaCount} -
- {/if} -
- {/if} - - - {#if showDoraPicker} - { addDoraIndicator(tile); showDoraPicker = false; }} - onClose={() => showDoraPicker = false} - disabledTiles={doraDisabledTiles} - /> - {/if} - - - {#if showUraDoraPicker} - { addUraDoraIndicator(tile); showUraDoraPicker = false; }} - onClose={() => showUraDoraPicker = false} - disabledTiles={doraDisabledTiles} - /> - {/if} -
- - -
-

{$t.results}

- -
-
- -
- -
-

{$t.options}

- -
- - - -
-
- {/if} -
- -
-

- {$t.footerPoweredBy} Agari - {$t.footerDescription} -

-
+ +
+

{$t.results}

+ +
+
+ + +
+ +
+

{$t.options}

+ +
+ + + +
+
+ {/if} +
+ +
+

+ {$t.footerPoweredBy} + Agari + {$t.footerDescription} +

+
diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f5e110f..cf517ba 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -112,6 +112,10 @@ export const en: Translations = { // Language language: "Language", + // Share + share: "Share", + copiedToClipboard: "Link copied!", + // Tile Theme tileTheme: "Tiles", tileThemeLight: "Light", diff --git a/web/src/lib/i18n/ja.ts b/web/src/lib/i18n/ja.ts index 89896eb..7726e74 100644 --- a/web/src/lib/i18n/ja.ts +++ b/web/src/lib/i18n/ja.ts @@ -112,6 +112,10 @@ export const ja: Translations = { // Language language: "言θͺž", + // Share + share: "ε…±ζœ‰", + copiedToClipboard: "γƒͺγƒ³γ‚―γ‚’γ‚³γƒ”γƒΌγ—γΎγ—γŸοΌ", + // Tile Theme tileTheme: "η‰Œ", tileThemeLight: "η™½", diff --git a/web/src/lib/i18n/types.ts b/web/src/lib/i18n/types.ts index 43dac6d..dd62348 100644 --- a/web/src/lib/i18n/types.ts +++ b/web/src/lib/i18n/types.ts @@ -117,6 +117,10 @@ export interface Translations { // Language language: string; + // Share + share: string; + copiedToClipboard: string; + // Tile Theme tileTheme: string; tileThemeLight: string; diff --git a/web/src/lib/urlState.ts b/web/src/lib/urlState.ts new file mode 100644 index 0000000..5b8d7c4 --- /dev/null +++ b/web/src/lib/urlState.ts @@ -0,0 +1,338 @@ +/** + * URL state serialization/deserialization for shareable links. + * + * Encodes the calculator state into query parameters using the agari-core + * terminal input syntax for the hand notation, and short keys for options. + */ + +// ============================================================================ +// Types (mirrors App.svelte internal types) +// ============================================================================ + +export interface TileEntry { + tile: string; + isRed?: boolean; + id: number; +} + +export interface Meld { + type: "chi" | "pon" | "kan" | "ankan"; + tiles: TileEntry[]; + id: number; +} + +export type Wind = "east" | "south" | "west" | "north"; + +export interface AppState { + handTiles: TileEntry[]; + melds: Meld[]; + winningTile?: string; + doraIndicators: TileEntry[]; + uraDoraIndicators: TileEntry[]; + isTsumo: boolean; + isRiichi: boolean; + isDoubleRiichi: boolean; + isIppatsu: boolean; + roundWind: Wind; + seatWind: Wind; + isLastTile: boolean; + isRinshan: boolean; + isChankan: boolean; + isTenhou: boolean; + isChiihou: boolean; +} + +// ============================================================================ +// Wind encoding helpers +// ============================================================================ + +const WIND_TO_CODE: Record = { + east: "e", + south: "s", + west: "w", + north: "n", +}; + +const CODE_TO_WIND: Record = { + e: "east", + s: "south", + w: "west", + n: "north", +}; + +// ============================================================================ +// Serialization (state β†’ URL) +// ============================================================================ + +function buildHandString(tiles: TileEntry[]): string { + if (tiles.length === 0) return ""; + + const groups: Record = { m: [], p: [], s: [], z: [] }; + for (const entry of tiles) { + const suit = entry.tile[1]; + const value = entry.isRed ? "0" : entry.tile[0]; + if (groups[suit]) { + groups[suit].push(value); + } + } + + let result = ""; + for (const [suit, values] of Object.entries(groups)) { + if (values.length > 0) { + result += values.join("") + suit; + } + } + return result; +} + +function buildMeldNotation(melds: Meld[]): string { + let meldStr = ""; + for (const meld of melds) { + const tiles = meld.tiles.map((t) => (t.isRed ? "0" : t.tile[0])).join(""); + const suit = meld.tiles[0].tile[1]; + if (meld.type === "ankan") { + meldStr += `[${tiles}${suit}]`; + } else { + meldStr += `(${tiles}${suit})`; + } + } + return meldStr; +} + +function buildTileList(tiles: TileEntry[]): string { + return tiles.map((t) => t.tile).join(","); +} + +export function serializeToUrl(state: AppState): string { + const params = new URLSearchParams(); + + // Hand + melds + const hand = buildHandString(state.handTiles) + buildMeldNotation(state.melds); + if (hand) params.set("h", hand); + + // Winning tile + if (state.winningTile) params.set("w", state.winningTile); + + // Dora + if (state.doraIndicators.length > 0) { + params.set("d", buildTileList(state.doraIndicators)); + } + if (state.uraDoraIndicators.length > 0) { + params.set("u", buildTileList(state.uraDoraIndicators)); + } + + // Winds (only if non-default) + if (state.roundWind !== "east") params.set("rw", WIND_TO_CODE[state.roundWind]); + if (state.seatWind !== "east") params.set("sw", WIND_TO_CODE[state.seatWind]); + + // Boolean flags (presence = true) + if (state.isTsumo) params.set("t", ""); + if (state.isRiichi) params.set("ri", ""); + if (state.isDoubleRiichi) params.set("dri", ""); + if (state.isIppatsu) params.set("ip", ""); + if (state.isLastTile) params.set("lt", ""); + if (state.isRinshan) params.set("rs", ""); + if (state.isChankan) params.set("ck", ""); + if (state.isTenhou) params.set("th", ""); + if (state.isChiihou) params.set("ch", ""); + + const qs = params.toString(); + const base = window.location.origin + window.location.pathname; + return qs ? `${base}?${qs}` : base; +} + +// ============================================================================ +// Deserialization (URL β†’ state) +// ============================================================================ + +export function deserializeFromUrl(): AppState | null { + const params = new URLSearchParams(window.location.search); + + const hand = params.get("h"); + if (!hand) return null; + + let idCounter = 0; + const nextId = () => idCounter++; + + // Parse hand notation + const parsed = parseHandNotation(hand, nextId); + + // Parse winning tile + const winningTile = params.get("w") || undefined; + + // Parse dora indicators + const doraIndicators = parseTileList(params.get("d"), nextId); + const uraDoraIndicators = parseTileList(params.get("u"), nextId); + + // Parse winds + const roundWind = CODE_TO_WIND[params.get("rw") || ""] || "east"; + const seatWind = CODE_TO_WIND[params.get("sw") || ""] || "east"; + + // Parse boolean flags + const hasFlag = (key: string) => params.has(key); + + return { + handTiles: parsed.tiles, + melds: parsed.melds, + winningTile, + doraIndicators, + uraDoraIndicators, + isTsumo: hasFlag("t"), + isRiichi: hasFlag("ri"), + isDoubleRiichi: hasFlag("dri"), + isIppatsu: hasFlag("ip"), + roundWind, + seatWind, + isLastTile: hasFlag("lt"), + isRinshan: hasFlag("rs"), + isChankan: hasFlag("ck"), + isTenhou: hasFlag("th"), + isChiihou: hasFlag("ch"), + }; +} + +// ============================================================================ +// Hand notation parser +// ============================================================================ + +interface ParsedHand { + tiles: TileEntry[]; + melds: Meld[]; +} + +/** + * Parse agari hand notation string into TileEntry[] and Meld[]. + * + * Only handles numeric notation (e.g. `123m456p789s11z`) since the web UI + * only produces this subset (not letter-based honor notation like `e`/`wh`). + * + * Melds: `(123m)` = open, `[1111m]` = closed kan + */ +export function parseHandNotation( + notation: string, + nextId: () => number, +): ParsedHand { + const tiles: TileEntry[] = []; + const melds: Meld[] = []; + let meldIdCounter = 0; + + // Extract melds first, then parse remaining as free tiles + let remaining = ""; + let i = 0; + + while (i < notation.length) { + const ch = notation[i]; + + if (ch === "(" || ch === "[") { + const closeBracket = ch === "(" ? ")" : "]"; + const closeIdx = notation.indexOf(closeBracket, i + 1); + if (closeIdx === -1) { + // Malformed, skip + i++; + continue; + } + + const isOpen = ch === "("; + const meldContent = notation.substring(i + 1, closeIdx); + const meldTiles = parseTileString(meldContent, nextId); + + if (meldTiles.length > 0) { + const meldType = detectMeldType(meldTiles, isOpen); + melds.push({ + type: meldType, + tiles: meldTiles, + id: meldIdCounter++, + }); + } + + i = closeIdx + 1; + } else { + remaining += ch; + i++; + } + } + + // Parse free tiles from remaining string + tiles.push(...parseTileString(remaining, nextId)); + + return { tiles, melds }; +} + +/** + * Parse a tile string like "123m456p789s11z" into TileEntry[]. + * Handles red fives: digit 0 β†’ { tile: "5X", isRed: true } + */ +function parseTileString(input: string, nextId: () => number): TileEntry[] { + const entries: TileEntry[] = []; + const pending: { digit: string; isRed: boolean }[] = []; + + for (const ch of input) { + if (ch >= "0" && ch <= "9") { + const isRed = ch === "0"; + pending.push({ digit: isRed ? "5" : ch, isRed }); + } else if (ch === "m" || ch === "p" || ch === "s" || ch === "z") { + // Flush pending digits with this suit + for (const p of pending) { + entries.push({ + tile: `${p.digit}${ch}`, + isRed: p.isRed || undefined, + id: nextId(), + }); + } + pending.length = 0; + } + // Ignore any other characters (whitespace, etc.) + } + + return entries; +} + +/** + * Detect meld type from parsed tiles and bracket type. + */ +function detectMeldType( + tiles: TileEntry[], + isOpen: boolean, +): "chi" | "pon" | "kan" | "ankan" { + if (tiles.length === 4) { + return isOpen ? "kan" : "ankan"; + } + + // 3 tiles: check if all same (pon) or sequential (chi) + if (tiles.length === 3) { + const allSame = tiles.every((t) => t.tile === tiles[0].tile); + if (allSame) return "pon"; + return "chi"; + } + + // Fallback (shouldn't happen with valid notation) + return "pon"; +} + +/** + * Parse a comma-separated tile list (e.g. "1m,5z,0p") into TileEntry[]. + * Used for dora/ura dora indicators. + */ +function parseTileList( + input: string | null, + nextId: () => number, +): TileEntry[] { + if (!input) return []; + + return input.split(",").map((t) => { + const trimmed = t.trim(); + const isRed = trimmed.startsWith("0"); + return { + tile: trimmed, + isRed: isRed || undefined, + id: nextId(), + }; + }); +} + +/** + * Clear URL query parameters without adding a history entry. + */ +export function clearUrlParams(): void { + history.replaceState({}, "", window.location.pathname); +} From 583f040287e49be126ed8b8cd6f73bc60fcdd959 Mon Sep 17 00:00:00 2001 From: Ryan Sullenberger Date: Sat, 14 Feb 2026 12:19:46 -0500 Subject: [PATCH 2/2] fix: use cargo-dist custom publish job for crates.io The publish-crates-io job was manually added to the autogenerated release.yml, causing dist plan --check to fail. Move it to a reusable workflow (.github/workflows/publish-crates-io.yml) and reference it in dist-workspace.toml as a custom publish job. This lets cargo-dist manage release.yml while still publishing to crates.io. --- .github/workflows/publish-crates-io.yml | 24 +++++++++++++++++++++ .github/workflows/release.yml | 28 +++++++++++-------------- .gitignore | 1 + dist-workspace.toml | 2 +- 4 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/publish-crates-io.yml diff --git a/.github/workflows/publish-crates-io.yml b/.github/workflows/publish-crates-io.yml new file mode 100644 index 0000000..b1e2ed4 --- /dev/null +++ b/.github/workflows/publish-crates-io.yml @@ -0,0 +1,24 @@ +name: Publish to crates.io + +on: + workflow_call: + inputs: + plan: + required: true + type: string + +jobs: + publish-crates-io: + runs-on: ubuntu-22.04 + if: ${{ !fromJson(inputs.plan).announcement_is_prerelease || fromJson(inputs.plan).publish_prereleases }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Publish to crates.io + run: cargo publish -p agari + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bdb2e8..4fa68a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ on: pull_request: push: tags: - - "**[0-9]+.[0-9]+.[0-9]+*" + - '**[0-9]+.[0-9]+.[0-9]+*' jobs: # Run 'dist plan' (or host) to determine what tasks we need to do @@ -324,34 +324,30 @@ jobs: done git push - publish-crates-io: + custom-publish-crates-io: needs: - plan - host - runs-on: "ubuntu-22.04" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - submodules: recursive - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Publish to crates.io - run: cargo publish -p agari - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + uses: ./.github/workflows/publish-crates-io.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "id-token": "write" + "packages": "write" announce: needs: - plan - host - publish-homebrew-formula - - publish-crates-io + - custom-publish-crates-io # use "always() && ..." to allow us to wait for all publish jobs while # still allowing individual publish jobs to skip themselves (for prereleases). # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.publish-crates-io.result == 'skipped' || needs.publish-crates-io.result == 'success') }} + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-crates-io.result == 'skipped' || needs.custom-publish-crates-io.result == 'success') }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d83c715..3a62692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target .DS_Store +AGENTS.md # JSON files for mismatches tracking (but not package.json) mismatches*.json diff --git a/dist-workspace.toml b/dist-workspace.toml index faa22cb..f1f0293 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -16,6 +16,6 @@ targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-da # Path that installers should place binaries in install-path = "CARGO_HOME" # Publish jobs to run in CI -publish-jobs = ["homebrew"] +publish-jobs = ["homebrew", "./publish-crates-io"] # Whether to install an updater program install-updater = false