🌳
pt0/gamesapp/serverF/gameState/doGameActionF.mts
1import * as _ from 'lodash-es'
2import { sql } from 'kysely'
16import type { GamesDB, GameActionH } from '../gamesDbF.d.ts'
17import type { Kysely } from 'kysely'
19type ApplyGameActionParams = {
20 existingGame: any
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]
29 if (!gameActionFnc) {
30 throPtErr('!gameActionFnc', {playerAction})
31 }
32 // Preserve ipAddress/userId from existing context if not in gameActionH
33 const existingCtx = gameActionCtx.getStore() || {}
34 const enterH = {
35 ...gameActionH,
36 existingGame,
37 ipAddress: gameActionH.ipAddress || existingCtx.ipAddress,
38 userId: gameActionH.userId ?? existingCtx.userId,
39 }
40 if (gameRoom) gameRoomCtx.enterWith({gameRoom}) // ctx:clear
41 gameActionCtx.enterWith(enterH)
42 await gameActionFnc(enterH)
45type DoGameActionParams = {
46 gameRoom: string
47 sessId: string
48 playerAction: string
49 ipAddress?: string
50 userId?: string | null
51 [key: string]: any
54export const doGameAction = async ({gameRoom, ...gameActionH}: DoGameActionParams) => {
55 assertDefined(gameActionH.sessId)
56 assertDefined(gameRoom)
57 throwIf(() => isReplayGameCtx.getStore(), {gameRoom, playerAction: gameActionH.playerAction})
61 let existingGame = await fetchGameState({gameRoom})
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})
71 if (pregenFnc) {
72 const pregenActionAtMs = gameActionH.actionAtMs
73 const sessId = gameActionH.sessId
74 if (sessId && existingGame.playerStates?.[sessId]) {
75 _.set(existingGame, ['playerStates', sessId, 'publicState', 'gameActiveAt'], pregenActionAtMs)
76 }
77 gameActionH = {...gameActionH, pregenFncRet: await pregenFnc()}
78 }
79 if (pushToHistory === false) {
80 recordActionInHistory = false
81 }
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')
89 .set((eb) => ({
90 jdata: sql`jsonb_set(COALESCE(jdata, '{}'), '{blockedIps}', ${JSON.stringify(existingGame.settings.blockedIps)}::jsonb)`,
91 updated_at: sql`now()`
92 }))
93 .where('room_label', '=', gameRoom)
94 .execute()
95 }
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})
109 }
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
122 }
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})
129 } else {
130 if (isFirstAction) {
131 const room_id = await lookupGameroomId({gameRoom, db})
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}))
135 .execute()
136 } else {
137 const updateResult = await db.updateTable('dbgames')
138 .set({game_state, updated_at: sql`now()`, active_player_count})
139 .where('id', '=', id)
140 .executeTakeFirst()
141 throwIf(() => Number(updateResult.numUpdatedRows) === 0, {id, gameRoom, gameActionsLen})
142 }
143 }
144 }
146 const needsAiDrawings = existingGame.isGameFin && existingGame.needsAiDrawings
147 if (existingGame.isGameFin && !needsAiDrawings) {
148 await handleGameFinished({existingGame, gameRoom})
149 }
151 return {gameRoom, needsAiDrawings}