game-state-debugger
Debug Fujian Mahjong game state issues. Use when game state is inconsistent, tiles are missing/duplicated, turns are stuck, or multiplayer sync issues occur.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
Game State Debugger
Helps debug game state issues in Fujian Mahjong.
When to Use
- Tiles are missing or duplicated
- Hand sizes are wrong
- Turn order is broken
- Calling phase is stuck
- Scores don't match expected
- Multiplayer state is out of sync
- Game phase transitions fail
State Validation Functions
1. Tile Count Validation
Total tiles should always equal 128.
function validateTileCount(gameState, privateHands) {
const errors = [];
let totalTiles = 0;
const allTiles = [];
// Wall
totalTiles += gameState.wall.length;
allTiles.push(...gameState.wall);
// Discard pile
totalTiles += gameState.discardPile.length;
allTiles.push(...gameState.discardPile);
// Exposed gold
totalTiles += 1;
allTiles.push(gameState.exposedGold);
// Each player
for (let seat = 0; seat < 4; seat++) {
// Concealed tiles
const concealed = privateHands[`seat${seat}`]?.concealedTiles || [];
totalTiles += concealed.length;
allTiles.push(...concealed);
// Exposed melds
const melds = gameState.exposedMelds[`seat${seat}`] || [];
for (const meld of melds) {
totalTiles += meld.tiles.length;
allTiles.push(...meld.tiles);
}
// Bonus tiles
const bonus = gameState.bonusTiles[`seat${seat}`] || [];
totalTiles += bonus.length;
allTiles.push(...bonus);
}
if (totalTiles !== 128) {
errors.push({
type: 'TILE_COUNT_MISMATCH',
expected: 128,
actual: totalTiles,
difference: 128 - totalTiles
});
}
return { totalTiles, allTiles, errors };
}
2. Duplicate Tile Detection
Each tile ID should appear exactly once.
function findDuplicateTiles(allTiles) {
const seen = new Map();
const duplicates = [];
for (const tile of allTiles) {
if (seen.has(tile)) {
duplicates.push({
tile,
firstLocation: seen.get(tile),
duplicateLocation: 'unknown' // Would need to track location
});
} else {
seen.set(tile, 'found');
}
}
return duplicates;
}
3. Hand Size Validation
function validateHandSizes(gameState, privateHands) {
const errors = [];
const phase = gameState.phase;
const currentPlayer = gameState.currentPlayerSeat;
for (let seat = 0; seat < 4; seat++) {
const concealed = privateHands[`seat${seat}`]?.concealedTiles || [];
const melds = gameState.exposedMelds[`seat${seat}`] || [];
const meldTileCount = melds.reduce((sum, m) => sum + m.tiles.length, 0);
const totalInHand = concealed.length + meldTileCount;
let expectedSize;
let allowedSizes;
if (phase === 'playing' && seat === currentPlayer) {
// Current player after draw: 17 tiles
expectedSize = 17;
allowedSizes = [16, 17]; // 16 if just discarded, 17 if just drew
} else {
// Other players or non-turn: 16 tiles
expectedSize = 16;
allowedSizes = [16];
}
if (!allowedSizes.includes(totalInHand)) {
errors.push({
type: 'HAND_SIZE_ERROR',
seat,
expected: expectedSize,
actual: totalInHand,
concealed: concealed.length,
melds: meldTileCount,
phase,
isCurrentPlayer: seat === currentPlayer
});
}
}
return errors;
}
4. Gold Tile Validation
function validateGoldTiles(gameState, privateHands) {
const errors = [];
const goldType = gameState.goldTileType;
// Find all gold tiles in play
const goldTiles = [];
// Check exposed gold
if (getTileType(gameState.exposedGold) === goldType) {
goldTiles.push({ tile: gameState.exposedGold, location: 'exposed' });
} else {
errors.push({
type: 'EXPOSED_GOLD_MISMATCH',
expected: goldType,
actual: getTileType(gameState.exposedGold)
});
}
// Check all locations for gold tiles
for (let seat = 0; seat < 4; seat++) {
const concealed = privateHands[`seat${seat}`]?.concealedTiles || [];
for (const tile of concealed) {
if (getTileType(tile) === goldType) {
goldTiles.push({ tile, location: `seat${seat}_concealed` });
}
}
const melds = gameState.exposedMelds[`seat${seat}`] || [];
for (const meld of melds) {
for (const tile of meld.tiles) {
if (getTileType(tile) === goldType) {
goldTiles.push({ tile, location: `seat${seat}_meld` });
}
}
}
}
for (const tile of gameState.discardPile) {
if (getTileType(tile) === goldType) {
goldTiles.push({ tile, location: 'discard' });
}
}
for (const tile of gameState.wall) {
if (getTileType(tile) === goldType) {
goldTiles.push({ tile, location: 'wall' });
}
}
// Should be exactly 4 gold tiles (1 exposed + 3 in play)
if (goldTiles.length !== 4) {
errors.push({
type: 'GOLD_TILE_COUNT_ERROR',
expected: 4,
actual: goldTiles.length,
found: goldTiles
});
}
return { goldTiles, errors };
}
5. Turn Order Validation
function validateTurnOrder(gameState, actionHistory) {
const errors = [];
// Check that turns follow counter-clockwise order
let expectedNext = gameState.dealerSeat;
for (const action of actionHistory) {
if (action.type === 'discard') {
if (action.seat !== expectedNext) {
// Could be valid if someone called a discard
const previousAction = actionHistory[actionHistory.indexOf(action) - 1];
if (!previousAction || !['pung', 'chow'].includes(previousAction.type)) {
errors.push({
type: 'TURN_ORDER_VIOLATION',
expected: expectedNext,
actual: action.seat,
action
});
}
}
expectedNext = (action.seat + 3) % 4; // Counter-clockwise
} else if (action.type === 'pung' || action.type === 'chow') {
expectedNext = action.seat; // Caller becomes current player
}
}
return errors;
}
6. Calling Phase Validation
function validateCallingPhase(gameState) {
const errors = [];
if (gameState.phase !== 'calling') {
return errors;
}
const calls = gameState.pendingCalls;
// Check that exactly one player is marked as discarder
const discarders = Object.entries(calls)
.filter(([_, call]) => call === 'discarder');
if (discarders.length !== 1) {
errors.push({
type: 'CALLING_PHASE_NO_DISCARDER',
discarders
});
}
// Check that non-discarders have valid call values
for (const [seat, call] of Object.entries(calls)) {
if (call === 'discarder') continue;
const validCalls = ['win', 'pung', 'chow', 'pass', null];
if (!validCalls.includes(call)) {
errors.push({
type: 'INVALID_CALL_VALUE',
seat,
call
});
}
}
return errors;
}
7. Meld Validation
function validateMelds(gameState, goldTileType) {
const errors = [];
for (let seat = 0; seat < 4; seat++) {
const melds = gameState.exposedMelds[`seat${seat}`] || [];
for (const meld of melds) {
if (!['chow', 'pung', 'kong', 'concealed_kong'].includes(meld.type)) {
errors.push({
type: 'INVALID_MELD_TYPE',
seat,
meld,
reason: 'Valid types: chow, pung, kong, concealed_kong'
});
continue;
}
const expectedSize = meld.type.includes('kong') ? 4 : 3;
if (meld.tiles.length !== expectedSize) {
errors.push({
type: 'INVALID_MELD_SIZE',
seat,
meld,
expected: expectedSize,
actual: meld.tiles.length
});
continue;
}
// Validate meld is actually valid
const tileTypes = meld.tiles.map(t => getTileType(t));
if (meld.type === 'pung') {
// All tiles should be same type (accounting for gold)
const nonGoldTypes = tileTypes.filter(t => t !== goldTileType);
const uniqueNonGold = [...new Set(nonGoldTypes)];
if (uniqueNonGold.length > 1) {
errors.push({
type: 'INVALID_PUNG',
seat,
meld,
reason: 'Pung tiles are not identical'
});
}
} else if (meld.type === 'chow') {
// Should be sequential same-suit tiles
const isValid = isValidChow(tileTypes, goldTileType);
if (!isValid) {
errors.push({
type: 'INVALID_CHOW',
seat,
meld,
reason: 'Chow is not a valid sequence'
});
}
}
}
}
return errors;
}
function isValidChow(tileTypes, goldTileType) {
// Filter out golds and check if remaining can form sequence
const nonGold = tileTypes.filter(t => t !== goldTileType);
const goldCount = tileTypes.length - nonGold.length;
if (nonGold.length === 0) {
// All golds - technically valid as any chow
return true;
}
// Parse tiles
const parsed = nonGold.map(t => {
const [suit, num] = t.split('_');
return { suit, num: parseInt(num) };
});
// All must be same suit
const suits = [...new Set(parsed.map(p => p.suit))];
if (suits.length !== 1 || !['dots', 'bamboo', 'characters'].includes(suits[0])) {
return false;
}
// Check if can form sequence with gold substitution
const nums = parsed.map(p => p.num).sort((a, b) => a - b);
// With gold(s), check if sequence is possible
// e.g., [2, 4] with 1 gold could be 2-3-4
// This is a simplified check
const min = Math.min(...nums);
const max = Math.max(...nums);
return max - min <= 2; // Sequence spans at most 3 numbers
}
Debug Output Functions
Full State Dump
function dumpGameState(gameState, privateHands) {
console.log('=== GAME STATE DEBUG ===');
console.log('Phase:', gameState.phase);
console.log('Current Player:', gameState.currentPlayerSeat);
console.log('Dealer:', gameState.dealerSeat);
console.log('Gold Type:', gameState.goldTileType);
console.log('Wall Size:', gameState.wall.length);
console.log('Discard Pile Size:', gameState.discardPile.length);
console.log('Last Action:', JSON.stringify(gameState.lastAction));
console.log('\n--- Player Hands ---');
for (let seat = 0; seat < 4; seat++) {
const concealed = privateHands[`seat${seat}`]?.concealedTiles || [];
const melds = gameState.exposedMelds[`seat${seat}`] || [];
const bonus = gameState.bonusTiles[`seat${seat}`] || [];
console.log(`Seat ${seat}:`);
console.log(` Concealed (${concealed.length}):`, concealed.join(', '));
console.log(` Melds (${melds.length}):`, JSON.stringify(melds));
console.log(` Bonus (${bonus.length}):`, bonus.join(', '));
}
if (gameState.phase === 'calling') {
console.log('\n--- Pending Calls ---');
console.log(JSON.stringify(gameState.pendingCalls, null, 2));
}
}
Validation Report
function generateValidationReport(gameState, privateHands) {
const report = {
timestamp: new Date().toISOString(),
phase: gameState.phase,
errors: [],
warnings: []
};
// Run all validations
const tileValidation = validateTileCount(gameState, privateHands);
report.errors.push(...tileValidation.errors);
const duplicates = findDuplicateTiles(tileValidation.allTiles);
if (duplicates.length > 0) {
report.errors.push({
type: 'DUPLICATE_TILES',
duplicates
});
}
const handErrors = validateHandSizes(gameState, privateHands);
report.errors.push(...handErrors);
const goldValidation = validateGoldTiles(gameState, privateHands);
report.errors.push(...goldValidation.errors);
const callingErrors = validateCallingPhase(gameState);
report.errors.push(...callingErrors);
const meldErrors = validateMelds(gameState, gameState.goldTileType);
report.errors.push(...meldErrors);
// Summary
report.isValid = report.errors.length === 0;
report.errorCount = report.errors.length;
report.warningCount = report.warnings.length;
return report;
}
Common Issues and Fixes
Issue: Tiles Disappearing
Symptoms: Tile count < 128, tiles missing
Common Causes:
- Tile removed from hand but not added to discard/meld
- Replacement draw not added to hand
- Race condition in multiplayer
Debug Steps:
// Track where tiles went
function trackTileMovement(before, after, action) {
const beforeTiles = getAllTiles(before);
const afterTiles = getAllTiles(after);
const missing = beforeTiles.filter(t => !afterTiles.includes(t));
const added = afterTiles.filter(t => !beforeTiles.includes(t));
console.log('Action:', action);
console.log('Missing:', missing);
console.log('Added:', added);
if (missing.length !== added.length) {
console.error('TILE COUNT CHANGED!');
}
}
Issue: Turn Stuck
Symptoms: Game phase doesn't advance, no one can act
Common Causes:
- pendingCalls has null values that never get filled
- Phase transition condition not met
- Current player disconnected
Debug Steps:
function debugStuckTurn(gameState) {
console.log('Phase:', gameState.phase);
console.log('Current Player:', gameState.currentPlayerSeat);
if (gameState.phase === 'calling') {
const calls = gameState.pendingCalls;
const waiting = Object.entries(calls)
.filter(([_, v]) => v === null)
.map(([k, _]) => k);
console.log('Waiting for responses from:', waiting);
}
if (gameState.phase === 'playing') {
console.log('Waiting for seat', gameState.currentPlayerSeat, 'to act');
}
}
Issue: Wrong Score
Symptoms: Calculated score doesn't match expected
Debug Steps:
function debugScore(winner, gameState, privateHands) {
const hand = privateHands[`seat${winner.seat}`].concealedTiles;
const bonus = gameState.bonusTiles[`seat${winner.seat}`];
const goldType = gameState.goldTileType;
const goldsInHand = hand.filter(t => getTileType(t) === goldType).length;
console.log('Score Debug:');
console.log(' Base: 1');
console.log(' Bonus tiles:', bonus.length, '(+' + bonus.length + ')');
console.log(' Golds in hand:', goldsInHand, '(+' + goldsInHand + ')');
console.log(' Subtotal:', 1 + bonus.length + goldsInHand);
console.log(' Self-draw:', winner.isSelfDraw ? 'Yes (×2)' : 'No');
console.log(' Three Golds:', winner.isThreeGolds ? 'Yes (+20)' : 'No');
let total = 1 + bonus.length + goldsInHand;
if (winner.isSelfDraw || winner.isThreeGolds) {
total *= 2;
}
if (winner.isThreeGolds) {
total += 20;
}
console.log(' Calculated Total:', total);
}
Usage
When debugging, call these functions with your game state:
// Quick validation
const report = generateValidationReport(gameState, privateHands);
if (!report.isValid) {
console.error('Game state invalid:', report.errors);
}
// Full dump
dumpGameState(gameState, privateHands);
// Specific issue
debugStuckTurn(gameState);
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-01-26
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!