1import * as _ from 'lodash-es' 2import { sql } from 'kysely' 17import type { Kysely } from 'kysely' 19type ApplyGameActionParams = { 21 gameRoom: string | undefined 22 gameActionH: {playerAction: string; [key: string]: any} 25export const applyGameAction = async ({ 26 existingGame, gameRoom, gameActionH: {playerAction, ...gameActionH} 27}: ApplyGameActionParams) => { 28 const gameActionFnc = (gameActionFncs as any)[playerAction] 32 // Preserve ipAddress/userId from existing context if not in gameActionH 33 const existingCtx = gameActionCtx.getStore() || {} 37 ipAddress: gameActionH.ipAddress || existingCtx.ipAddress, 38 userId: gameActionH.userId ?? existingCtx.userId, 40 if (gameRoom) gameRoomCtx.enterWith({gameRoom}) // ctx:clear 41 gameActionCtx.enterWith(enterH) 42 await gameActionFnc(enterH) 45type DoGameActionParams = { 50 userId?: string | null 54export const doGameAction = async ({gameRoom, ...gameActionH}: DoGameActionParams) => { 57 throwIf(() => isReplayGameCtx.getStore(), {gameRoom, playerAction: gameActionH.playerAction}) 63 let recordActionInHistory = true 65 const gameActionFnc = (gameActionFncs as any)[gameActionH.playerAction] 66 const {pushToHistory, pregenFnc, persistBlockedIps} = gameActionFnc 68 if (gameRoom) gameRoomCtx.enterWith({gameRoom}) // ctx:clear 69 gameActionH = {...gameActionH, actionAtMs: _.now()} 70 gameActionCtx.enterWith({...gameActionH, existingGame}) 72 const pregenActionAtMs = gameActionH.actionAtMs 73 const sessId = gameActionH.sessId 74 if (sessId && existingGame.playerStates?.[sessId]) { 75 _.set(existingGame, ['playerStates', sessId, 'publicState', 'gameActiveAt'], pregenActionAtMs) 77 gameActionH = {...gameActionH, pregenFncRet: await pregenFnc()} 79 if (pushToHistory === false) { 80 recordActionInHistory = false 83 await applyGameAction({existingGame, gameActionH, gameRoom}) 84 // make sure invalid action throws^ before recording below 86 // Persist blockedIps to room jdata if action modified them 87 if (persistBlockedIps && gameActionH.blockIp && existingGame.settings?.blockedIps?.length) { 88 await db.updateTable('gamerooms') 90 jdata: sql`jsonb_set(COALESCE(jdata, '{}'), '{blockedIps}', ${JSON.stringify(existingGame.settings.blockedIps)}::jsonb)`, 91 updated_at: sql`now()` 93 .where('room_label', '=', gameRoom) 97 if (recordActionInHistory) { 98 const {id} = existingGame 100 const {isEphemeralGame} = existingGame 101 existingGame.gameActionHistA ||= [] 102 const {gameActionHistA} = existingGame 103 const isFirstAction = gameActionHistA.length === 0 105 // Validate before mutating in-memory state — assertion failure must not corrupt gameActionHistA 106 if (isFirstAction && !isEphemeralGame) { 107 const allowedFirstActions = ['setPlayerAlias'] as const 108 assertIncludes(allowedFirstActions, gameActionH.playerAction, {playerAction: gameActionH.playerAction, gameRoom, id}) 111 // Attach playerAlias so extractPlayerNames works for all actions, not just setPlayerAlias 112 const myAlias = existingGame.playerStates?.[gameActionH.sessId]?.publicState?.playerAlias 113 if (myAlias) gameActionH.playerAlias ||= myAlias 115 gameActionHistA.push(gameActionH) 116 // Append AI actions that were generated during this action (e.g. end-of-game AI drawings) 117 // Must come AFTER the triggering human action for correct replay ordering 118 const {pendingAiActionHistA} = existingGame 119 if (pendingAiActionHistA?.length) { 120 gameActionHistA.push(...pendingAiActionHistA) 121 delete existingGame.pendingAiActionHistA 124 const game_state = {gameActionHistA} 125 const gameActionsLen = gameActionHistA.length 126 const active_player_count = _.filter(existingGame.playerStates, (ps: any) => !ps.hasBeenKicked).length 127 if (isEphemeralGame) { 128 betLog('skiprecord', {gameActionHistALen: gameActionsLen, isEphemeralGame}) 132 await db.insertInto('dbgames') 133 .values({game_id: gameRoom, id, game_state, updated_at: sql`now()`, room_id, active_player_count}) 134 .onConflict(oc => oc.column('id').doUpdateSet({game_state, updated_at: sql`now()`, active_player_count})) 137 const updateResult = await db.updateTable('dbgames') 138 .set({game_state, updated_at: sql`now()`, active_player_count}) 139 .where('id', '=', id) 141 throwIf(() => Number(updateResult.numUpdatedRows) === 0, {id, gameRoom, gameActionsLen}) 146 const needsAiDrawings = existingGame.isGameFin && existingGame.needsAiDrawings 147 if (existingGame.isGameFin && !needsAiDrawings) { 151 return {gameRoom, needsAiDrawings}