🌳
pt0/gamesapp/serverF/telgame/gameActionsF.mjs
1import * as _ from 'lodash-es'
13import { aiDraw } from './aiDrawF.mjs'
19const applyUpdatePlayerStates = async ({existingGame}) => {
20 const result = await updatePlayerStates({existingGame})
21 Object.assign(existingGame, result)
24const updateActiveAtValAssStack = () => {
25 const {actionAtMs, existingGame, sessId} = getReqGameActionCtx()
26 if ( !_.get(existingGame, ['playerStates', sessId]) ) {
27 throPtErr('playerNotFound', {sessId})
28 }
30 if (!guardIsAssignedStack()) return false
32 _.set(existingGame, ['playerStates', sessId, 'publicState', 'gameActiveAt'], actionAtMs)
33 return true
36export const setPlayerAlias = async (props) => {
37 const {existingGame, sessId, playerAlias} = props
38 const {ipAddress, userId} = gameActionCtx.getStore() || {}
40 const {settings} = existingGame
42 // Check if player's IP is blocked
43 if (ipAddress && settings?.blockedIps?.includes(ipAddress)) {
44 silPubErr('You have been blocked from this room')
45 }
47 // Check if player is rejoining (same alias exists)
48 const trimmedAlias = _.trim(playerAlias).toLowerCase()
49 const existingPlayerEntry = _.find(_.toPairs(existingGame.playerStates), ([_sessId, playerState]) => {
50 const {publicState: {playerAlias: _playerAlias}} = playerState
51 return trimmedAlias === _playerAlias
52 })
54 // Check alias ownership - if alias exists, verify same user (by userId) or same IP
55 if (existingPlayerEntry && !getEnvConf().skipAliasOwnershipCheck) {
56 const [, existingPlayerState] = existingPlayerEntry
57 const existingUserId = existingPlayerState.userId
58 const existingIp = existingPlayerState.ipAddress
59 // Prefer userId match, fall back to IP match
60 const isOwner = existingUserId
61 ? (userId && existingUserId === userId)
62 : (ipAddress && existingIp === ipAddress)
63 if (!isOwner && (existingUserId || existingIp)) {
64 silPubErr('username already taken')
65 }
66 }
68 // If new player (not rejoining), check max players limit
69 const isNewPlayer = !existingPlayerEntry
70 if (isNewPlayer && settings?.maxHumPlayers) {
71 const activePlayers = _.filter(existingGame.playerStates, ps => !ps.hasBeenKicked)
72 if (activePlayers.length >= settings.maxHumPlayers) {
73 silPubErr(`Room is full (max ${settings.maxHumPlayers} players)`)
74 }
75 }
79 // Store player's IP/userId for ownership validation (after doSetPlayerAlias so sessId is correct)
80 if (ipAddress) _.set(existingGame, ['playerStates', sessId, 'ipAddress'], ipAddress)
81 if (userId) _.set(existingGame, ['playerStates', sessId, 'userId'], userId)
83 await applyUpdatePlayerStates({existingGame})
86export const submitDrawing = async (props) => {
87 const {existingGame, sessId, finishedTrails, assignedStackName, pregenFncRet, isAiGenerated, aiModel, aiCostUsd} = props
89 // AI-generated drawings don't have a player - just add to stack directly
90 if (isAiGenerated) {
91 const {hiddenState} = existingGame
92 const {paperStacks} = hiddenState
93 const stack = paperStacks[assignedStackName]
94 assertDefined(stack, {assignedStackName})
96 const aiPlayerAlias = existingGame.settings?.aiDrawerUsername || getShortModelName(aiModel) || 'ai'
97 const {ipfsCid, imageDataUrl} = props
98 // old games have finishedTrails, newer ones imageDataUrl, newest ipfsCid — replay must handle all
99 /** @type {{lastFinishedTrails?: unknown, lastImageDataUrl?: string, lastIpfsCid?: string}} */
100 let respH = {lastFinishedTrails: finishedTrails}
101 if (imageDataUrl) respH = {lastImageDataUrl: imageDataUrl}
102 if (ipfsCid) respH = {lastIpfsCid: ipfsCid}
103 stack.responses.push({...respH, playerAlias: aiPlayerAlias, isAiGenerated: true, aiCostUsd})
104 return
105 }
107 if (!updateActiveAtValAssStack()) return
109 assertDefined(finishedTrails, props)
111 const {playerState, stack, playerAlias} = getPlayerStateAssStack()
113 // Guard: last response should be a description, not a drawing (prevents race condition)
114 const lastResp = _.last(stack.responses)
115 if (lastResp?.lastFinishedTrails) {
116 return // race condition - another drawing was already submitted
117 }
119 stack.responses.push({lastFinishedTrails: finishedTrails, playerAlias})
120 if (pregenFncRet) {
121 const {content: lastDescription, model, costUsd} = pregenFncRet
122 assertNonEmptyString(lastDescription, {pregenFncRet})
123 stack.responses.push({lastDescription, playerAlias: getShortModelName(model), aiCostUsd: costUsd})
124 }
126 delete playerState.assignedStackName
128 await applyUpdatePlayerStates({existingGame})
130submitDrawing.pregenFnc = aiDescribeDraw
132export const submitDescription = async (props) => {
133 const {existingGame, sessId, descriptionText, assignedStackName, pregenFncRet} = props
134 if (!updateActiveAtValAssStack()) return
136 assertNonEmptyString(descriptionText, props)
138 const {playerState, stack, playerAlias} = getPlayerStateAssStack()
140 // Guard: last response should be a drawing, not a description (prevents race condition)
141 const lastResp = _.last(stack.responses)
142 if (lastResp?.lastDescription) {
143 return // race condition - another description was already submitted
144 }
146 stack.responses.push({lastDescription: descriptionText, playerAlias})
148 // Handle AI drawing from pregenFnc (aiDrawingAfterFirstHumanDescript)
149 if (pregenFncRet?.ipfsCid) {
150 const {ipfsCid, model, costUsd, genTimeMs} = pregenFncRet
151 const modelDerivedAlias = getShortModelName(model) || 'ai'
152 const displayAlias = existingGame.settings?.aiDrawerUsername || modelDerivedAlias
153 stack.responses.push({
154 lastIpfsCid: ipfsCid,
155 playerAlias: displayAlias,
156 aiModelAlias: modelDerivedAlias,
157 isAiGenerated: true,
158 aiCostUsd: costUsd,
159 aiGenTimeMs: genTimeMs,
160 })
161 }
163 delete playerState.assignedStackName
165 await applyUpdatePlayerStates({existingGame})
167submitDescription.pregenFnc = aiDraw
169export const leaveGame = async (props) => {
170 const {existingGame, sessId} = props
172 const playerState = existingGame.playerStates?.[sessId]
173 if (!playerState || playerState.hasBeenKicked) return
174 const {publicState: {playerAlias}} = playerState
175 doKickPlayer({existingGame, playerAlias})
176 await applyUpdatePlayerStates({existingGame})
178export const debugUpdateGameState = async ({existingGame}) => {
179 await applyUpdatePlayerStates({existingGame})
182export const kickPlayer = async (props) => {
183 const {existingGame, playerAlias, blockIp} = props
185 // If blockIp is requested, find and block the player's IP
186 if (blockIp) {
187 const playerEntry = _.find(_.toPairs(existingGame.playerStates), ([_sessId, playerState]) => {
188 return _.get(playerState, ['publicState', 'playerAlias']) === playerAlias
189 })
190 if (playerEntry) {
191 const [, playerState] = playerEntry
192 const playerIp = playerState.ipAddress
193 if (playerIp) {
194 existingGame.settings ||= {}
195 existingGame.settings.blockedIps ||= []
196 if (!existingGame.settings.blockedIps.includes(playerIp)) {
197 existingGame.settings.blockedIps.push(playerIp)
198 }
199 }
200 }
201 }
204 await applyUpdatePlayerStates({existingGame})
206kickPlayer.persistBlockedIps = true
208export const kickAwol = async (props) => {
209 const { existingGame, sessId } = props
210 const playerState = existingGame.playerStates?.[sessId]
211 if (!playerState) return
213 const { playerAlias } = playerState.publicState || {}
214 doKickPlayer({ existingGame, playerAlias })
215 await applyUpdatePlayerStates({existingGame})
218export const resetGame = async ({existingGame}) => {
219 await doResetGame({justFinishedGameId: existingGame.id, markOldGameFinished: true})
221resetGame.pushToHistory = false
223export const gotoActionIdx = async ({existingGame, tgtActionIdx}) => {
224 const {id, gameActionHistA, gqActionsHistA} = existingGame
225 betLog('gotoActionIdx', {tgtActionIdx, id})
226 const isIncrOne = tgtActionIdx == getApplyToIdx() + 1
227 if (isIncrOne) {
228 betLog({isIncrOne, allActionsLen: gameActionHistA.length, appliedActionsLen: gqActionsHistA.length})
229 await applyGameHistActionIdx({gameActionHistA, gqActionsHistA, existingGame, curActionId: tgtActionIdx})
230 setApplyToIdx(tgtActionIdx)
231 return
232 }
233 setApplyToIdx(tgtActionIdx)
234 _.each(existingGame, (val, key) => delete existingGame[key])
235 let dbGame = await getGameStateAtIdx({id, isEphemeralGame: true})
236 Object.assign(existingGame, dbGame)
238gotoActionIdx.pushToHistory = false