fujian-mahjong-validator

star 1

Validate Fujian Mahjong game logic including hands, sets, winning conditions, Gold tile rules, and scoring. Use when testing mahjong game code, checking if a hand wins, calculating scores, or verifying move legality.

Teng-AI By Teng-AI schedule Updated 1/11/2026

name: fujian-mahjong-validator description: Validate Fujian Mahjong game logic including hands, sets, winning conditions, Gold tile rules, and scoring. Use when testing mahjong game code, checking if a hand wins, calculating scores, or verifying move legality.

Fujian Mahjong Validator

Validates game logic for Fujian (Fuzhou) Mahjong, also known as "Gold Rush Mahjong" (金麻将).

Current Features: Full game with Kongs, Golden Pair bonus (+30), All One Suit bonus (+60), dealer streak.

When to Use

  • Validate if a hand is a winning hand
  • Check if sets (chows, pungs) are valid
  • Calculate scores for a winning hand
  • Verify move legality (chow, pung, win calls)
  • Test Gold tile substitution logic

Rules Reference

  • mahjong-fujian-rules.md — Complete rules
  • app/FUTURE_FEATURES.md — Roadmap for planned features

Tile Representation

Suits (108 tiles):
  dots_1 to dots_9 (4 copies each = 36)
  bamboo_1 to bamboo_9 (4 copies each = 36)
  characters_1 to characters_9 (4 copies each = 36)

Bonus (20 tiles):
  wind_east, wind_south, wind_west, wind_north (4 copies each = 16)
  dragon_red (4 copies = 4)

Gold:
  One suit tile flipped at game start (not used in play)
  Only 3 Gold tiles remain in the game
  Gold tiles can substitute for ANY tile

Validation Functions

1. Three Golds Detection (Check First!)

Three Golds: Player collects all 3 Gold tiles → instant automatic win

function hasThreeGolds(hand, goldTileType) {
  const goldCount = hand.filter(t => t === goldTileType).length;
  return goldCount === 3;
}
  • Check after EVERY draw (normal, replacement, bonus)
  • Cannot be declined — triggers automatically
  • Counts as self-draw (×2 multiplier)
  • +20 special bonus

2. Hand Validation

During play: 16 tiles (concealed + exposed melds) After draw: 17 tiles temporarily Winning hand: 5 sets + 1 pair = 17 tiles

Sets can be Chow, Pung, or Kong (quad counts as one set)

3. Set Validation

Chow (顺子): 3 consecutive same-suit tiles

// Valid: bamboo_2, bamboo_3, bamboo_4
// Valid with Gold: bamboo_2, GOLD, bamboo_4 (Gold = bamboo_3)

Pung (刻子): 3 identical tiles

// Valid: dots_7, dots_7, dots_7
// Valid with Gold: dots_7, dots_7, GOLD (Gold = dots_7)

Pair (眼): 2 identical tiles

// Valid: characters_5, characters_5
// Valid with Gold: characters_5, GOLD (Gold = characters_5)

Gold tiles can substitute in any position.

4. Win Detection Algorithm

function isWinningHand(tiles, goldTileType) {
  // tiles should be 17 tiles (hand + drawn tile)

  // Step 1: Check Three Golds first
  if (hasThreeGolds(tiles, goldTileType)) {
    return { winning: true, type: 'three_golds' };
  }

  // Step 2: Try to form 5 sets + 1 pair
  const goldCount = tiles.filter(t => t === goldTileType).length;
  const nonGoldTiles = tiles.filter(t => t !== goldTileType);

  return tryFormWinningHand(nonGoldTiles, goldCount);
}

function tryFormWinningHand(tiles, goldsRemaining) {
  // Try each possible pair
  const uniqueTiles = [...new Set(tiles)];

  for (const pairTile of uniqueTiles) {
    const remaining = removeTiles(tiles, [pairTile, pairTile]);
    if (remaining !== null && canFormFiveSets(remaining, goldsRemaining)) {
      return { winning: true, pair: pairTile };
    }
  }

  // Try using Gold(s) as pair
  if (goldsRemaining >= 2) {
    if (canFormFiveSets(tiles, goldsRemaining - 2)) {
      return { winning: true, pair: 'gold_pair' };
    }
  }
  if (goldsRemaining >= 1) {
    for (const pairTile of uniqueTiles) {
      const remaining = removeTiles(tiles, [pairTile]);
      if (remaining !== null && canFormFiveSets(remaining, goldsRemaining - 1)) {
        return { winning: true, pair: pairTile };
      }
    }
  }

  return { winning: false };
}

