1import * as _ from 'lodash-es' 5// AI player names (from getShortModelName) - they auto-respond after drawings 6const aiPlayerPatterns = ['gpt', 'mytho', 'wizard', 'deep', 'claude', 'gemini', 'llama'] 7const isAiPlayer = (playerAlias) => { 8 if (!playerAlias) return false 9 const lower = playerAlias.toLowerCase() 10 return aiPlayerPatterns.some(p => lower.startsWith(p)) 13export const invariants = { 15 * Player can only submit to their assigned stack 17 assignedStackConsistency: (gameState) => { 18 const { playerStates, hiddenState } = gameState 19 const paperStacks = hiddenState?.paperStacks || {} 21 for (const [sessId, ps] of _.toPairs(playerStates)) { 22 if (ps.hasBeenKicked) continue 23 const { assignedStackName } = ps 24 if (!assignedStackName) continue 26 const stack = paperStacks[assignedStackName] 28 return { ok: false, message: `Player ${sessId} assigned to non-existent stack ${assignedStackName}` } 35 * Task name matches task type (drawing prompt = DrawDescription, text prompt = DescribeDrawing) 37 taskTypeConsistency: (gameState) => { 38 const { playerStates } = gameState 40 for (const [sessId, ps] of _.toPairs(playerStates)) { 41 if (ps.hasBeenKicked) continue 42 const { publicState, taskPrompt } = ps 43 const { myTaskName } = publicState || {} 45 if (myTaskName === 'DrawDescription') { 46 if (taskPrompt && typeof taskPrompt !== 'string') { 47 return { ok: false, message: `DrawDescription task should have string prompt, got ${typeof taskPrompt}` } 50 if (myTaskName === 'DescribeDrawing') { 51 // taskPrompt can be trails array, imageDataUrl string, or ipfsCid string 52 if (taskPrompt && !Array.isArray(taskPrompt) && typeof taskPrompt !== 'string') { 53 return { ok: false, message: `DescribeDrawing task should have array (trails) or string (imageDataUrl/ipfsCid), got ${typeof taskPrompt}` } 61 * Responses alternate: description -> drawing -> description -> ... 63 responseAlternation: (gameState) => { 64 const paperStacks = gameState.hiddenState?.paperStacks || {} 66 for (const [stackName, stack] of _.toPairs(paperStacks)) { 67 const { responses } = stack 68 if (!responses || responses.length === 0) continue 70 // First response should be description (initial prompt) 71 if (!responses[0].lastDescription) { 72 return { ok: false, message: `Stack ${stackName} first response should be description` } 75 for (let i = 1; i < responses.length; i++) { 76 const prev = responses[i - 1] 77 const curr = responses[i] 79 // After description comes drawing 80 if (prev.lastDescription && !respHasDrawing(curr) && !curr.lastDescription) { 81 return { ok: false, message: `Stack ${stackName} response ${i}: expected drawing after description` } 83 // After drawing comes description (human or AI auto-describe) 85 return { ok: false, message: `Stack ${stackName} response ${i}: expected description after drawing` } 87 // Two descriptions in a row: only OK if second is AI (auto-describe after drawing vision) 88 if (prev.lastDescription && curr.lastDescription && !isAiPlayer(curr.playerAlias)) { 89 return { ok: false, message: `Stack ${stackName} response ${i}: two human descriptions in a row (${prev.playerAlias} then ${curr.playerAlias})` } 91 // Two drawings in a row is NOT OK 93 return { ok: false, message: `Stack ${stackName} response ${i}: two drawings in a row` } 101 * No human player responds to same stack twice (AI can respond multiple times) 103 noDoubleResponses: (gameState) => { 104 const paperStacks = gameState.hiddenState?.paperStacks || {} 106 for (const [stackName, stack] of _.toPairs(paperStacks)) { 107 const { responses } = stack 108 if (!responses) continue 110 const playerResponses = {} 111 for (const resp of responses) { 112 const { playerAlias } = resp 113 if (!playerAlias) continue 114 // Skip AI responses - they auto-describe after drawings 115 if (isAiPlayer(playerAlias)) continue 117 playerResponses[playerAlias] = (playerResponses[playerAlias] || 0) + 1 118 if (playerResponses[playerAlias] > 1) { 119 return { ok: false, message: `Player ${playerAlias} responded to stack ${stackName} multiple times` } 129 * Check all invariants against game state 131export const checkAllInvariants = (gameState) => { 135 for (const [name, checkFn] of _.toPairs(invariants)) { 136 const result = checkFn(gameState) 137 results[name] = result 138 if (!result.ok) allOk = false 141 return { allOk, results } 145 * Assert all invariants pass, throw if any fail 147export const assertInvariants = (gameState, context = '') => { 148 const { allOk, results } = checkAllInvariants(gameState) 149 if (allOk) return true 151 const failures = _.map(_.filter(_.toPairs(results), 152 ([_, r]) => !r.ok), ([name, r]) => `${name}: ${r.message}`)