From 684fe57e64b8df7afcb7764ad14b68438822efa9 Mon Sep 17 00:00:00 2001 From: Ryan Sullenberger Date: Fri, 30 Jan 2026 17:27:42 -0500 Subject: [PATCH] fix: meld builder validation for red fives and chi sequences - Include meld builder tiles in tileCounts so palette updates in real-time - Properly check red five availability (red5m/red5p/red5s counts) - Add chi sequence validation: only allow tiles that can form valid sequences - Disable invalid tiles visually in the palette during meld building - For pon/kan: after first tile, only that tile type is selectable --- web/src/App.svelte | 136 +++++++++++++++++++++- web/src/lib/components/TilePalette.svelte | 15 ++- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/web/src/App.svelte b/web/src/App.svelte index e24b6b6..9cae6ad 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -118,6 +118,16 @@ } } } + // Subtract meld builder tiles (tiles currently being added to a meld) + for (const entry of meldBuilderTiles) { + if (counts[entry.tile] !== undefined) { + counts[entry.tile]--; + } + // Track red five usage + if (entry.isRed && redFiveCounts[entry.tile] !== undefined) { + redFiveCounts[entry.tile]--; + } + } // Subtract dora indicators for (const entry of doraIndicators) { // Red fives use 0m/0p/0s notation - map to 5m/5p/5s for count tracking @@ -213,6 +223,37 @@ totalTiles >= (mode === 'score' ? 14 : 1) ); + // Compute disabled tiles for meld builder (for chi validation) + const meldBuilderDisabledTiles: Set = $derived.by(() => { + if (!showMeldBuilder) return new Set(); + + // For pon/kan, after first tile is selected, only that tile is allowed + if ((meldBuilderType === 'pon' || meldBuilderType === 'kan' || meldBuilderType === 'ankan') && meldBuilderTiles.length > 0) { + const allowedTile = meldBuilderTiles[0].tile; + const disabled = new Set(); + for (const tile of ALL_TILES) { + if (tile !== allowedTile) { + disabled.add(tile); + } + } + return disabled; + } + + // For chi, compute which tiles can form a valid sequence + if (meldBuilderType === 'chi') { + const allowed = getChiAllowedTiles(); + const disabled = new Set(); + for (const tile of ALL_TILES) { + if (!allowed.has(tile)) { + disabled.add(tile); + } + } + return disabled; + } + + return new Set(); + }); + // ============================================================================ // Functions // ============================================================================ @@ -255,15 +296,101 @@ showMeldBuilder = true; } + // Get the numeric value of a tile (handles red fives) + function getTileValue(tile: string, isRed: boolean = false): number { + if (isRed) return 5; + return parseInt(tile[0]); + } + + // Compute allowed tiles for chi meld builder based on current selection + function getChiAllowedTiles(): Set { + const allowed = new Set(); + + if (meldBuilderTiles.length === 0) { + // Any non-honor tile is allowed as the first tile + for (const suit of ['m', 'p', 's']) { + for (let i = 1; i <= 9; i++) { + allowed.add(`${i}${suit}`); + } + } + return allowed; + } + + const suit = meldBuilderTiles[0].tile[1]; + + if (meldBuilderTiles.length === 1) { + // Second tile: must be same suit and able to form a sequence with first tile + const v1 = getTileValue(meldBuilderTiles[0].tile, meldBuilderTiles[0].isRed); + // Possible second tiles: v1-2, v1-1, v1+1, v1+2 (that could still form a valid sequence) + for (let delta = -2; delta <= 2; delta++) { + if (delta === 0) continue; + const v2 = v1 + delta; + if (v2 >= 1 && v2 <= 9) { + // Check if v1 and v2 can form part of a valid sequence (3 consecutive tiles exist) + const min = Math.min(v1, v2); + const max = Math.max(v1, v2); + // A valid sequence needs 3 consecutive tiles + // If we have 2 tiles, the third must complete the sequence + // Possible: min-1 (if >= 1), or max+1 (if <= 9) + if (max - min <= 2) { // tiles are close enough to form a sequence + // Check if third tile would be valid + if (max - min === 1) { + // Consecutive: need min-1 or max+1 + if (min - 1 >= 1 || max + 1 <= 9) { + allowed.add(`${v2}${suit}`); + } + } else if (max - min === 2) { + // Gap of 1: middle tile completes it + allowed.add(`${v2}${suit}`); + } + } + } + } + return allowed; + } + + if (meldBuilderTiles.length === 2) { + // Third tile: must complete the sequence + const v1 = getTileValue(meldBuilderTiles[0].tile, meldBuilderTiles[0].isRed); + const v2 = getTileValue(meldBuilderTiles[1].tile, meldBuilderTiles[1].isRed); + const min = Math.min(v1, v2); + const max = Math.max(v1, v2); + + if (max - min === 1) { + // Consecutive tiles, need either end + if (min - 1 >= 1) allowed.add(`${min - 1}${suit}`); + if (max + 1 <= 9) allowed.add(`${max + 1}${suit}`); + } else if (max - min === 2) { + // Gap of 1, need middle tile + allowed.add(`${min + 1}${suit}`); + } + // If gap > 2, no valid third tile exists + return allowed; + } + + return allowed; + } + function addTileToMeldBuilder(tile: string, isRed: boolean = false) { const maxTiles = meldBuilderType === 'kan' || meldBuilderType === 'ankan' ? 4 : 3; if (meldBuilderTiles.length >= maxTiles) return; + + // Check regular tile count if (tileCounts[tile] <= 0) return; - // For chi, tiles must be sequential in the same suit - if (meldBuilderType === 'chi' && meldBuilderTiles.length > 0) { - const suit = meldBuilderTiles[0].tile[1]; - if (tile[1] !== suit || tile[1] === 'z') return; // Must be same suit, no honors + // For red fives, also check the red five count + if (isRed) { + const redKey = `red${tile}` as keyof typeof tileCounts; + if (tileCounts[redKey] <= 0) return; + } + + // For chi, validate that tile can form a valid sequence + if (meldBuilderType === 'chi') { + // Honor tiles cannot be in chi + if (tile[1] === 'z') return; + + const allowedTiles = getChiAllowedTiles(); + if (!allowedTiles.has(tile)) return; } // For pon/kan, tiles must be the same @@ -512,6 +639,7 @@ onSelect={showMeldBuilder ? addTileToMeldBuilder : addTile} tileCounts={tileCounts} showRedFives={true} + disabledTiles={meldBuilderDisabledTiles} /> diff --git a/web/src/lib/components/TilePalette.svelte b/web/src/lib/components/TilePalette.svelte index dd7de7f..a316e8d 100644 --- a/web/src/lib/components/TilePalette.svelte +++ b/web/src/lib/components/TilePalette.svelte @@ -37,13 +37,16 @@ }; // Check if tile is disabled - const isDisabled = (tile: string): boolean => { - return disabledTiles.has(tile) || getCount(tile) <= 0; + const isDisabled = (tile: string, isRed: boolean = false): boolean => { + // Check if tile is in the disabled set + if (disabledTiles.has(tile)) return true; + // Check count + return getCount(tile, isRed) <= 0; }; // Handle tile click const handleClick = (tile: string, isRed: boolean = false) => { - if (!isDisabled(tile)) { + if (!isDisabled(tile, isRed)) { onSelect(tile, isRed); } }; @@ -69,7 +72,7 @@ tile="5m" size="md" red={true} - disabled={getCount('5m', true) <= 0} + disabled={isDisabled('5m', true)} showCount={true} count={getCount('5m', true)} onclick={() => handleClick('5m', true)} @@ -97,7 +100,7 @@ tile="5p" size="md" red={true} - disabled={getCount('5p', true) <= 0} + disabled={isDisabled('5p', true)} showCount={true} count={getCount('5p', true)} onclick={() => handleClick('5p', true)} @@ -125,7 +128,7 @@ tile="5s" size="md" red={true} - disabled={getCount('5s', true) <= 0} + disabled={isDisabled('5s', true)} showCount={true} count={getCount('5s', true)} onclick={() => handleClick('5s', true)}