function canFormFiveSets(tiles, goldsRemaining, setsFormed = 0) {
  if (tiles.length === 0 && setsFormed === 5) return true;
  if (tiles.length === 0 && setsFormed < 5) {
    // Can we complete remaining sets with Golds?
    const setsNeeded = 5 - setsFormed;
    return goldsRemaining >= setsNeeded * 3;
  }
  if (tiles.length < 3 && goldsRemaining < 3 - tiles.length) return false;

  // Sort tiles for consistent processing
  tiles = sortTiles(tiles);
  const firstTile = tiles[0];

  // Try Pung
  if (countTile(tiles, firstTile) >= 3) {
    const remaining = removeTiles(tiles, [firstTile, firstTile, firstTile]);
    if (canFormFiveSets(remaining, goldsRemaining, setsFormed + 1)) {
      return true;
    }
  }

  // Try Pung with Gold(s)
  if (countTile(tiles, firstTile) === 2 && goldsRemaining >= 1) {
    const remaining = removeTiles(tiles, [firstTile, firstTile]);
    if (canFormFiveSets(remaining, goldsRemaining - 1, setsFormed + 1)) {
      return true;
    }
  }
  if (countTile(tiles, firstTile) === 1 && goldsRemaining >= 2) {
    const remaining = removeTiles(tiles, [firstTile]);
    if (canFormFiveSets(remaining, goldsRemaining - 2, setsFormed + 1)) {
      return true;
    }
  }

  // Try Chow (only for suit tiles)
  if (isSuitTile(firstTile)) {
    const [suit, num] = parseTile(firstTile);
    const tile2 = `${suit}_${num + 1}`;
    const tile3 = `${suit}_${num + 2}`;

    if (hasTile(tiles, tile2) && hasTile(tiles, tile3)) {
      const remaining = removeTiles(tiles, [firstTile, tile2, tile3]);
      if (canFormFiveSets(remaining, goldsRemaining, setsFormed + 1)) {
        return true;
      }
    }

    // Try Chow with Gold substitutions (multiple combinations)
    // ... (similar pattern with goldsRemaining)
  }

  return false;
}

5. Score Calculation

function calculateScore(winner) {
  const { bonusTiles, goldsInHand, isSelfDraw, isThreeGolds,
          concealedKongs, exposedKongs, hasGoldenPair, isAllOneSuit } = winner;

  // Non-special points
  let points = 1;                          // Base
  points += bonusTiles.length;             // +1 per bonus tile
  points += goldsInHand;                   // +1 per Gold
  points += concealedKongs * 2;            // +2 per concealed Kong
  points += exposedKongs * 1;              // +1 per exposed Kong

  // Self-draw multiplier
  if (isSelfDraw || isThreeGolds) {
    points = points * 2;
  }

  // Special bonuses (added after multiplier)
  if (isThreeGolds) points += 20;
  if (hasGoldenPair) points += 30;         // 2 Golds form the pair
  if (isAllOneSuit) points += 60;          // All tiles same suit

  return {
    points,
    eachLoserPays: points,
    totalFromLosers: points * 3
  };
}

Scoring Formula:

points = (1 + bonus + golds + kong_bonuses) × 2 if self_draw
       + 20 if three_golds
       + 30 if golden_pair
       + 60 if all_one_suit

6. Move Legality

Chow:

  • Only from player to your LEFT (previous in turn order)
  • Need 2 sequential tiles in hand
  • Gold CANNOT be used for calling

Pung:

  • From ANY player's discard
  • Need 2 matching tiles in hand
  • Gold CANNOT be used for calling

Win:

  • From any discard that completes hand, OR
  • Self-draw that completes hand
  • Winning is OPTIONAL (can decline, except Three Golds)

Priority: Win > Pung > Chow

Tie-breaker: Closest to discarder (counter-clockwise) wins

7. Calling System Validation

function getValidCalls(player, discardedTile, discarderIndex, playerIndex) {
  const calls = [];
  const hand = player.concealedTiles;

  // Check Win
  const testHand = [...hand, discardedTile];
  if (isWinningHand(testHand, goldTileType).winning) {
    calls.push('win');
  }

  // Check Pung (need 2 matching, NOT Gold)
  const matchCount = hand.filter(t => t === discardedTile && t !== goldTileType).length;
  if (matchCount >= 2) {
    calls.push('pung');
  }

  // Check Chow (only from left player)
  const isLeftPlayer = (discarderIndex + 1) % 4 === playerIndex;
  if (isLeftPlayer && isSuitTile(discardedTile)) {
    // Check for sequential tiles (not using Gold)
    const [suit, num] = parseTile(discardedTile);
    const hasSequence =
      (hasTile(hand, `${suit}_${num-2}`) && hasTile(hand, `${suit}_${num-1}`)) ||
      (hasTile(hand, `${suit}_${num-1}`) && hasTile(hand, `${suit}_${num+1}`)) ||
      (hasTile(hand, `${suit}_${num+1}`) && hasTile(hand, `${suit}_${num+2}`));
    if (hasSequence) {
      calls.push('chow');
    }
  }

  // Pass is always available
  calls.push('pass');

  return calls;
}

Testing Checklist

When validating game code, verify:

  • 16 tiles (concealed + exposed) maintained during play
  • 17 tiles after draw, before discard/win
  • Only 3 Gold tiles in play (1 exposed, not used)
  • Wind/dragon tiles auto-exposed with replacement draws
  • Taking discard skips normal draw
  • Three Golds: instant automatic win when player collects all 3 Golds
  • Kong replacement draw from back of wall
  • Scoring includes kong bonuses (+2 concealed, +1 exposed)
  • Golden Pair bonus (+30) when 2 Golds form the pair
  • All One Suit bonus (+60) for flush hands
  • Payment: all 3 losers pay winner
  • Call priority: Win > Kong > Pung > Chow
  • Chow only from player to your left
  • Gold cannot be used for calling (Chow/Pung/Kong)
  • Winning is optional (except Three Golds)
  • All players must manually pass (no auto-pass)

Not Yet Implemented

  • ❌ Calling phase timer (reverted due to Firebase sync issues)
  • ❌ Robbing the Kong
  • ❌ No Bonus/Kong bonus (+10)
Install via CLI
npx skills add https://github.com/Teng-AI/mahjong --skill fujian-mahjong-validator
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator