Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/apps/games.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
---@type string[] NUI action suffixes proxied 1:1 to sd-phone:server:games:<action>.
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',
Expand Down
6 changes: 4 additions & 2 deletions server/battleship/init.lua
Original file line number Diff line number Diff line change
@@ -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 })
43 changes: 43 additions & 0 deletions server/games/engine.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions web/src/apps/_games/onlineApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<game>: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 });
Expand Down
8 changes: 8 additions & 0 deletions web/src/apps/_games/useOnlineLobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface UseOnlineLobbyOptions {
onStart: (d: OnlineStartData) => void;
/** An opponent move arrived (the `move` payload of the `<game>: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. */
Expand Down Expand Up @@ -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<unknown>(`${game}:oppReady`, () => { opts.current.onOppReady?.(); });
useGameEvent<{ reason?: string }>(`${game}:ended`, d => {
opts.current.onEnded(d?.reason || 'left');
});
Expand Down
56 changes: 44 additions & 12 deletions web/src/apps/battleship/Battleship.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -62,6 +64,7 @@ export function Battleship({ onClose: _onClose }: Props) {
const [humanSide, setHumanSide] = useState<'1' | '2'>('1');
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
const [thinking, setThinking] = useState(false);
const [oppDeployed, setOppDeployed] = useState(false);
const [flash, setFlash] = useState<string | null>(null);

const [ended, setEnded] = useState<{ reason: string } | null>(null);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 = (() => {
Expand All @@ -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 }));
})();
Expand Down
Loading