fujian-mahjong-validator
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.
When & Why to Use This Skill
This Claude skill provides a comprehensive validation engine for Fujian (Fuzhou) Mahjong, also known as 'Gold Rush Mahjong.' It automates the verification of complex game logic, including Gold tile substitutions, 17-tile winning hand detection, and intricate scoring systems. Designed for developers and testers, it ensures game engines strictly adhere to regional rules, covering everything from move legality to special bonus calculations like 'Three Golds' and 'Golden Pair.'
Use Cases
- Game Logic Unit Testing: Automate the QA process for Mahjong game development by verifying that the core engine correctly handles complex Fujian-specific rules and tile substitutions.
- Winning Hand Verification: Instantly validate whether a specific 17-tile hand configuration constitutes a win, including the detection of 'Three Golds' automatic wins.
- Score & Payout Calculation: Accurately compute points based on dealer streaks, bonus tiles, and special multipliers such as 'All One Suit' (+60) or 'Golden Pair' (+30).
- Move Legality Auditing: Verify the legality of player calls (Chow, Pung, Kong) and ensure that Gold tiles are correctly restricted from being used in specific meld calls.
- Algorithm Reference for Developers: Serve as a technical blueprint for implementing recursive winning hand detection and priority-based calling systems (Win > Pung > Chow).
| 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 rulesapp/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)