diff --git a/client/apps/games.lua b/client/apps/games.lua index efe11dda..8b8801d0 100644 --- a/client/apps/games.lua +++ b/client/apps/games.lua @@ -4,7 +4,7 @@ ---@type string[] NUI action suffixes proxied 1:1 to sd-phone:server:games:. local ACTIONS = { 'createLobby', 'lobbies', 'joinLobby', 'inviteLobby', 'declineInvite', - 'leaveLobby', 'kickMember', 'setWager', 'setReady', 'returnToLobby', 'startLobby', 'pending', 'move', + 'leaveLobby', 'kickMember', 'setWager', 'setReady', 'setupReady', 'returnToLobby', 'startLobby', 'pending', 'move', 'resign', 'finish', 'report', 'stats', 'record', 'leaderboard', 'submitScore', 'scoreboard', 'chipsGet', 'chipsBuy', 'chipsSell', 'bjDeal', 'bjHit', 'bjStand', 'bjDouble', diff --git a/server/battleship/init.lua b/server/battleship/init.lua index feeeb76f..86bf85ee 100644 --- a/server/battleship/init.lua +++ b/server/battleship/init.lua @@ -1,5 +1,7 @@ ---@type table Online-game engine (server.games.engine): lobbies, invites, move relay, wagers, stats. local engine = require 'server.games.engine' --- Battleship online: the generalized engine with two sides ('1' goes first), opaque shot/result relay. -engine.register('battleship', { sides = { '1', '2' }, title = 'Battleship' }) +-- Battleship online: the generalized engine with two sides ('1' goes first), opaque shot/result +-- relay. `requiresSetup` holds every shot until BOTH players have reported their fleet placed - +-- without it the first player to deploy could fire into a board still being shuffled. +engine.register('battleship', { sides = { '1', '2' }, title = 'Battleship', requiresSetup = true }) diff --git a/server/games/engine.lua b/server/games/engine.lua index 4bed74b6..52865387 100644 --- a/server/games/engine.lua +++ b/server/games/engine.lua @@ -478,6 +478,9 @@ lib.callback.register('sd-phone:server:games:startLobby', function(src, payload) game = game, turn = sides[1], wager = wager, pot = wager * 2, settled = wager == 0, reports = {}, players = { [hostSide] = hostSrc, [oppSide] = oppSrc }, + -- Games flagged `requiresSetup` (battleship places its fleet first) stay closed + -- to moves until BOTH sides report ready; see the games:ready callback below. + ready = {}, } lobby.gameId = gameId lobby.starting = nil @@ -489,6 +492,37 @@ lib.callback.register('sd-phone:server:games:startLobby', function(src, payload) return ok() end) +---Reports that this side has finished setup (battleship: fleet placed). Once BOTH sides are in, +---the engine picks who moves first and pushes 'begin' to both clients - the ONLY point from which +---moves are accepted in a `requiresSetup` game. Idempotent; a non-participant call is a no-op. +lib.callback.register('sd-phone:server:games:setupReady', function(src, payload) + payload = type(payload) == 'table' and payload or {} + local g = games[payload.gameId] + if not g then return fail('Game over') end + local mySide = sideOf(g, src) + if not mySide then return fail('Not your game') end + + local sides = sidesOf(g.game) + if not sides then return ok() end + if g.ready[mySide] then return ok() end + g.ready[mySide] = true + + -- Tell the waiting side someone is now deployed, so it can show progress. + local opp = opponentOf(g, src) + if online(opp) then pushClient(opp, g.game, 'oppReady', { gameId = payload.gameId }) end + + if not (g.ready[sides[1]] and g.ready[sides[2]]) then return ok() end + + g.turn = sides[1] + for _, side in ipairs(sides) do + local p = g.players[side] + if online(p) then + pushClient(p, g.game, 'begin', { gameId = payload.gameId, turn = g.turn, you = side }) + end + end + return ok() +end) + ---Relays an opaque move to the opponent. Only a session participant may move, and for turn-based ---games only on their own turn; freeRelay games forward moves without enforcing alternation. lib.callback.register('sd-phone:server:games:move', function(src, payload) @@ -502,6 +536,15 @@ lib.callback.register('sd-phone:server:games:move', function(src, payload) if online(opp) then pushClient(opp, g.game, 'move', { gameId = payload.gameId, move = payload.move }) end return ok() end + -- Setup gate: in battleship the client used to flip itself to 'firing' locally, so + -- whoever deployed first could fire while the opponent was still shuffling. The + -- server owns that now - no move lands until both fleets are placed. + if configs[g.game] and configs[g.game].requiresSetup then + local sides = sidesOf(g.game) + if not sides or not (g.ready[sides[1]] and g.ready[sides[2]]) then + return fail('Opponent is still deploying') + end + end if g.turn ~= mySide then return fail('Not your turn') end g.turn = otherSide(g.game, mySide) diff --git a/web/src/apps/_games/onlineApi.ts b/web/src/apps/_games/onlineApi.ts index 8efb6ed0..d9eba3c5 100644 --- a/web/src/apps/_games/onlineApi.ts +++ b/web/src/apps/_games/onlineApi.ts @@ -76,6 +76,15 @@ export function moveApi(gameId: string, move: unknown): void { void fetchNui('sd-phone:games:move', { gameId, move }); } +/** + * Reports that this player's pre-match setup is done (battleship: fleet placed). The server + * holds every move until BOTH sides report, then pushes `:begin` with whose turn it is. + */ +export function setupReadyApi(gameId: string): void { + if (!isFiveM || !gameId) return; + void fetchNui('sd-phone:games:setupReady', { gameId }); +} + export function resignApi(gameId: string): void { if (!isFiveM || !gameId) return; void fetchNui('sd-phone:games:resign', { gameId }); diff --git a/web/src/apps/_games/useOnlineLobby.ts b/web/src/apps/_games/useOnlineLobby.ts index 6de755a3..c5ea610f 100644 --- a/web/src/apps/_games/useOnlineLobby.ts +++ b/web/src/apps/_games/useOnlineLobby.ts @@ -18,6 +18,10 @@ export interface UseOnlineLobbyOptions { onStart: (d: OnlineStartData) => void; /** An opponent move arrived (the `move` payload of the `:move` event). */ onMove?: (move: unknown) => void; + /** Both sides finished setup: the match is live and `turn` says who moves first. */ + onBegin?: (d: { turn: string; you: string }) => void; + /** The opponent finished their setup; we may still be placing. */ + onOppReady?: () => void; /** The opponent resigned or left mid-match. */ onEnded: (reason: string) => void; /** Return-to-lobby succeeded: clear match state and show the lobby screen. */ @@ -101,6 +105,10 @@ export function useOnlineLobby(game: string, options: UseOnlineLobbyOptions): On useGameEvent<{ move?: unknown }>(`${game}:move`, d => { if (d?.move != null) opts.current.onMove?.(d.move); }); + useGameEvent<{ turn?: string; you?: string }>(`${game}:begin`, d => { + if (d?.turn && d?.you) opts.current.onBegin?.({ turn: d.turn, you: d.you }); + }); + useGameEvent(`${game}:oppReady`, () => { opts.current.onOppReady?.(); }); useGameEvent<{ reason?: string }>(`${game}:ended`, d => { opts.current.onEnded(d?.reason || 'left'); }); diff --git a/web/src/apps/battleship/Battleship.tsx b/web/src/apps/battleship/Battleship.tsx index 080b7054..617626c2 100644 --- a/web/src/apps/battleship/Battleship.tsx +++ b/web/src/apps/battleship/Battleship.tsx @@ -15,7 +15,7 @@ import { LobbyRoom } from '@/apps/_games/LobbyRoom'; import { Leaderboard } from '@/apps/_games/Leaderboard'; import { GameOverDialog } from '@/apps/_games/GameOverDialog'; import { GameHeader } from '@/apps/_games/GameHeader'; -import { finishApi, moveApi, registerGameSides, reportResultApi, type Side } from '@/apps/_games/onlineApi'; +import { finishApi, moveApi, registerGameSides, reportResultApi, setupReadyApi, type Side } from '@/apps/_games/onlineApi'; import { useOnlineLobby } from '@/apps/_games/useOnlineLobby'; import { loadLeaderboard, loadStats, recordResultApi, type GameLeaderboard, type GameStats } from '@/apps/_games/statsApi'; @@ -25,7 +25,9 @@ const SB_H = 54; type Screen = 'home' | 'lobby' | 'game' | 'leaderboard'; type Mode = 'cpu' | 'online'; -type Phase = 'placing' | 'firing'; +// 'waiting' is online-only: this player has deployed and the server is holding the +// match until the opponent does too. No shot can be fired or received in it. +type Phase = 'placing' | 'waiting' | 'firing'; const GAME = 'battleship'; const ACCENT = '#17A0B5'; @@ -62,6 +64,7 @@ export function Battleship({ onClose: _onClose }: Props) { const [humanSide, setHumanSide] = useState<'1' | '2'>('1'); const [difficulty, setDifficulty] = useState('medium'); const [thinking, setThinking] = useState(false); + const [oppDeployed, setOppDeployed] = useState(false); const [flash, setFlash] = useState(null); const [ended, setEnded] = useState<{ reason: string } | null>(null); @@ -97,6 +100,22 @@ export function Battleship({ onClose: _onClose }: Props) { const m = mv as BSMove; if (m) handleIncoming(m); }, + onBegin: d => { + // Both fleets are placed; the server says who shoots first. + phaseRef.current = 'firing'; + setPhase('firing'); + setOppDeployed(true); + const mine = d.turn === d.you; + if (pendingShot.current) { + const shot = pendingShot.current; + pendingShot.current = null; + if (resolveIncoming(shot)) { sendFinal(); return; } + setMyTurn(true); + return; + } + setMyTurn(mine); + }, + onOppReady: () => setOppDeployed(true), onEnded: reason => setEnded({ reason }), onReset: () => { clearGame(); @@ -165,7 +184,9 @@ export function Battleship({ onClose: _onClose }: Props) { if (Object.values(next).filter(v => v === 'hit').length >= FLEET_CELLS) { setMyTurn(false); return; } } if (m.shot) { - if (phaseRef.current === 'placing') { pendingShot.current = m.shot; return; } + // The server holds shots until both sides are ready, so this should no longer + // be reachable online - kept as a safety net so a shot can never be dropped. + if (phaseRef.current !== 'firing') { pendingShot.current = m.shot; return; } if (resolveIncoming(m.shot)) { sendFinal(); return; } } setMyTurn(true); @@ -182,6 +203,7 @@ export function Battleship({ onClose: _onClose }: Props) { setEnemyFleet(newMode === 'cpu' ? randomFleet() : null); setShotsAtEnemy({}); setShotsAtMe({}); setPhase('placing'); setMyTurn(false); setThinking(false); setEnded(null); setFlash(null); + setOppDeployed(false); }, []); useEffect(() => { @@ -228,17 +250,20 @@ export function Battleship({ onClose: _onClose }: Props) { } function shuffleFleet() { if (phase === 'placing') setMyFleet(randomFleet()); } function confirmPlacement() { + if (mode === 'online') { + // Do NOT start firing here. Tell the server the fleet is placed and wait for + // its 'begin' push - it arrives only once BOTH boards are deployed, and it + // decides who shoots first. Deciding that locally is what let whoever + // deployed first fire into a board that was still being shuffled. + phaseRef.current = 'waiting'; + setPhase('waiting'); + setMyTurn(false); + if (onlineRef.current) setupReadyApi(onlineRef.current.gameId); + return; + } phaseRef.current = 'firing'; setPhase('firing'); - const first = humanSide === '1'; - if (mode === 'online' && pendingShot.current) { - const lost = resolveIncoming(pendingShot.current); - pendingShot.current = null; - if (lost) { sendFinal(); return; } - setMyTurn(true); - } else { - setMyTurn(first); - } + setMyTurn(humanSide === '1'); } function fire(cell: number) { if (over || phase !== 'firing' || !myTurn || thinking) return; @@ -269,6 +294,8 @@ export function Battleship({ onClose: _onClose }: Props) { enemyRef.current = {}; meRef.current = {}; myLastTarget.current = null; lastResolved.current = null; pendingShot.current = null; setShotsAtEnemy({}); setShotsAtMe({}); setPhase('placing'); setMyTurn(false); setThinking(false); setEnded(null); setFlash(null); + setOppDeployed(false); + phaseRef.current = 'placing'; recorded.current = false; } const banner = (() => { @@ -278,6 +305,11 @@ export function Battleship({ onClose: _onClose }: Props) { return result === 'win' ? win : t('battleship.defeatFleetSunk', 'Defeat. Your fleet was sunk.'); } if (phase === 'placing') return t('battleship.positionFleet', 'Position your fleet'); + if (phase === 'waiting') { + return oppDeployed + ? t('battleship.startingMatch', 'Starting match…') + : t('battleship.waitingForOpponent', 'Waiting for {name} to deploy…', { name: oppName }); + } if (flash) return flash; return myTurn ? t('battleship.yourShot', 'Your shot, fire!') : (thinking ? t('battleship.takingAim', '{name} is taking aim…', { name: oppName }) : t('battleship.oppTurn', "{name}'s turn", { name: oppName })); })();