mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
203 lines
6 KiB
TypeScript
203 lines
6 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
|
||
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
|
||
import { GameSelectors, useAppSelector } from '@app/store';
|
||
|
||
/**
|
||
* MTG turn phase count (0..10). Mirrors desktop's wrap-around behavior in
|
||
* `GameView::actNextPhase` — see `types/game.ts` for the Phase enum.
|
||
*/
|
||
const PHASE_COUNT = 11;
|
||
|
||
export interface TurnControlsOpponent {
|
||
playerId: number;
|
||
name: string;
|
||
}
|
||
|
||
export interface TurnControls {
|
||
isHost: boolean;
|
||
isConceded: boolean;
|
||
invertVerticalCoordinate: boolean;
|
||
settingsReady: boolean;
|
||
canAdvance: boolean;
|
||
canLeave: boolean;
|
||
canConcede: boolean;
|
||
canUnconcede: boolean;
|
||
canRoll: boolean;
|
||
canKick: boolean;
|
||
canRemoveArrows: boolean;
|
||
hasLiveGame: boolean;
|
||
opponents: TurnControlsOpponent[];
|
||
kickAnchor: HTMLElement | null;
|
||
setKickAnchor: (el: HTMLElement | null) => void;
|
||
handlePassTurn: () => void;
|
||
handleReverseTurn: () => void;
|
||
handleNextPhase: () => void;
|
||
handleConcedeToggle: () => void;
|
||
handleRemoveArrows: () => void;
|
||
handleLeave: () => void;
|
||
handleToggleInvert: () => void;
|
||
handleKick: (playerId: number) => void;
|
||
}
|
||
|
||
export interface UseTurnControlsArgs {
|
||
gameId: number | undefined;
|
||
onRequestConcede: () => void;
|
||
onRequestUnconcede: () => void;
|
||
}
|
||
|
||
export function useTurnControls({
|
||
gameId,
|
||
onRequestConcede,
|
||
onRequestUnconcede,
|
||
}: UseTurnControlsArgs): TurnControls {
|
||
const webClient = useWebClient();
|
||
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
|
||
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
|
||
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
|
||
|
||
// Post-kick: the reducer has deleted the game from state but the dialog
|
||
// may still be mounted for a frame while `useGameLifecycle` navigates to
|
||
// /server. Every handler double-checks `game` so a trailing click can't
|
||
// fire a command against a game the server no longer has.
|
||
const hasLiveGame = gameId != null && game != null;
|
||
|
||
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
|
||
|
||
const opponents = useMemo<TurnControlsOpponent[]>(() => {
|
||
if (!game) {
|
||
return [];
|
||
}
|
||
return Object.values(game.players)
|
||
.filter((p) => p.properties.playerId !== game.localPlayerId)
|
||
.map((p) => ({
|
||
playerId: p.properties.playerId,
|
||
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
|
||
}));
|
||
}, [game]);
|
||
|
||
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
|
||
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
|
||
const localArrows = useAppSelector((state) =>
|
||
gameId != null && game != null
|
||
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
|
||
: undefined,
|
||
);
|
||
const localArrowIds = useMemo(
|
||
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
|
||
[localArrows],
|
||
);
|
||
|
||
// Players (judge or not) act as participants; pure spectators don't.
|
||
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
|
||
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
|
||
// getLocalOrJudge gates).
|
||
const isParticipant = gameId != null && game != null && !isSpectator;
|
||
const isConceded = localPlayer?.properties.conceded ?? false;
|
||
const canAdvance =
|
||
gameId != null && game != null && isStarted &&
|
||
(isJudge || game.activePlayerId === game.localPlayerId);
|
||
const canLeave = gameId != null && game != null;
|
||
const canConcede = isParticipant && !isConceded;
|
||
const canUnconcede = isParticipant && isConceded;
|
||
// Rolling dice is a player action; judges may also roll. Pure spectators
|
||
// cannot (desktop exposes it through the player menu, which spectators
|
||
// don't receive).
|
||
const canRoll = gameId != null && (isParticipant || isJudge);
|
||
const canKick = gameId != null && isHost && opponents.length > 0;
|
||
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
|
||
|
||
const handlePassTurn = () => {
|
||
if (!canAdvance || !hasLiveGame) {
|
||
return;
|
||
}
|
||
webClient.request.game.nextTurn(gameId);
|
||
};
|
||
|
||
const handleReverseTurn = () => {
|
||
if (!canAdvance || !hasLiveGame) {
|
||
return;
|
||
}
|
||
webClient.request.game.reverseTurn(gameId);
|
||
};
|
||
|
||
const handleNextPhase = () => {
|
||
if (!canAdvance || !hasLiveGame) {
|
||
return;
|
||
}
|
||
// Desktop wraps at PHASE_COUNT → 0 (the Phase enum is 0–10). When no phase
|
||
// is active yet (activePhase < 0 during the pre-game lobby), advance to
|
||
// Untap (0).
|
||
const current = game.activePhase;
|
||
const next = current >= 0 ? (current + 1) % PHASE_COUNT : 0;
|
||
webClient.request.game.setActivePhase(gameId, { phase: next });
|
||
};
|
||
|
||
const handleConcedeToggle = () => {
|
||
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
|
||
return;
|
||
}
|
||
if (isConceded) {
|
||
onRequestUnconcede();
|
||
} else {
|
||
onRequestConcede();
|
||
}
|
||
};
|
||
|
||
const handleRemoveArrows = () => {
|
||
if (!canRemoveArrows) {
|
||
return;
|
||
}
|
||
for (const arrowId of localArrowIds) {
|
||
webClient.request.game.deleteArrow(gameId, { arrowId });
|
||
}
|
||
};
|
||
|
||
const handleLeave = () => {
|
||
if (!canLeave || !hasLiveGame) {
|
||
return;
|
||
}
|
||
webClient.request.game.leaveGame(gameId);
|
||
};
|
||
|
||
const handleToggleInvert = () => {
|
||
if (settingsStatus !== LoadingState.READY) {
|
||
return;
|
||
}
|
||
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
|
||
};
|
||
|
||
const handleKick = (playerId: number) => {
|
||
if (!hasLiveGame) {
|
||
return;
|
||
}
|
||
webClient.request.game.kickFromGame(gameId, { playerId });
|
||
setKickAnchor(null);
|
||
};
|
||
|
||
return {
|
||
isHost,
|
||
isConceded,
|
||
invertVerticalCoordinate,
|
||
settingsReady: settingsStatus === LoadingState.READY,
|
||
canAdvance,
|
||
canLeave,
|
||
canConcede,
|
||
canUnconcede,
|
||
canRoll,
|
||
canKick,
|
||
canRemoveArrows,
|
||
hasLiveGame,
|
||
opponents,
|
||
kickAnchor,
|
||
setKickAnchor,
|
||
handlePassTurn,
|
||
handleReverseTurn,
|
||
handleNextPhase,
|
||
handleConcedeToggle,
|
||
handleRemoveArrows,
|
||
handleLeave,
|
||
handleToggleInvert,
|
||
handleKick,
|
||
};
|
||
}
|