From 74803442d2fa9b1ae43f302629154ed8751b6552 Mon Sep 17 00:00:00 2001 From: seavor Date: Sun, 12 Apr 2026 05:05:16 -0500 Subject: [PATCH] Implement game layer from protobuf to redux --- webclient/src/store/game/game.actions.ts | 234 ++++++ webclient/src/store/game/game.dispatch.ts | 155 ++++ webclient/src/store/game/game.interfaces.ts | 59 ++ webclient/src/store/game/game.reducer.ts | 729 ++++++++++++++++++ webclient/src/store/game/game.selectors.ts | 72 ++ webclient/src/store/game/game.types.ts | 34 + webclient/src/store/game/index.ts | 6 + webclient/src/store/index.ts | 9 +- webclient/src/store/rootReducer.ts | 2 + webclient/src/types/game.ts | 458 +++++++++++ .../src/websocket/commands/game/attachCard.ts | 6 + .../commands/game/changeZoneProperties.ts | 6 + .../src/websocket/commands/game/concede.ts | 5 + .../websocket/commands/game/createArrow.ts | 6 + .../websocket/commands/game/createCounter.ts | 6 + .../websocket/commands/game/createToken.ts | 6 + .../src/websocket/commands/game/deckSelect.ts | 6 + .../src/websocket/commands/game/delCounter.ts | 6 + .../websocket/commands/game/deleteArrow.ts | 6 + .../src/websocket/commands/game/drawCards.ts | 6 + .../src/websocket/commands/game/dumpZone.ts | 6 + .../src/websocket/commands/game/flipCard.ts | 6 + .../src/websocket/commands/game/gameSay.ts | 6 + .../websocket/commands/game/incCardCounter.ts | 6 + .../src/websocket/commands/game/incCounter.ts | 6 + .../src/websocket/commands/game/index.ts | 31 + .../websocket/commands/game/kickFromGame.ts | 6 + .../src/websocket/commands/game/leaveGame.ts | 5 + .../src/websocket/commands/game/moveCard.ts | 6 + .../src/websocket/commands/game/mulligan.ts | 6 + .../src/websocket/commands/game/nextTurn.ts | 5 + .../src/websocket/commands/game/readyStart.ts | 6 + .../websocket/commands/game/revealCards.ts | 6 + .../websocket/commands/game/reverseTurn.ts | 5 + .../websocket/commands/game/setActivePhase.ts | 6 + .../websocket/commands/game/setCardAttr.ts | 6 + .../websocket/commands/game/setCardCounter.ts | 6 + .../src/websocket/commands/game/setCounter.ts | 6 + .../commands/game/setSideboardLock.ts | 6 + .../commands/game/setSideboardPlan.ts | 6 + .../src/websocket/commands/game/shuffle.ts | 6 + .../src/websocket/commands/game/undoDraw.ts | 5 + webclient/src/websocket/commands/index.ts | 1 + .../src/websocket/events/common/index.ts | 5 +- .../events/common/playerPropertiesChanged.ts | 9 +- .../src/websocket/events/game/attachCard.ts | 6 + .../events/game/changeZoneProperties.ts | 6 + .../src/websocket/events/game/createArrow.ts | 6 + .../websocket/events/game/createCounter.ts | 6 + .../src/websocket/events/game/createToken.ts | 6 + .../src/websocket/events/game/delCounter.ts | 6 + .../src/websocket/events/game/deleteArrow.ts | 6 + .../src/websocket/events/game/destroyCard.ts | 6 + .../src/websocket/events/game/drawCards.ts | 6 + .../src/websocket/events/game/dumpZone.ts | 6 + .../src/websocket/events/game/flipCard.ts | 6 + .../src/websocket/events/game/gameClosed.ts | 6 + .../websocket/events/game/gameEvents.spec.ts | 28 +- .../websocket/events/game/gameHostChanged.ts | 10 + .../src/websocket/events/game/gameSay.ts | 6 + .../websocket/events/game/gameStateChanged.ts | 6 + webclient/src/websocket/events/game/index.ts | 82 +- .../src/websocket/events/game/joinGame.ts | 6 +- webclient/src/websocket/events/game/kicked.ts | 6 + .../src/websocket/events/game/leaveGame.ts | 7 +- .../src/websocket/events/game/moveCard.ts | 6 + .../events/game/playerPropertiesChanged.ts | 6 + .../src/websocket/events/game/revealCards.ts | 6 + .../src/websocket/events/game/reverseTurn.ts | 6 + .../src/websocket/events/game/rollDie.ts | 6 + .../websocket/events/game/setActivePhase.ts | 6 + .../websocket/events/game/setActivePlayer.ts | 6 + .../src/websocket/events/game/setCardAttr.ts | 6 + .../websocket/events/game/setCardCounter.ts | 6 + .../src/websocket/events/game/setCounter.ts | 6 + .../src/websocket/events/game/shuffle.ts | 6 + .../persistence/GamePersistence.spec.ts | 27 +- .../websocket/persistence/GamePersistence.ts | 142 +++- .../persistence/SessionPersistence.spec.ts | 14 +- .../persistence/SessionPersistence.ts | 26 +- .../src/websocket/services/BackendService.ts | 10 + .../src/websocket/services/ProtobufService.ts | 50 +- 82 files changed, 2455 insertions(+), 88 deletions(-) create mode 100644 webclient/src/store/game/game.actions.ts create mode 100644 webclient/src/store/game/game.dispatch.ts create mode 100644 webclient/src/store/game/game.interfaces.ts create mode 100644 webclient/src/store/game/game.reducer.ts create mode 100644 webclient/src/store/game/game.selectors.ts create mode 100644 webclient/src/store/game/game.types.ts create mode 100644 webclient/src/store/game/index.ts create mode 100644 webclient/src/websocket/commands/game/attachCard.ts create mode 100644 webclient/src/websocket/commands/game/changeZoneProperties.ts create mode 100644 webclient/src/websocket/commands/game/concede.ts create mode 100644 webclient/src/websocket/commands/game/createArrow.ts create mode 100644 webclient/src/websocket/commands/game/createCounter.ts create mode 100644 webclient/src/websocket/commands/game/createToken.ts create mode 100644 webclient/src/websocket/commands/game/deckSelect.ts create mode 100644 webclient/src/websocket/commands/game/delCounter.ts create mode 100644 webclient/src/websocket/commands/game/deleteArrow.ts create mode 100644 webclient/src/websocket/commands/game/drawCards.ts create mode 100644 webclient/src/websocket/commands/game/dumpZone.ts create mode 100644 webclient/src/websocket/commands/game/flipCard.ts create mode 100644 webclient/src/websocket/commands/game/gameSay.ts create mode 100644 webclient/src/websocket/commands/game/incCardCounter.ts create mode 100644 webclient/src/websocket/commands/game/incCounter.ts create mode 100644 webclient/src/websocket/commands/game/index.ts create mode 100644 webclient/src/websocket/commands/game/kickFromGame.ts create mode 100644 webclient/src/websocket/commands/game/leaveGame.ts create mode 100644 webclient/src/websocket/commands/game/moveCard.ts create mode 100644 webclient/src/websocket/commands/game/mulligan.ts create mode 100644 webclient/src/websocket/commands/game/nextTurn.ts create mode 100644 webclient/src/websocket/commands/game/readyStart.ts create mode 100644 webclient/src/websocket/commands/game/revealCards.ts create mode 100644 webclient/src/websocket/commands/game/reverseTurn.ts create mode 100644 webclient/src/websocket/commands/game/setActivePhase.ts create mode 100644 webclient/src/websocket/commands/game/setCardAttr.ts create mode 100644 webclient/src/websocket/commands/game/setCardCounter.ts create mode 100644 webclient/src/websocket/commands/game/setCounter.ts create mode 100644 webclient/src/websocket/commands/game/setSideboardLock.ts create mode 100644 webclient/src/websocket/commands/game/setSideboardPlan.ts create mode 100644 webclient/src/websocket/commands/game/shuffle.ts create mode 100644 webclient/src/websocket/commands/game/undoDraw.ts create mode 100644 webclient/src/websocket/events/game/attachCard.ts create mode 100644 webclient/src/websocket/events/game/changeZoneProperties.ts create mode 100644 webclient/src/websocket/events/game/createArrow.ts create mode 100644 webclient/src/websocket/events/game/createCounter.ts create mode 100644 webclient/src/websocket/events/game/createToken.ts create mode 100644 webclient/src/websocket/events/game/delCounter.ts create mode 100644 webclient/src/websocket/events/game/deleteArrow.ts create mode 100644 webclient/src/websocket/events/game/destroyCard.ts create mode 100644 webclient/src/websocket/events/game/drawCards.ts create mode 100644 webclient/src/websocket/events/game/dumpZone.ts create mode 100644 webclient/src/websocket/events/game/flipCard.ts create mode 100644 webclient/src/websocket/events/game/gameClosed.ts create mode 100644 webclient/src/websocket/events/game/gameHostChanged.ts create mode 100644 webclient/src/websocket/events/game/gameSay.ts create mode 100644 webclient/src/websocket/events/game/gameStateChanged.ts create mode 100644 webclient/src/websocket/events/game/kicked.ts create mode 100644 webclient/src/websocket/events/game/moveCard.ts create mode 100644 webclient/src/websocket/events/game/playerPropertiesChanged.ts create mode 100644 webclient/src/websocket/events/game/revealCards.ts create mode 100644 webclient/src/websocket/events/game/reverseTurn.ts create mode 100644 webclient/src/websocket/events/game/rollDie.ts create mode 100644 webclient/src/websocket/events/game/setActivePhase.ts create mode 100644 webclient/src/websocket/events/game/setActivePlayer.ts create mode 100644 webclient/src/websocket/events/game/setCardAttr.ts create mode 100644 webclient/src/websocket/events/game/setCardCounter.ts create mode 100644 webclient/src/websocket/events/game/setCounter.ts create mode 100644 webclient/src/websocket/events/game/shuffle.ts diff --git a/webclient/src/store/game/game.actions.ts b/webclient/src/store/game/game.actions.ts new file mode 100644 index 000000000..3ae9950a6 --- /dev/null +++ b/webclient/src/store/game/game.actions.ts @@ -0,0 +1,234 @@ +import { + AttachCardData, + ChangeZonePropertiesData, + CreateArrowData, + CreateCounterData, + CreateTokenData, + DelCounterData, + DeleteArrowData, + DestroyCardData, + DrawCardsData, + DumpZoneData, + FlipCardData, + GameStateChangedData, + MoveCardData, + PlayerProperties, + RevealCardsData, + RollDieData, + SetCardAttrData, + SetCardCounterData, + SetCounterData, + ShuffleData, +} from 'types'; +import { GameEntry } from './game.interfaces'; +import { Types } from './game.types'; + +export const Actions = { + clearStore: () => ({ + type: Types.CLEAR_STORE, + }), + + gameJoined: (gameId: number, gameEntry: GameEntry) => ({ + type: Types.GAME_JOINED, + gameId, + gameEntry, + }), + + gameLeft: (gameId: number) => ({ + type: Types.GAME_LEFT, + gameId, + }), + + gameClosed: (gameId: number) => ({ + type: Types.GAME_CLOSED, + gameId, + }), + + gameHostChanged: (gameId: number, hostId: number) => ({ + type: Types.GAME_HOST_CHANGED, + gameId, + hostId, + }), + + gameStateChanged: (gameId: number, data: GameStateChangedData) => ({ + type: Types.GAME_STATE_CHANGED, + gameId, + data, + }), + + playerJoined: (gameId: number, playerProperties: PlayerProperties) => ({ + type: Types.PLAYER_JOINED, + gameId, + playerProperties, + }), + + playerLeft: (gameId: number, playerId: number, reason: number) => ({ + type: Types.PLAYER_LEFT, + gameId, + playerId, + reason, + }), + + playerPropertiesChanged: (gameId: number, playerId: number, properties: PlayerProperties) => ({ + type: Types.PLAYER_PROPERTIES_CHANGED, + gameId, + playerId, + properties, + }), + + kicked: (gameId: number) => ({ + type: Types.KICKED, + gameId, + }), + + cardMoved: (gameId: number, playerId: number, data: MoveCardData) => ({ + type: Types.CARD_MOVED, + gameId, + playerId, + data, + }), + + cardFlipped: (gameId: number, playerId: number, data: FlipCardData) => ({ + type: Types.CARD_FLIPPED, + gameId, + playerId, + data, + }), + + cardDestroyed: (gameId: number, playerId: number, data: DestroyCardData) => ({ + type: Types.CARD_DESTROYED, + gameId, + playerId, + data, + }), + + cardAttached: (gameId: number, playerId: number, data: AttachCardData) => ({ + type: Types.CARD_ATTACHED, + gameId, + playerId, + data, + }), + + tokenCreated: (gameId: number, playerId: number, data: CreateTokenData) => ({ + type: Types.TOKEN_CREATED, + gameId, + playerId, + data, + }), + + cardAttrChanged: (gameId: number, playerId: number, data: SetCardAttrData) => ({ + type: Types.CARD_ATTR_CHANGED, + gameId, + playerId, + data, + }), + + cardCounterChanged: (gameId: number, playerId: number, data: SetCardCounterData) => ({ + type: Types.CARD_COUNTER_CHANGED, + gameId, + playerId, + data, + }), + + arrowCreated: (gameId: number, playerId: number, data: CreateArrowData) => ({ + type: Types.ARROW_CREATED, + gameId, + playerId, + data, + }), + + arrowDeleted: (gameId: number, playerId: number, data: DeleteArrowData) => ({ + type: Types.ARROW_DELETED, + gameId, + playerId, + data, + }), + + counterCreated: (gameId: number, playerId: number, data: CreateCounterData) => ({ + type: Types.COUNTER_CREATED, + gameId, + playerId, + data, + }), + + counterSet: (gameId: number, playerId: number, data: SetCounterData) => ({ + type: Types.COUNTER_SET, + gameId, + playerId, + data, + }), + + counterDeleted: (gameId: number, playerId: number, data: DelCounterData) => ({ + type: Types.COUNTER_DELETED, + gameId, + playerId, + data, + }), + + cardsDrawn: (gameId: number, playerId: number, data: DrawCardsData) => ({ + type: Types.CARDS_DRAWN, + gameId, + playerId, + data, + }), + + cardsRevealed: (gameId: number, playerId: number, data: RevealCardsData) => ({ + type: Types.CARDS_REVEALED, + gameId, + playerId, + data, + }), + + zoneShuffled: (gameId: number, playerId: number, data: ShuffleData) => ({ + type: Types.ZONE_SHUFFLED, + gameId, + playerId, + data, + }), + + dieRolled: (gameId: number, playerId: number, data: RollDieData) => ({ + type: Types.DIE_ROLLED, + gameId, + playerId, + data, + }), + + activePlayerSet: (gameId: number, activePlayerId: number) => ({ + type: Types.ACTIVE_PLAYER_SET, + gameId, + activePlayerId, + }), + + activePhaseSet: (gameId: number, phase: number) => ({ + type: Types.ACTIVE_PHASE_SET, + gameId, + phase, + }), + + turnReversed: (gameId: number, reversed: boolean) => ({ + type: Types.TURN_REVERSED, + gameId, + reversed, + }), + + zoneDumped: (gameId: number, playerId: number, data: DumpZoneData) => ({ + type: Types.ZONE_DUMPED, + gameId, + playerId, + data, + }), + + zonePropertiesChanged: (gameId: number, playerId: number, data: ChangeZonePropertiesData) => ({ + type: Types.ZONE_PROPERTIES_CHANGED, + gameId, + playerId, + data, + }), + + gameSay: (gameId: number, playerId: number, message: string) => ({ + type: Types.GAME_SAY, + gameId, + playerId, + message, + }), +}; diff --git a/webclient/src/store/game/game.dispatch.ts b/webclient/src/store/game/game.dispatch.ts new file mode 100644 index 000000000..f56f6c5d7 --- /dev/null +++ b/webclient/src/store/game/game.dispatch.ts @@ -0,0 +1,155 @@ +import { + AttachCardData, + ChangeZonePropertiesData, + CreateArrowData, + CreateCounterData, + CreateTokenData, + DelCounterData, + DeleteArrowData, + DestroyCardData, + DrawCardsData, + DumpZoneData, + FlipCardData, + GameStateChangedData, + MoveCardData, + PlayerProperties, + RevealCardsData, + RollDieData, + SetCardAttrData, + SetCardCounterData, + SetCounterData, + ShuffleData, +} from 'types'; +import { store } from 'store/store'; +import { Actions } from './game.actions'; +import { GameEntry } from './game.interfaces'; + +export const Dispatch = { + clearStore: () => { + store.dispatch(Actions.clearStore()); + }, + + gameJoined: (gameId: number, gameEntry: GameEntry) => { + store.dispatch(Actions.gameJoined(gameId, gameEntry)); + }, + + gameLeft: (gameId: number) => { + store.dispatch(Actions.gameLeft(gameId)); + }, + + gameClosed: (gameId: number) => { + store.dispatch(Actions.gameClosed(gameId)); + }, + + gameHostChanged: (gameId: number, hostId: number) => { + store.dispatch(Actions.gameHostChanged(gameId, hostId)); + }, + + gameStateChanged: (gameId: number, data: GameStateChangedData) => { + store.dispatch(Actions.gameStateChanged(gameId, data)); + }, + + playerJoined: (gameId: number, playerProperties: PlayerProperties) => { + store.dispatch(Actions.playerJoined(gameId, playerProperties)); + }, + + playerLeft: (gameId: number, playerId: number, reason: number) => { + store.dispatch(Actions.playerLeft(gameId, playerId, reason)); + }, + + playerPropertiesChanged: (gameId: number, playerId: number, properties: PlayerProperties) => { + store.dispatch(Actions.playerPropertiesChanged(gameId, playerId, properties)); + }, + + kicked: (gameId: number) => { + store.dispatch(Actions.kicked(gameId)); + }, + + cardMoved: (gameId: number, playerId: number, data: MoveCardData) => { + store.dispatch(Actions.cardMoved(gameId, playerId, data)); + }, + + cardFlipped: (gameId: number, playerId: number, data: FlipCardData) => { + store.dispatch(Actions.cardFlipped(gameId, playerId, data)); + }, + + cardDestroyed: (gameId: number, playerId: number, data: DestroyCardData) => { + store.dispatch(Actions.cardDestroyed(gameId, playerId, data)); + }, + + cardAttached: (gameId: number, playerId: number, data: AttachCardData) => { + store.dispatch(Actions.cardAttached(gameId, playerId, data)); + }, + + tokenCreated: (gameId: number, playerId: number, data: CreateTokenData) => { + store.dispatch(Actions.tokenCreated(gameId, playerId, data)); + }, + + cardAttrChanged: (gameId: number, playerId: number, data: SetCardAttrData) => { + store.dispatch(Actions.cardAttrChanged(gameId, playerId, data)); + }, + + cardCounterChanged: (gameId: number, playerId: number, data: SetCardCounterData) => { + store.dispatch(Actions.cardCounterChanged(gameId, playerId, data)); + }, + + arrowCreated: (gameId: number, playerId: number, data: CreateArrowData) => { + store.dispatch(Actions.arrowCreated(gameId, playerId, data)); + }, + + arrowDeleted: (gameId: number, playerId: number, data: DeleteArrowData) => { + store.dispatch(Actions.arrowDeleted(gameId, playerId, data)); + }, + + counterCreated: (gameId: number, playerId: number, data: CreateCounterData) => { + store.dispatch(Actions.counterCreated(gameId, playerId, data)); + }, + + counterSet: (gameId: number, playerId: number, data: SetCounterData) => { + store.dispatch(Actions.counterSet(gameId, playerId, data)); + }, + + counterDeleted: (gameId: number, playerId: number, data: DelCounterData) => { + store.dispatch(Actions.counterDeleted(gameId, playerId, data)); + }, + + cardsDrawn: (gameId: number, playerId: number, data: DrawCardsData) => { + store.dispatch(Actions.cardsDrawn(gameId, playerId, data)); + }, + + cardsRevealed: (gameId: number, playerId: number, data: RevealCardsData) => { + store.dispatch(Actions.cardsRevealed(gameId, playerId, data)); + }, + + zoneShuffled: (gameId: number, playerId: number, data: ShuffleData) => { + store.dispatch(Actions.zoneShuffled(gameId, playerId, data)); + }, + + dieRolled: (gameId: number, playerId: number, data: RollDieData) => { + store.dispatch(Actions.dieRolled(gameId, playerId, data)); + }, + + activePlayerSet: (gameId: number, activePlayerId: number) => { + store.dispatch(Actions.activePlayerSet(gameId, activePlayerId)); + }, + + activePhaseSet: (gameId: number, phase: number) => { + store.dispatch(Actions.activePhaseSet(gameId, phase)); + }, + + turnReversed: (gameId: number, reversed: boolean) => { + store.dispatch(Actions.turnReversed(gameId, reversed)); + }, + + zoneDumped: (gameId: number, playerId: number, data: DumpZoneData) => { + store.dispatch(Actions.zoneDumped(gameId, playerId, data)); + }, + + zonePropertiesChanged: (gameId: number, playerId: number, data: ChangeZonePropertiesData) => { + store.dispatch(Actions.zonePropertiesChanged(gameId, playerId, data)); + }, + + gameSay: (gameId: number, playerId: number, message: string) => { + store.dispatch(Actions.gameSay(gameId, playerId, message)); + }, +}; diff --git a/webclient/src/store/game/game.interfaces.ts b/webclient/src/store/game/game.interfaces.ts new file mode 100644 index 000000000..2e5e28325 --- /dev/null +++ b/webclient/src/store/game/game.interfaces.ts @@ -0,0 +1,59 @@ +import { ArrowInfo, CardInfo, CounterInfo, PlayerProperties } from 'types'; + +export interface GamesState { + games: { [gameId: number]: GameEntry }; +} + +/** + * Full runtime state for a single active game (played or spectated). + * Keyed by gameId in GamesState so multiple concurrent games are supported. + */ +export interface GameEntry { + gameId: number; + roomId: number; + description: string; + hostId: number; + /** The playerId assigned to the local user in this game. */ + localPlayerId: number; + spectator: boolean; + judge: boolean; + started: boolean; + activePlayerId: number; + activePhase: number; + secondsElapsed: number; + reversed: boolean; + players: { [playerId: number]: PlayerEntry }; + messages: GameMessage[]; +} + +/** Normalized from ServerInfo_Player — keyed collections for O(1) lookup. */ +export interface PlayerEntry { + properties: PlayerProperties; + deckList: string; + /** Zones keyed by zone name (e.g. "hand", "deck", "table"). */ + zones: { [zoneName: string]: ZoneEntry }; + /** Player-level counters (e.g. life) keyed by counter id. */ + counters: { [counterId: number]: CounterInfo }; + /** Arrows keyed by arrow id. */ + arrows: { [arrowId: number]: ArrowInfo }; +} + +/** Normalized from ServerInfo_Zone — card list is an ordered array matching proto. */ +export interface ZoneEntry { + name: string; + /** ZoneType enum value (0=Private, 1=Public, 2=Hidden). */ + type: number; + withCoords: boolean; + /** Authoritative card count (used for hidden zones where cardList may be empty). */ + cardCount: number; + /** Ordered card list; may be empty for hidden zones with no dump active. */ + cards: CardInfo[]; + alwaysRevealTopCard: boolean; + alwaysLookAtTopCard: boolean; +} + +export interface GameMessage { + playerId: number; + message: string; + timeReceived: number; +} diff --git a/webclient/src/store/game/game.reducer.ts b/webclient/src/store/game/game.reducer.ts new file mode 100644 index 000000000..b6148fb4f --- /dev/null +++ b/webclient/src/store/game/game.reducer.ts @@ -0,0 +1,729 @@ +import { + ArrowInfo, + CardAttribute, + CardCounterInfo, + CardInfo, + CounterInfo, + PlayerInfo, + PlayerProperties, +} from 'types'; +import { GameEntry, GameMessage, GamesState, PlayerEntry, ZoneEntry } from './game.interfaces'; +import { Types } from './game.types'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function updateGame(state: GamesState, gameId: number, updates: Partial): GamesState { + const game = state.games[gameId]; + if (!game) { + return state; + } + return { + ...state, + games: { ...state.games, [gameId]: { ...game, ...updates } }, + }; +} + +function updatePlayer( + state: GamesState, + gameId: number, + playerId: number, + updates: Partial +): GamesState { + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + return updateGame(state, gameId, { + players: { ...game.players, [playerId]: { ...player, ...updates } }, + }); +} + +function updateZone( + state: GamesState, + gameId: number, + playerId: number, + zoneName: string, + updates: Partial +): GamesState { + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + return updatePlayer(state, gameId, playerId, { + zones: { ...player.zones, [zoneName]: { ...zone, ...updates } }, + }); +} + +function removeGame(state: GamesState, gameId: number): GamesState { + const games = { ...state.games }; + delete games[gameId]; + return { ...state, games }; +} + +/** Converts the proto PlayerInfo[] array into the keyed PlayerEntry map used in the store. */ +function normalizePlayers(playerList: PlayerInfo[]): { [playerId: number]: PlayerEntry } { + const players: { [playerId: number]: PlayerEntry } = {}; + for (const player of playerList) { + const playerId = player.properties.playerId; + + const zones: { [zoneName: string]: ZoneEntry } = {}; + for (const zone of player.zoneList) { + zones[zone.name] = { + name: zone.name, + type: zone.type, + withCoords: zone.withCoords, + cardCount: zone.cardCount, + cards: [...zone.cardList], + alwaysRevealTopCard: zone.alwaysRevealTopCard, + alwaysLookAtTopCard: zone.alwaysLookAtTopCard, + }; + } + + const counters: { [counterId: number]: CounterInfo } = {}; + for (const counter of player.counterList) { + counters[counter.id] = counter; + } + + const arrows: { [arrowId: number]: ArrowInfo } = {}; + for (const arrow of player.arrowList) { + arrows[arrow.id] = arrow; + } + + players[playerId] = { + properties: player.properties, + deckList: player.deckList, + zones, + counters, + arrows, + }; + } + return players; +} + +function buildEmptyCard( + id: number, + name: string, + x: number, + y: number, + faceDown: boolean, + providerId: string +): CardInfo { + return { + id, + name, + x, + y, + faceDown, + tapped: false, + attacking: false, + color: '', + pt: '', + annotation: '', + destroyOnZoneChange: false, + doesntUntap: false, + counterList: [], + attachPlayerId: -1, + attachZone: '', + attachCardId: -1, + providerId, + }; +} + +// ── Initial state ───────────────────────────────────────────────────────────── + +const initialState: GamesState = { + games: {}, +}; + +// ── Reducer ─────────────────────────────────────────────────────────────────── + +export const gamesReducer = (state: GamesState = initialState, action: any): GamesState => { + switch (action.type) { + case Types.CLEAR_STORE: { + return initialState; + } + + case Types.GAME_JOINED: { + return { + ...state, + games: { ...state.games, [action.gameId]: action.gameEntry }, + }; + } + + case Types.GAME_LEFT: + case Types.GAME_CLOSED: + case Types.KICKED: { + return removeGame(state, action.gameId); + } + + case Types.GAME_HOST_CHANGED: { + return updateGame(state, action.gameId, { hostId: action.hostId }); + } + + case Types.GAME_STATE_CHANGED: { + const { gameId, data } = action; + const game = state.games[gameId]; + if (!game) { + return state; + } + + const updates: Partial = {}; + if (data.playerList?.length > 0) { + updates.players = normalizePlayers(data.playerList); + } + if (data.gameStarted !== undefined && data.gameStarted !== null) { + updates.started = data.gameStarted; + } + if (data.activePlayerId !== undefined && data.activePlayerId !== null) { + updates.activePlayerId = data.activePlayerId; + } + if (data.activePhase !== undefined && data.activePhase !== null) { + updates.activePhase = data.activePhase; + } + if (data.secondsElapsed !== undefined) { + updates.secondsElapsed = data.secondsElapsed; + } + return updateGame(state, gameId, updates); + } + + case Types.PLAYER_JOINED: { + const { gameId, playerProperties } = action; + const game = state.games[gameId]; + if (!game) { + return state; + } + const newPlayer: PlayerEntry = { + properties: playerProperties as PlayerProperties, + deckList: '', + zones: {}, + counters: {}, + arrows: {}, + }; + return updateGame(state, gameId, { + players: { ...game.players, [playerProperties.playerId]: newPlayer }, + }); + } + + case Types.PLAYER_LEFT: { + const { gameId, playerId } = action; + const game = state.games[gameId]; + if (!game) { + return state; + } + const players = { ...game.players }; + delete players[playerId]; + return updateGame(state, gameId, { players }); + } + + case Types.PLAYER_PROPERTIES_CHANGED: { + return updatePlayer(state, action.gameId, action.playerId, { + properties: action.properties, + }); + } + + // ── Card manipulation ──────────────────────────────────────────────────── + + case Types.CARD_MOVED: { + const { gameId, playerId, data } = action; + const { + cardId, + cardName, + startPlayerId, + startZone, + position, + targetPlayerId, + targetZone, + x, + y, + newCardId, + faceDown, + newCardProviderId, + } = data; + + const game = state.games[gameId]; + if (!game) { + return state; + } + + const effectiveStartPlayerId = startPlayerId >= 0 ? startPlayerId : playerId; + const sourcePlayer = game.players[effectiveStartPlayerId]; + const sourceZoneEntry = sourcePlayer?.zones[startZone]; + if (!sourcePlayer || !sourceZoneEntry) { + return state; + } + + // Locate card in source zone (by id for visible zones, by position for hidden) + let removedCard: CardInfo | undefined; + let newSourceCards: CardInfo[]; + if (cardId >= 0) { + removedCard = sourceZoneEntry.cards.find(c => c.id === cardId); + newSourceCards = sourceZoneEntry.cards.filter(c => c.id !== cardId); + } else if (position >= 0 && position < sourceZoneEntry.cards.length) { + removedCard = sourceZoneEntry.cards[position]; + newSourceCards = sourceZoneEntry.cards.filter((_, i) => i !== position); + } else { + // Hidden zone with unknown position — just decrement count + newSourceCards = sourceZoneEntry.cards; + } + + const effectiveNewId = newCardId >= 0 ? newCardId : (removedCard?.id ?? -1); + const movedCard: CardInfo = removedCard + ? { + ...removedCard, + id: effectiveNewId, + name: cardName || removedCard.name, + x, + y, + faceDown, + providerId: newCardProviderId || removedCard.providerId, + } + : buildEmptyCard(effectiveNewId, cardName, x, y, faceDown, newCardProviderId ?? ''); + + let newState = updateZone(state, gameId, effectiveStartPlayerId, startZone, { + cards: newSourceCards, + cardCount: Math.max(0, sourceZoneEntry.cardCount - 1), + }); + + const updatedGame = newState.games[gameId]; + const targetPlayer = updatedGame?.players[targetPlayerId]; + const targetZoneEntry = targetPlayer?.zones[targetZone]; + if (!targetPlayer || !targetZoneEntry) { + return newState; + } + + newState = updateZone(newState, gameId, targetPlayerId, targetZone, { + cards: [...targetZoneEntry.cards, movedCard], + cardCount: targetZoneEntry.cardCount + 1, + }); + return newState; + } + + case Types.CARD_FLIPPED: { + const { gameId, playerId, data } = action; + const { zoneName, cardId, cardName, faceDown, cardProviderId } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + const cardIdx = zone.cards.findIndex(c => c.id === cardId); + if (cardIdx < 0) { + return state; + } + + const updatedCards = [...zone.cards]; + updatedCards[cardIdx] = { + ...updatedCards[cardIdx], + faceDown, + name: cardName || updatedCards[cardIdx].name, + providerId: cardProviderId || updatedCards[cardIdx].providerId, + }; + return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards }); + } + + case Types.CARD_DESTROYED: { + const { gameId, playerId, data } = action; + const { zoneName, cardId } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + return updateZone(state, gameId, playerId, zoneName, { + cards: zone.cards.filter(c => c.id !== cardId), + cardCount: Math.max(0, zone.cardCount - 1), + }); + } + + case Types.CARD_ATTACHED: { + const { gameId, playerId, data } = action; + const { startZone, cardId, targetPlayerId, targetZone, targetCardId } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[startZone]; + if (!zone) { + return state; + } + + const cardIdx = zone.cards.findIndex(c => c.id === cardId); + if (cardIdx < 0) { + return state; + } + + const updatedCards = [...zone.cards]; + updatedCards[cardIdx] = { + ...updatedCards[cardIdx], + attachPlayerId: targetPlayerId, + attachZone: targetZone, + attachCardId: targetCardId, + }; + return updateZone(state, gameId, playerId, startZone, { cards: updatedCards }); + } + + case Types.TOKEN_CREATED: { + const { gameId, playerId, data } = action; + const { + zoneName, + cardId, + cardName, + color, + pt, + annotation, + destroyOnZoneChange, + x, + y, + cardProviderId, + faceDown, + } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + const newCard: CardInfo = { + id: cardId, + name: cardName, + x, + y, + faceDown, + tapped: false, + attacking: false, + color, + pt, + annotation, + destroyOnZoneChange, + doesntUntap: false, + counterList: [], + attachPlayerId: -1, + attachZone: '', + attachCardId: -1, + providerId: cardProviderId, + }; + return updateZone(state, gameId, playerId, zoneName, { + cards: [...zone.cards, newCard], + cardCount: zone.cardCount + 1, + }); + } + + case Types.CARD_ATTR_CHANGED: { + const { gameId, playerId, data } = action; + const { zoneName, cardId, attribute, attrValue } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + const cardIdx = zone.cards.findIndex(c => c.id === cardId); + if (cardIdx < 0) { + return state; + } + + const attrPatch: Partial = {}; + switch (attribute as CardAttribute) { + case CardAttribute.AttrTapped: attrPatch.tapped = attrValue === '1'; break; + case CardAttribute.AttrAttacking: attrPatch.attacking = attrValue === '1'; break; + case CardAttribute.AttrFaceDown: attrPatch.faceDown = attrValue === '1'; break; + case CardAttribute.AttrColor: attrPatch.color = attrValue; break; + case CardAttribute.AttrPT: attrPatch.pt = attrValue; break; + case CardAttribute.AttrAnnotation: attrPatch.annotation = attrValue; break; + case CardAttribute.AttrDoesntUntap: attrPatch.doesntUntap = attrValue === '1'; break; + } + + const updatedCards = [...zone.cards]; + updatedCards[cardIdx] = { ...updatedCards[cardIdx], ...attrPatch }; + return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards }); + } + + case Types.CARD_COUNTER_CHANGED: { + const { gameId, playerId, data } = action; + const { zoneName, cardId, counterId, counterValue } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + const cardIdx = zone.cards.findIndex(c => c.id === cardId); + if (cardIdx < 0) { + return state; + } + + const card = zone.cards[cardIdx]; + let newCounterList: CardCounterInfo[]; + if (counterValue <= 0) { + newCounterList = card.counterList.filter(c => c.id !== counterId); + } else { + const existing = card.counterList.findIndex(c => c.id === counterId); + newCounterList = + existing >= 0 + ? card.counterList.map(c => (c.id === counterId ? { ...c, value: counterValue } : c)) + : [...card.counterList, { id: counterId, value: counterValue }]; + } + + const updatedCards = [...zone.cards]; + updatedCards[cardIdx] = { ...card, counterList: newCounterList }; + return updateZone(state, gameId, playerId, zoneName, { cards: updatedCards }); + } + + // ── Arrows ─────────────────────────────────────────────────────────────── + + case Types.ARROW_CREATED: { + const { gameId, playerId, data } = action; + const { arrowInfo } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + return updatePlayer(state, gameId, playerId, { + arrows: { ...player.arrows, [arrowInfo.id]: arrowInfo }, + }); + } + + case Types.ARROW_DELETED: { + const { gameId, playerId, data } = action; + const { arrowId } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const arrows = { ...player.arrows }; + delete arrows[arrowId]; + return updatePlayer(state, gameId, playerId, { arrows }); + } + + // ── Player counters ─────────────────────────────────────────────────────── + + case Types.COUNTER_CREATED: { + const { gameId, playerId, data } = action; + const { counterInfo } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + return updatePlayer(state, gameId, playerId, { + counters: { ...player.counters, [counterInfo.id]: counterInfo }, + }); + } + + case Types.COUNTER_SET: { + const { gameId, playerId, data } = action; + const { counterId, value } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const counter = player.counters[counterId]; + if (!counter) { + return state; + } + return updatePlayer(state, gameId, playerId, { + counters: { ...player.counters, [counterId]: { ...counter, count: value } }, + }); + } + + case Types.COUNTER_DELETED: { + const { gameId, playerId, data } = action; + const { counterId } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const counters = { ...player.counters }; + delete counters[counterId]; + return updatePlayer(state, gameId, playerId, { counters }); + } + + // ── Zone operations ─────────────────────────────────────────────────────── + + case Types.CARDS_DRAWN: { + const { gameId, playerId, data } = action; + const { number: drawCount, cards } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + + const deckZone = player.zones['deck']; + const handZone = player.zones['hand']; + if (!handZone) { + return state; + } + + // Decrement deck count for the drawing player + let newState = deckZone + ? updateZone(state, gameId, playerId, 'deck', { + cardCount: Math.max(0, deckZone.cardCount - drawCount), + }) + : state; + + // Append revealed cards to hand (cards array is empty for non-drawing players; + // use drawCount for count math so all observers track the correct hand/deck size) + const updatedHand = newState.games[gameId]!.players[playerId]!.zones['hand']!; + return updateZone(newState, gameId, playerId, 'hand', { + cards: [...updatedHand.cards, ...cards], + cardCount: updatedHand.cardCount + drawCount, + }); + } + + case Types.CARDS_REVEALED: { + const { gameId, playerId, data } = action; + const { zoneName, cards } = data; + const game = state.games[gameId]; + if (!game) { + return state; + } + const player = game.players[playerId]; + if (!player) { + return state; + } + const zone = player.zones[zoneName]; + if (!zone) { + return state; + } + + // Merge revealed card data into existing zone cards (update existing, append new) + const merged = [...zone.cards]; + for (const revealedCard of cards) { + const idx = merged.findIndex(c => c.id === revealedCard.id); + if (idx >= 0) { + merged[idx] = { ...merged[idx], ...revealedCard }; + } else { + merged.push(revealedCard); + } + } + return updateZone(state, gameId, playerId, zoneName, { cards: merged }); + } + + case Types.ZONE_PROPERTIES_CHANGED: { + const { gameId, playerId, data } = action; + const { zoneName, alwaysRevealTopCard, alwaysLookAtTopCard } = data; + const patch: Partial = {}; + if (alwaysRevealTopCard !== undefined && alwaysRevealTopCard !== null) { + patch.alwaysRevealTopCard = alwaysRevealTopCard; + } + if (alwaysLookAtTopCard !== undefined && alwaysLookAtTopCard !== null) { + patch.alwaysLookAtTopCard = alwaysLookAtTopCard; + } + return updateZone(state, gameId, playerId, zoneName, patch); + } + + // ── Turn / phase ────────────────────────────────────────────────────────── + + case Types.ACTIVE_PLAYER_SET: { + return updateGame(state, action.gameId, { activePlayerId: action.activePlayerId }); + } + + case Types.ACTIVE_PHASE_SET: { + return updateGame(state, action.gameId, { activePhase: action.phase }); + } + + case Types.TURN_REVERSED: { + return updateGame(state, action.gameId, { reversed: action.reversed }); + } + + // ── Chat ────────────────────────────────────────────────────────────────── + + case Types.GAME_SAY: { + const { gameId, playerId, message } = action; + const game = state.games[gameId]; + if (!game) { + return state; + } + const newMessage: GameMessage = { playerId, message, timeReceived: Date.now() }; + return updateGame(state, gameId, { + messages: [...game.messages, newMessage], + }); + } + + // ── Log-only events (state unchanged, future game log will use these) ───── + case Types.ZONE_SHUFFLED: + case Types.ZONE_DUMPED: + case Types.DIE_ROLLED: { + return state; + } + + default: + return state; + } +}; diff --git a/webclient/src/store/game/game.selectors.ts b/webclient/src/store/game/game.selectors.ts new file mode 100644 index 000000000..2de0cb1d1 --- /dev/null +++ b/webclient/src/store/game/game.selectors.ts @@ -0,0 +1,72 @@ +import { GamesState, GameEntry, PlayerEntry, ZoneEntry } from './game.interfaces'; + +interface State { + games: GamesState; +} + +export const Selectors = { + getGames: ({ games }: State): { [gameId: number]: GameEntry } => games.games, + + getGame: ({ games }: State, gameId: number): GameEntry | undefined => games.games[gameId], + + getPlayers: ({ games }: State, gameId: number): { [playerId: number]: PlayerEntry } | undefined => + games.games[gameId]?.players, + + getPlayer: ({ games }: State, gameId: number, playerId: number): PlayerEntry | undefined => + games.games[gameId]?.players[playerId], + + getLocalPlayerId: ({ games }: State, gameId: number): number | undefined => + games.games[gameId]?.localPlayerId, + + getLocalPlayer: (state: State, gameId: number): PlayerEntry | undefined => { + const game = state.games.games[gameId]; + if (!game) { + return undefined; + } + return game.players[game.localPlayerId]; + }, + + getZones: ( + { games }: State, + gameId: number, + playerId: number + ): { [zoneName: string]: ZoneEntry } | undefined => + games.games[gameId]?.players[playerId]?.zones, + + getZone: ( + { games }: State, + gameId: number, + playerId: number, + zoneName: string + ): ZoneEntry | undefined => games.games[gameId]?.players[playerId]?.zones[zoneName], + + getCards: ({ games }: State, gameId: number, playerId: number, zoneName: string) => + games.games[gameId]?.players[playerId]?.zones[zoneName]?.cards ?? [], + + getCounters: ({ games }: State, gameId: number, playerId: number) => + games.games[gameId]?.players[playerId]?.counters ?? {}, + + getArrows: ({ games }: State, gameId: number, playerId: number) => + games.games[gameId]?.players[playerId]?.arrows ?? {}, + + getActivePlayerId: ({ games }: State, gameId: number): number | undefined => + games.games[gameId]?.activePlayerId, + + getActivePhase: ({ games }: State, gameId: number): number | undefined => + games.games[gameId]?.activePhase, + + isStarted: ({ games }: State, gameId: number): boolean => + games.games[gameId]?.started ?? false, + + isSpectator: ({ games }: State, gameId: number): boolean => + games.games[gameId]?.spectator ?? false, + + isReversed: ({ games }: State, gameId: number): boolean => + games.games[gameId]?.reversed ?? false, + + getMessages: ({ games }: State, gameId: number) => + games.games[gameId]?.messages ?? [], + + getActiveGameIds: ({ games }: State): number[] => + Object.keys(games.games).map(Number), +}; diff --git a/webclient/src/store/game/game.types.ts b/webclient/src/store/game/game.types.ts new file mode 100644 index 000000000..19ed016b7 --- /dev/null +++ b/webclient/src/store/game/game.types.ts @@ -0,0 +1,34 @@ +export const Types = { + CLEAR_STORE: '[Games] Clear Store', + GAME_JOINED: '[Games] Game Joined', + GAME_LEFT: '[Games] Game Left', + GAME_CLOSED: '[Games] Game Closed', + GAME_HOST_CHANGED: '[Games] Game Host Changed', + GAME_STATE_CHANGED: '[Games] Game State Changed', + PLAYER_JOINED: '[Games] Player Joined', + PLAYER_LEFT: '[Games] Player Left', + PLAYER_PROPERTIES_CHANGED: '[Games] Player Properties Changed', + KICKED: '[Games] Kicked', + CARD_MOVED: '[Games] Card Moved', + CARD_FLIPPED: '[Games] Card Flipped', + CARD_DESTROYED: '[Games] Card Destroyed', + CARD_ATTACHED: '[Games] Card Attached', + TOKEN_CREATED: '[Games] Token Created', + CARD_ATTR_CHANGED: '[Games] Card Attribute Changed', + CARD_COUNTER_CHANGED: '[Games] Card Counter Changed', + ARROW_CREATED: '[Games] Arrow Created', + ARROW_DELETED: '[Games] Arrow Deleted', + COUNTER_CREATED: '[Games] Counter Created', + COUNTER_SET: '[Games] Counter Set', + COUNTER_DELETED: '[Games] Counter Deleted', + CARDS_DRAWN: '[Games] Cards Drawn', + CARDS_REVEALED: '[Games] Cards Revealed', + ZONE_SHUFFLED: '[Games] Zone Shuffled', + DIE_ROLLED: '[Games] Die Rolled', + ACTIVE_PLAYER_SET: '[Games] Active Player Set', + ACTIVE_PHASE_SET: '[Games] Active Phase Set', + TURN_REVERSED: '[Games] Turn Reversed', + ZONE_DUMPED: '[Games] Zone Dumped', + ZONE_PROPERTIES_CHANGED: '[Games] Zone Properties Changed', + GAME_SAY: '[Games] Game Say', +}; diff --git a/webclient/src/store/game/index.ts b/webclient/src/store/game/index.ts new file mode 100644 index 000000000..77accdb13 --- /dev/null +++ b/webclient/src/store/game/index.ts @@ -0,0 +1,6 @@ +export { Types } from './game.types'; +export { gamesReducer } from './game.reducer'; +export { Actions } from './game.actions'; +export { Dispatch } from './game.dispatch'; +export { Selectors } from './game.selectors'; +export * from './game.interfaces'; diff --git a/webclient/src/store/index.ts b/webclient/src/store/index.ts index b43f290c5..7c257b09e 100644 --- a/webclient/src/store/index.ts +++ b/webclient/src/store/index.ts @@ -3,8 +3,15 @@ export { store } from './store'; // Common export { SortUtil } from './common'; -// Server +// Games +export { + Types as GameTypes, + Selectors as GameSelectors, + Dispatch as GameDispatch } from './game'; +export * from 'store/game/game.interfaces'; + +// Server export { Types as ServerTypes, Selectors as ServerSelectors, diff --git a/webclient/src/store/rootReducer.ts b/webclient/src/store/rootReducer.ts index 0f39d7e37..8da693fce 100644 --- a/webclient/src/store/rootReducer.ts +++ b/webclient/src/store/rootReducer.ts @@ -1,11 +1,13 @@ import { combineReducers } from 'redux'; +import { gamesReducer } from './game'; import { roomsReducer } from './rooms'; import { serverReducer } from './server'; import { reducer as formReducer } from 'redux-form' import { actionReducer } from './actions' export default combineReducers({ + games: gamesReducer, rooms: roomsReducer, server: serverReducer, diff --git a/webclient/src/types/game.ts b/webclient/src/types/game.ts index b9fcc1dc2..4961dc92c 100644 --- a/webclient/src/types/game.ts +++ b/webclient/src/types/game.ts @@ -40,3 +40,461 @@ export enum LeaveGameReason { USER_LEFT = 3, USER_DISCONNECTED = 4 } + +// ── Enums ──────────────────────────────────────────────────────────────────── + +export enum ZoneType { + PrivateZone = 0, + PublicZone = 1, + HiddenZone = 2, +} + +/** Matches CardAttribute enum in card_attributes.proto */ +export enum CardAttribute { + AttrTapped = 1, + AttrAttacking = 2, + AttrFaceDown = 3, + AttrColor = 4, + AttrPT = 5, + AttrAnnotation = 6, + AttrDoesntUntap = 7, +} + +// ── Primitive data structures (mirrors ServerInfo_* protos) ────────────────── + +export interface Color { + r: number; + g: number; + b: number; + a: number; +} + +/** Mirrors ServerInfo_CardCounter */ +export interface CardCounterInfo { + id: number; + value: number; +} + +/** Mirrors ServerInfo_Card */ +export interface CardInfo { + id: number; + name: string; + x: number; + y: number; + faceDown: boolean; + tapped: boolean; + attacking: boolean; + color: string; + pt: string; + annotation: string; + destroyOnZoneChange: boolean; + doesntUntap: boolean; + counterList: CardCounterInfo[]; + attachPlayerId: number; + attachZone: string; + attachCardId: number; + providerId: string; +} + +/** Mirrors ServerInfo_Zone */ +export interface ZoneInfo { + name: string; + type: ZoneType; + withCoords: boolean; + cardCount: number; + cardList: CardInfo[]; + alwaysRevealTopCard: boolean; + alwaysLookAtTopCard: boolean; +} + +/** Mirrors ServerInfo_Counter */ +export interface CounterInfo { + id: number; + name: string; + counterColor: Color; + radius: number; + count: number; +} + +/** Mirrors ServerInfo_Arrow */ +export interface ArrowInfo { + id: number; + startPlayerId: number; + startZone: string; + startCardId: number; + targetPlayerId: number; + targetZone: string; + targetCardId: number; + arrowColor: Color; +} + +/** Mirrors ServerInfo_PlayerProperties */ +export interface PlayerProperties { + playerId: number; + userInfo: any; + spectator: boolean; + conceded: boolean; + readyStart: boolean; + deckHash: string; + pingSeconds: number; + sideboardLocked: boolean; + judge: boolean; +} + +/** Mirrors ServerInfo_Player */ +export interface PlayerInfo { + properties: PlayerProperties; + deckList: string; + zoneList: ZoneInfo[]; + counterList: CounterInfo[]; + arrowList: ArrowInfo[]; +} + +// ── Game event payload interfaces (data arriving from server events) ────────── + +export interface GameStateChangedData { + playerList: PlayerInfo[]; + gameStarted: boolean; + activePlayerId: number; + activePhase: number; + secondsElapsed: number; +} + +export interface GameSayData { + message: string; +} + +export interface MoveCardData { + cardId: number; + cardName: string; + startPlayerId: number; + startZone: string; + position: number; + targetPlayerId: number; + targetZone: string; + x: number; + y: number; + newCardId: number; + faceDown: boolean; + newCardProviderId: string; +} + +export interface FlipCardData { + zoneName: string; + cardId: number; + cardName: string; + faceDown: boolean; + cardProviderId: string; +} + +export interface DestroyCardData { + zoneName: string; + cardId: number; +} + +export interface AttachCardData { + startZone: string; + cardId: number; + targetPlayerId: number; + targetZone: string; + targetCardId: number; +} + +export interface CreateTokenData { + zoneName: string; + cardId: number; + cardName: string; + color: string; + pt: string; + annotation: string; + destroyOnZoneChange: boolean; + x: number; + y: number; + cardProviderId: string; + faceDown: boolean; +} + +export interface SetCardAttrData { + zoneName: string; + cardId: number; + attribute: CardAttribute; + attrValue: string; +} + +export interface SetCardCounterData { + zoneName: string; + cardId: number; + counterId: number; + counterValue: number; +} + +export interface CreateArrowData { + arrowInfo: ArrowInfo; +} + +export interface DeleteArrowData { + arrowId: number; +} + +export interface CreateCounterData { + counterInfo: CounterInfo; +} + +export interface SetCounterData { + counterId: number; + value: number; +} + +export interface DelCounterData { + counterId: number; +} + +export interface DrawCardsData { + number: number; + cards: CardInfo[]; +} + +export interface RevealCardsData { + zoneName: string; + cardId: number[]; + otherPlayerId: number; + cards: CardInfo[]; + grantWriteAccess: boolean; + numberOfCards: number; +} + +export interface ShuffleData { + zoneName: string; + start: number; + end: number; +} + +export interface RollDieData { + sides: number; + value: number; + values: number[]; +} + +export interface DumpZoneData { + zoneOwnerId: number; + zoneName: string; + numberCards: number; + isReversed: boolean; +} + +export interface ChangeZonePropertiesData { + zoneName: string; + alwaysRevealTopCard: boolean; + alwaysLookAtTopCard: boolean; +} + +export interface SetActivePlayerData { + activePlayerId: number; +} + +export interface SetActivePhaseData { + phase: number; +} + +export interface ReverseTurnData { + reversed: boolean; +} + +/** + * Passed to every game event handler alongside the event payload. + * Contains per-container metadata from GameEventContainer. + * Not stored in Redux — transient routing metadata only. + */ +export interface GameEventMeta { + gameId: number; + playerId: number; + /** Raw protobuf GameEventContext object. Not stored in Redux. */ + context: any; + secondsElapsed: number; + /** Proto type is uint32. Non-zero means the action was forced by a judge. */ + forcedByJudge: number; +} + +// ── Command parameter interfaces ───────────────────────────────────────────── + +export interface CardToMove { + cardId: number; + faceDown?: boolean; + pt?: string; + tapped?: boolean; +} + +export interface MoveCardParams { + startPlayerId: number; + startZone: string; + cardsToMove: { card: CardToMove[] }; + targetPlayerId: number; + targetZone: string; + x?: number; + y?: number; + isReversed?: boolean; +} + +export interface DrawCardsParams { + number: number; +} + +export interface RollDieParams { + sides: number; + count?: number; +} + +export interface ShuffleParams { + zoneName: string; + start?: number; + end?: number; +} + +export interface FlipCardParams { + zone: string; + cardId: number; + faceDown: boolean; + pt?: string; +} + +export interface AttachCardParams { + startZone: string; + cardId: number; + targetPlayerId?: number; + targetZone?: string; + targetCardId?: number; +} + +export interface CreateTokenParams { + zone: string; + cardName: string; + color?: string; + pt?: string; + annotation?: string; + destroyOnZoneChange?: boolean; + x?: number; + y?: number; + targetZone?: string; + targetCardId?: number; + targetMode?: number; + cardProviderId?: string; + faceDown?: boolean; +} + +export interface SetCardAttrParams { + zone: string; + cardId: number; + attribute: CardAttribute; + attrValue: string; +} + +export interface SetCardCounterParams { + zone: string; + cardId: number; + counterId: number; + counterValue: number; +} + +export interface IncCardCounterParams { + zone: string; + cardId: number; + counterId: number; + counterDelta: number; +} + +export interface RevealCardsParams { + zoneName: string; + cardId?: number[]; + playerId?: number; + grantWriteAccess?: boolean; + topCards?: number; +} + +export interface DumpZoneParams { + playerId: number; + zoneName: string; + numberCards: number; + isReversed?: boolean; +} + +export interface ChangeZonePropertiesParams { + zoneName: string; + alwaysRevealTopCard?: boolean; + alwaysLookAtTopCard?: boolean; +} + +export interface CreateArrowParams { + startPlayerId: number; + startZone: string; + startCardId: number; + targetPlayerId: number; + targetZone?: string; + targetCardId?: number; + arrowColor: Color; + deleteInPhase?: number; +} + +export interface DeleteArrowParams { + arrowId: number; +} + +export interface CreateCounterParams { + counterName: string; + counterColor: Color; + radius: number; + value: number; +} + +export interface SetCounterParams { + counterId: number; + value: number; +} + +export interface IncCounterParams { + counterId: number; + delta: number; +} + +export interface DelCounterParams { + counterId: number; +} + +export interface KickFromGameParams { + playerId: number; +} + +export interface ReadyStartParams { + ready: boolean; + forceStart?: boolean; +} + +export interface MulliganParams { + number: number; +} + +export interface DeckSelectParams { + deck?: string; + deckId?: number; +} + +export interface MoveCardToZone { + cardName: string; + startZone: string; + targetZone: string; +} + +export interface SetSideboardPlanParams { + moveList: MoveCardToZone[]; +} + +export interface SetSideboardLockParams { + locked: boolean; +} + +export interface SetActivePhaseParams { + phase: number; +} + +export interface GameSayParams { + message: string; +} diff --git a/webclient/src/websocket/commands/game/attachCard.ts b/webclient/src/websocket/commands/game/attachCard.ts new file mode 100644 index 000000000..bc9ebce75 --- /dev/null +++ b/webclient/src/websocket/commands/game/attachCard.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { AttachCardParams } from 'types'; + +export function attachCard(gameId: number, params: AttachCardParams): void { + BackendService.sendGameCommand(gameId, 'Command_AttachCard', params); +} diff --git a/webclient/src/websocket/commands/game/changeZoneProperties.ts b/webclient/src/websocket/commands/game/changeZoneProperties.ts new file mode 100644 index 000000000..77167cc72 --- /dev/null +++ b/webclient/src/websocket/commands/game/changeZoneProperties.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { ChangeZonePropertiesParams } from 'types'; + +export function changeZoneProperties(gameId: number, params: ChangeZonePropertiesParams): void { + BackendService.sendGameCommand(gameId, 'Command_ChangeZoneProperties', params); +} diff --git a/webclient/src/websocket/commands/game/concede.ts b/webclient/src/websocket/commands/game/concede.ts new file mode 100644 index 000000000..fd587464b --- /dev/null +++ b/webclient/src/websocket/commands/game/concede.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function concede(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_Concede', {}); +} diff --git a/webclient/src/websocket/commands/game/createArrow.ts b/webclient/src/websocket/commands/game/createArrow.ts new file mode 100644 index 000000000..7b3a8a294 --- /dev/null +++ b/webclient/src/websocket/commands/game/createArrow.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { CreateArrowParams } from 'types'; + +export function createArrow(gameId: number, params: CreateArrowParams): void { + BackendService.sendGameCommand(gameId, 'Command_CreateArrow', params); +} diff --git a/webclient/src/websocket/commands/game/createCounter.ts b/webclient/src/websocket/commands/game/createCounter.ts new file mode 100644 index 000000000..562ac06b9 --- /dev/null +++ b/webclient/src/websocket/commands/game/createCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { CreateCounterParams } from 'types'; + +export function createCounter(gameId: number, params: CreateCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_CreateCounter', params); +} diff --git a/webclient/src/websocket/commands/game/createToken.ts b/webclient/src/websocket/commands/game/createToken.ts new file mode 100644 index 000000000..16e170e70 --- /dev/null +++ b/webclient/src/websocket/commands/game/createToken.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { CreateTokenParams } from 'types'; + +export function createToken(gameId: number, params: CreateTokenParams): void { + BackendService.sendGameCommand(gameId, 'Command_CreateToken', params); +} diff --git a/webclient/src/websocket/commands/game/deckSelect.ts b/webclient/src/websocket/commands/game/deckSelect.ts new file mode 100644 index 000000000..077cee580 --- /dev/null +++ b/webclient/src/websocket/commands/game/deckSelect.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { DeckSelectParams } from 'types'; + +export function deckSelect(gameId: number, params: DeckSelectParams): void { + BackendService.sendGameCommand(gameId, 'Command_DeckSelect', params); +} diff --git a/webclient/src/websocket/commands/game/delCounter.ts b/webclient/src/websocket/commands/game/delCounter.ts new file mode 100644 index 000000000..ade08ef21 --- /dev/null +++ b/webclient/src/websocket/commands/game/delCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { DelCounterParams } from 'types'; + +export function delCounter(gameId: number, params: DelCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_DelCounter', params); +} diff --git a/webclient/src/websocket/commands/game/deleteArrow.ts b/webclient/src/websocket/commands/game/deleteArrow.ts new file mode 100644 index 000000000..fceef8a95 --- /dev/null +++ b/webclient/src/websocket/commands/game/deleteArrow.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { DeleteArrowParams } from 'types'; + +export function deleteArrow(gameId: number, params: DeleteArrowParams): void { + BackendService.sendGameCommand(gameId, 'Command_DeleteArrow', params); +} diff --git a/webclient/src/websocket/commands/game/drawCards.ts b/webclient/src/websocket/commands/game/drawCards.ts new file mode 100644 index 000000000..ae8e80744 --- /dev/null +++ b/webclient/src/websocket/commands/game/drawCards.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { DrawCardsParams } from 'types'; + +export function drawCards(gameId: number, params: DrawCardsParams): void { + BackendService.sendGameCommand(gameId, 'Command_DrawCards', params); +} diff --git a/webclient/src/websocket/commands/game/dumpZone.ts b/webclient/src/websocket/commands/game/dumpZone.ts new file mode 100644 index 000000000..18aec44f6 --- /dev/null +++ b/webclient/src/websocket/commands/game/dumpZone.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { DumpZoneParams } from 'types'; + +export function dumpZone(gameId: number, params: DumpZoneParams): void { + BackendService.sendGameCommand(gameId, 'Command_DumpZone', params); +} diff --git a/webclient/src/websocket/commands/game/flipCard.ts b/webclient/src/websocket/commands/game/flipCard.ts new file mode 100644 index 000000000..907f1e6a4 --- /dev/null +++ b/webclient/src/websocket/commands/game/flipCard.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { FlipCardParams } from 'types'; + +export function flipCard(gameId: number, params: FlipCardParams): void { + BackendService.sendGameCommand(gameId, 'Command_FlipCard', params); +} diff --git a/webclient/src/websocket/commands/game/gameSay.ts b/webclient/src/websocket/commands/game/gameSay.ts new file mode 100644 index 000000000..2f574b52e --- /dev/null +++ b/webclient/src/websocket/commands/game/gameSay.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { GameSayParams } from 'types'; + +export function gameSay(gameId: number, params: GameSayParams): void { + BackendService.sendGameCommand(gameId, 'Command_GameSay', params); +} diff --git a/webclient/src/websocket/commands/game/incCardCounter.ts b/webclient/src/websocket/commands/game/incCardCounter.ts new file mode 100644 index 000000000..e2fda829a --- /dev/null +++ b/webclient/src/websocket/commands/game/incCardCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { IncCardCounterParams } from 'types'; + +export function incCardCounter(gameId: number, params: IncCardCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_IncCardCounter', params); +} diff --git a/webclient/src/websocket/commands/game/incCounter.ts b/webclient/src/websocket/commands/game/incCounter.ts new file mode 100644 index 000000000..a24173679 --- /dev/null +++ b/webclient/src/websocket/commands/game/incCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { IncCounterParams } from 'types'; + +export function incCounter(gameId: number, params: IncCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_IncCounter', params); +} diff --git a/webclient/src/websocket/commands/game/index.ts b/webclient/src/websocket/commands/game/index.ts new file mode 100644 index 000000000..8978c938c --- /dev/null +++ b/webclient/src/websocket/commands/game/index.ts @@ -0,0 +1,31 @@ +export { leaveGame } from './leaveGame'; +export { kickFromGame } from './kickFromGame'; +export { gameSay } from './gameSay'; +export { readyStart } from './readyStart'; +export { concede } from './concede'; +export { nextTurn } from './nextTurn'; +export { setActivePhase } from './setActivePhase'; +export { reverseTurn } from './reverseTurn'; +export { moveCard } from './moveCard'; +export { flipCard } from './flipCard'; +export { attachCard } from './attachCard'; +export { createToken } from './createToken'; +export { setCardAttr } from './setCardAttr'; +export { setCardCounter } from './setCardCounter'; +export { incCardCounter } from './incCardCounter'; +export { drawCards } from './drawCards'; +export { undoDraw } from './undoDraw'; +export { createArrow } from './createArrow'; +export { deleteArrow } from './deleteArrow'; +export { createCounter } from './createCounter'; +export { setCounter } from './setCounter'; +export { incCounter } from './incCounter'; +export { delCounter } from './delCounter'; +export { shuffle } from './shuffle'; +export { dumpZone } from './dumpZone'; +export { revealCards } from './revealCards'; +export { changeZoneProperties } from './changeZoneProperties'; +export { deckSelect } from './deckSelect'; +export { setSideboardPlan } from './setSideboardPlan'; +export { setSideboardLock } from './setSideboardLock'; +export { mulligan } from './mulligan'; diff --git a/webclient/src/websocket/commands/game/kickFromGame.ts b/webclient/src/websocket/commands/game/kickFromGame.ts new file mode 100644 index 000000000..c14aab980 --- /dev/null +++ b/webclient/src/websocket/commands/game/kickFromGame.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { KickFromGameParams } from 'types'; + +export function kickFromGame(gameId: number, params: KickFromGameParams): void { + BackendService.sendGameCommand(gameId, 'Command_KickFromGame', params); +} diff --git a/webclient/src/websocket/commands/game/leaveGame.ts b/webclient/src/websocket/commands/game/leaveGame.ts new file mode 100644 index 000000000..48c8dcc2a --- /dev/null +++ b/webclient/src/websocket/commands/game/leaveGame.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function leaveGame(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_LeaveGame', {}); +} diff --git a/webclient/src/websocket/commands/game/moveCard.ts b/webclient/src/websocket/commands/game/moveCard.ts new file mode 100644 index 000000000..aebc97bbd --- /dev/null +++ b/webclient/src/websocket/commands/game/moveCard.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { MoveCardParams } from 'types'; + +export function moveCard(gameId: number, params: MoveCardParams): void { + BackendService.sendGameCommand(gameId, 'Command_MoveCard', params); +} diff --git a/webclient/src/websocket/commands/game/mulligan.ts b/webclient/src/websocket/commands/game/mulligan.ts new file mode 100644 index 000000000..9871acf27 --- /dev/null +++ b/webclient/src/websocket/commands/game/mulligan.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { MulliganParams } from 'types'; + +export function mulligan(gameId: number, params: MulliganParams): void { + BackendService.sendGameCommand(gameId, 'Command_Mulligan', params); +} diff --git a/webclient/src/websocket/commands/game/nextTurn.ts b/webclient/src/websocket/commands/game/nextTurn.ts new file mode 100644 index 000000000..696002256 --- /dev/null +++ b/webclient/src/websocket/commands/game/nextTurn.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function nextTurn(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_NextTurn', {}); +} diff --git a/webclient/src/websocket/commands/game/readyStart.ts b/webclient/src/websocket/commands/game/readyStart.ts new file mode 100644 index 000000000..50e818914 --- /dev/null +++ b/webclient/src/websocket/commands/game/readyStart.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { ReadyStartParams } from 'types'; + +export function readyStart(gameId: number, params: ReadyStartParams): void { + BackendService.sendGameCommand(gameId, 'Command_ReadyStart', params); +} diff --git a/webclient/src/websocket/commands/game/revealCards.ts b/webclient/src/websocket/commands/game/revealCards.ts new file mode 100644 index 000000000..567970db5 --- /dev/null +++ b/webclient/src/websocket/commands/game/revealCards.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { RevealCardsParams } from 'types'; + +export function revealCards(gameId: number, params: RevealCardsParams): void { + BackendService.sendGameCommand(gameId, 'Command_RevealCards', params); +} diff --git a/webclient/src/websocket/commands/game/reverseTurn.ts b/webclient/src/websocket/commands/game/reverseTurn.ts new file mode 100644 index 000000000..27662ea7e --- /dev/null +++ b/webclient/src/websocket/commands/game/reverseTurn.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function reverseTurn(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_ReverseTurn', {}); +} diff --git a/webclient/src/websocket/commands/game/setActivePhase.ts b/webclient/src/websocket/commands/game/setActivePhase.ts new file mode 100644 index 000000000..664815f16 --- /dev/null +++ b/webclient/src/websocket/commands/game/setActivePhase.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetActivePhaseParams } from 'types'; + +export function setActivePhase(gameId: number, params: SetActivePhaseParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetActivePhase', params); +} diff --git a/webclient/src/websocket/commands/game/setCardAttr.ts b/webclient/src/websocket/commands/game/setCardAttr.ts new file mode 100644 index 000000000..3d4418aa7 --- /dev/null +++ b/webclient/src/websocket/commands/game/setCardAttr.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetCardAttrParams } from 'types'; + +export function setCardAttr(gameId: number, params: SetCardAttrParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetCardAttr', params); +} diff --git a/webclient/src/websocket/commands/game/setCardCounter.ts b/webclient/src/websocket/commands/game/setCardCounter.ts new file mode 100644 index 000000000..87e1940d2 --- /dev/null +++ b/webclient/src/websocket/commands/game/setCardCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetCardCounterParams } from 'types'; + +export function setCardCounter(gameId: number, params: SetCardCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetCardCounter', params); +} diff --git a/webclient/src/websocket/commands/game/setCounter.ts b/webclient/src/websocket/commands/game/setCounter.ts new file mode 100644 index 000000000..a850d5111 --- /dev/null +++ b/webclient/src/websocket/commands/game/setCounter.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetCounterParams } from 'types'; + +export function setCounter(gameId: number, params: SetCounterParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetCounter', params); +} diff --git a/webclient/src/websocket/commands/game/setSideboardLock.ts b/webclient/src/websocket/commands/game/setSideboardLock.ts new file mode 100644 index 000000000..00df8c763 --- /dev/null +++ b/webclient/src/websocket/commands/game/setSideboardLock.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetSideboardLockParams } from 'types'; + +export function setSideboardLock(gameId: number, params: SetSideboardLockParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetSideboardLock', params); +} diff --git a/webclient/src/websocket/commands/game/setSideboardPlan.ts b/webclient/src/websocket/commands/game/setSideboardPlan.ts new file mode 100644 index 000000000..ad7278738 --- /dev/null +++ b/webclient/src/websocket/commands/game/setSideboardPlan.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { SetSideboardPlanParams } from 'types'; + +export function setSideboardPlan(gameId: number, params: SetSideboardPlanParams): void { + BackendService.sendGameCommand(gameId, 'Command_SetSideboardPlan', params); +} diff --git a/webclient/src/websocket/commands/game/shuffle.ts b/webclient/src/websocket/commands/game/shuffle.ts new file mode 100644 index 000000000..777d03fd6 --- /dev/null +++ b/webclient/src/websocket/commands/game/shuffle.ts @@ -0,0 +1,6 @@ +import { BackendService } from '../../services/BackendService'; +import { ShuffleParams } from 'types'; + +export function shuffle(gameId: number, params: ShuffleParams): void { + BackendService.sendGameCommand(gameId, 'Command_Shuffle', params); +} diff --git a/webclient/src/websocket/commands/game/undoDraw.ts b/webclient/src/websocket/commands/game/undoDraw.ts new file mode 100644 index 000000000..28d47c093 --- /dev/null +++ b/webclient/src/websocket/commands/game/undoDraw.ts @@ -0,0 +1,5 @@ +import { BackendService } from '../../services/BackendService'; + +export function undoDraw(gameId: number): void { + BackendService.sendGameCommand(gameId, 'Command_UndoDraw', {}); +} diff --git a/webclient/src/websocket/commands/index.ts b/webclient/src/websocket/commands/index.ts index 2c68fcfb9..5bed30ffe 100644 --- a/webclient/src/websocket/commands/index.ts +++ b/webclient/src/websocket/commands/index.ts @@ -1,4 +1,5 @@ export * as AdminCommands from './admin'; +export * as GameCommands from './game'; export * as ModeratorCommands from './moderator'; export * as RoomCommands from './room'; export * as SessionCommands from './session'; diff --git a/webclient/src/websocket/events/common/index.ts b/webclient/src/websocket/events/common/index.ts index 77a325c1e..305171fbc 100644 --- a/webclient/src/websocket/events/common/index.ts +++ b/webclient/src/websocket/events/common/index.ts @@ -1,6 +1,3 @@ import { ProtobufEvents } from '../../services/ProtobufService'; -import { playerPropertiesChanged } from './playerPropertiesChanged'; -export const CommonEvents: ProtobufEvents = { - '.Event_PlayerPropertiesChanged.ext': playerPropertiesChanged, -} +export const CommonEvents: ProtobufEvents = {}; diff --git a/webclient/src/websocket/events/common/playerPropertiesChanged.ts b/webclient/src/websocket/events/common/playerPropertiesChanged.ts index 557e57ac9..ff2527fd5 100644 --- a/webclient/src/websocket/events/common/playerPropertiesChanged.ts +++ b/webclient/src/websocket/events/common/playerPropertiesChanged.ts @@ -1,6 +1,3 @@ -import { PlayerGamePropertiesData } from '../session/interfaces'; -import { SessionPersistence } from '../../persistence'; - -export function playerPropertiesChanged(payload: PlayerGamePropertiesData): void { - SessionPersistence.playerPropertiesChanged(payload); -} +// Event_PlayerPropertiesChanged is handled as a game event in websocket/events/game/playerPropertiesChanged.ts +// This file is retained for reference but is no longer registered in CommonEvents. +export {}; diff --git a/webclient/src/websocket/events/game/attachCard.ts b/webclient/src/websocket/events/game/attachCard.ts new file mode 100644 index 000000000..612d8e0bc --- /dev/null +++ b/webclient/src/websocket/events/game/attachCard.ts @@ -0,0 +1,6 @@ +import { AttachCardData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function attachCard(data: AttachCardData, meta: GameEventMeta): void { + GamePersistence.cardAttached(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/changeZoneProperties.ts b/webclient/src/websocket/events/game/changeZoneProperties.ts new file mode 100644 index 000000000..d18008d46 --- /dev/null +++ b/webclient/src/websocket/events/game/changeZoneProperties.ts @@ -0,0 +1,6 @@ +import { ChangeZonePropertiesData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function changeZoneProperties(data: ChangeZonePropertiesData, meta: GameEventMeta): void { + GamePersistence.zonePropertiesChanged(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/createArrow.ts b/webclient/src/websocket/events/game/createArrow.ts new file mode 100644 index 000000000..188b308d5 --- /dev/null +++ b/webclient/src/websocket/events/game/createArrow.ts @@ -0,0 +1,6 @@ +import { CreateArrowData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function createArrow(data: CreateArrowData, meta: GameEventMeta): void { + GamePersistence.arrowCreated(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/createCounter.ts b/webclient/src/websocket/events/game/createCounter.ts new file mode 100644 index 000000000..aa9c9ed5a --- /dev/null +++ b/webclient/src/websocket/events/game/createCounter.ts @@ -0,0 +1,6 @@ +import { CreateCounterData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function createCounter(data: CreateCounterData, meta: GameEventMeta): void { + GamePersistence.counterCreated(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/createToken.ts b/webclient/src/websocket/events/game/createToken.ts new file mode 100644 index 000000000..d2389e98a --- /dev/null +++ b/webclient/src/websocket/events/game/createToken.ts @@ -0,0 +1,6 @@ +import { CreateTokenData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function createToken(data: CreateTokenData, meta: GameEventMeta): void { + GamePersistence.tokenCreated(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/delCounter.ts b/webclient/src/websocket/events/game/delCounter.ts new file mode 100644 index 000000000..431e64696 --- /dev/null +++ b/webclient/src/websocket/events/game/delCounter.ts @@ -0,0 +1,6 @@ +import { DelCounterData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function delCounter(data: DelCounterData, meta: GameEventMeta): void { + GamePersistence.counterDeleted(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/deleteArrow.ts b/webclient/src/websocket/events/game/deleteArrow.ts new file mode 100644 index 000000000..30710e9ec --- /dev/null +++ b/webclient/src/websocket/events/game/deleteArrow.ts @@ -0,0 +1,6 @@ +import { DeleteArrowData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function deleteArrow(data: DeleteArrowData, meta: GameEventMeta): void { + GamePersistence.arrowDeleted(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/destroyCard.ts b/webclient/src/websocket/events/game/destroyCard.ts new file mode 100644 index 000000000..79e4a536a --- /dev/null +++ b/webclient/src/websocket/events/game/destroyCard.ts @@ -0,0 +1,6 @@ +import { DestroyCardData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function destroyCard(data: DestroyCardData, meta: GameEventMeta): void { + GamePersistence.cardDestroyed(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/drawCards.ts b/webclient/src/websocket/events/game/drawCards.ts new file mode 100644 index 000000000..7946fed92 --- /dev/null +++ b/webclient/src/websocket/events/game/drawCards.ts @@ -0,0 +1,6 @@ +import { DrawCardsData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function drawCards(data: DrawCardsData, meta: GameEventMeta): void { + GamePersistence.cardsDrawn(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/dumpZone.ts b/webclient/src/websocket/events/game/dumpZone.ts new file mode 100644 index 000000000..39e195288 --- /dev/null +++ b/webclient/src/websocket/events/game/dumpZone.ts @@ -0,0 +1,6 @@ +import { DumpZoneData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function dumpZone(data: DumpZoneData, meta: GameEventMeta): void { + GamePersistence.zoneDumped(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/flipCard.ts b/webclient/src/websocket/events/game/flipCard.ts new file mode 100644 index 000000000..040445ebc --- /dev/null +++ b/webclient/src/websocket/events/game/flipCard.ts @@ -0,0 +1,6 @@ +import { FlipCardData, GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function flipCard(data: FlipCardData, meta: GameEventMeta): void { + GamePersistence.cardFlipped(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/gameClosed.ts b/webclient/src/websocket/events/game/gameClosed.ts new file mode 100644 index 000000000..9ebdaba5c --- /dev/null +++ b/webclient/src/websocket/events/game/gameClosed.ts @@ -0,0 +1,6 @@ +import { GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function gameClosed(_data: {}, meta: GameEventMeta): void { + GamePersistence.gameClosed(meta.gameId); +} diff --git a/webclient/src/websocket/events/game/gameEvents.spec.ts b/webclient/src/websocket/events/game/gameEvents.spec.ts index 34154fa7e..7f4f9f6b5 100644 --- a/webclient/src/websocket/events/game/gameEvents.spec.ts +++ b/webclient/src/websocket/events/game/gameEvents.spec.ts @@ -1,29 +1,31 @@ jest.mock('../../persistence', () => ({ GamePersistence: { - joinGame: jest.fn(), - leaveGame: jest.fn(), + playerJoined: jest.fn(), + playerLeft: jest.fn(), }, })); import { GamePersistence } from '../../persistence'; +import { joinGame } from './joinGame'; +import { leaveGame } from './leaveGame'; beforeEach(() => jest.clearAllMocks()); describe('joinGame event', () => { - const { joinGame } = jest.requireActual('./joinGame'); - - it('delegates to GamePersistence.joinGame', () => { - const data = { gameId: 5, player: { playerId: 1 } } as any; - joinGame(data); - expect(GamePersistence.joinGame).toHaveBeenCalledWith(data); + it('delegates to GamePersistence.playerJoined with gameId from meta', () => { + const playerProperties = { playerId: 1 }; + const data = { playerProperties } as any; + const meta = { gameId: 5, playerId: 1, context: null, secondsElapsed: 0, forcedByJudge: 0 }; + joinGame(data, meta); + expect(GamePersistence.playerJoined).toHaveBeenCalledWith(5, playerProperties); }); }); describe('leaveGame event', () => { - const { leaveGame } = jest.requireActual('./leaveGame'); - - it('delegates to GamePersistence.leaveGame', () => { - leaveGame(42 as any); - expect(GamePersistence.leaveGame).toHaveBeenCalledWith(42); + it('delegates to GamePersistence.playerLeft with gameId/playerId from meta', () => { + const data = { reason: 3 }; + const meta = { gameId: 5, playerId: 2, context: null, secondsElapsed: 0, forcedByJudge: 0 }; + leaveGame(data, meta); + expect(GamePersistence.playerLeft).toHaveBeenCalledWith(5, 2, 3); }); }); diff --git a/webclient/src/websocket/events/game/gameHostChanged.ts b/webclient/src/websocket/events/game/gameHostChanged.ts new file mode 100644 index 000000000..174b2fca1 --- /dev/null +++ b/webclient/src/websocket/events/game/gameHostChanged.ts @@ -0,0 +1,10 @@ +import { GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +/** + * Event_GameHostChanged carries no payload fields. + * The new host is identified by GameEvent.player_id (meta.playerId). + */ +export function gameHostChanged(_data: {}, meta: GameEventMeta): void { + GamePersistence.gameHostChanged(meta.gameId, meta.playerId); +} diff --git a/webclient/src/websocket/events/game/gameSay.ts b/webclient/src/websocket/events/game/gameSay.ts new file mode 100644 index 000000000..1f649a2fa --- /dev/null +++ b/webclient/src/websocket/events/game/gameSay.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, GameSayData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function gameSay(data: GameSayData, meta: GameEventMeta): void { + GamePersistence.gameSay(meta.gameId, meta.playerId, data.message); +} diff --git a/webclient/src/websocket/events/game/gameStateChanged.ts b/webclient/src/websocket/events/game/gameStateChanged.ts new file mode 100644 index 000000000..94ce9a136 --- /dev/null +++ b/webclient/src/websocket/events/game/gameStateChanged.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, GameStateChangedData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function gameStateChanged(data: GameStateChangedData, meta: GameEventMeta): void { + GamePersistence.gameStateChanged(meta.gameId, data); +} diff --git a/webclient/src/websocket/events/game/index.ts b/webclient/src/websocket/events/game/index.ts index a7b3277a5..aa4be6391 100644 --- a/webclient/src/websocket/events/game/index.ts +++ b/webclient/src/websocket/events/game/index.ts @@ -1,36 +1,62 @@ import { ProtobufEvents } from '../../services/ProtobufService'; +import { attachCard } from './attachCard'; +import { changeZoneProperties } from './changeZoneProperties'; +import { createArrow } from './createArrow'; +import { createCounter } from './createCounter'; +import { createToken } from './createToken'; +import { delCounter } from './delCounter'; +import { deleteArrow } from './deleteArrow'; +import { destroyCard } from './destroyCard'; +import { drawCards } from './drawCards'; +import { dumpZone } from './dumpZone'; +import { flipCard } from './flipCard'; +import { gameClosed } from './gameClosed'; +import { gameHostChanged } from './gameHostChanged'; +import { gameSay } from './gameSay'; +import { gameStateChanged } from './gameStateChanged'; import { joinGame } from './joinGame'; +import { kicked } from './kicked'; import { leaveGame } from './leaveGame'; - +import { moveCard } from './moveCard'; +import { playerPropertiesChanged } from './playerPropertiesChanged'; +import { revealCards } from './revealCards'; +import { reverseTurn } from './reverseTurn'; +import { rollDie } from './rollDie'; +import { setActivePhase } from './setActivePhase'; +import { setActivePlayer } from './setActivePlayer'; +import { setCardAttr } from './setCardAttr'; +import { setCardCounter } from './setCardCounter'; +import { setCounter } from './setCounter'; +import { shuffle } from './shuffle'; export const GameEvents: ProtobufEvents = { '.Event_Join.ext': joinGame, '.Event_Leave.ext': leaveGame, - '.Event_GameClosed.ext': () => console.log('Event_GameClosed.ext'), - '.Event_GameHostChanged.ext': () => console.log('Event_GameHostChanged.ext'), - '.Event_Kicked.ext': () => console.log('Event_Kicked.ext'), - '.Event_GameStateChanged.ext': () => console.log('Event_GameStateChanged.ext'), - // '.Event_PlayerPropertiesChanged.ext': () => console.log("Event_PlayerProperties.ext"), - '.Event_GameSay.ext': () => console.log('Event_GameSay.ext'), - '.Event_CreateArrow.ext': () => console.log('Event_CreateArrow.ext'), - '.Event_DeleteArrow.ext': () => console.log('Event_DeleteArrow.ext'), - '.Event_CreateCounter.ext': () => console.log('Event_CreateCounter.ext'), - '.Event_SetCounter.ext': () => console.log('Event_SetCounter.ext'), - '.Event_DelCounter.ext': () => console.log('Event_DelCounter.ext'), - '.Event_DrawCards.ext': () => console.log('Event_DrawCards.ext'), - '.Event_RevealCards.ext': () => console.log('Event_RevealCards.ext'), - '.Event_Shuffle.ext': () => console.log('Event_Shuffle.ext'), - '.Event_RollDie.ext': () => console.log('Event_Roll.ext'), - '.Event_MoveCard.ext': () => console.log('Event_MoveCard.ext'), - '.Event_FlipCard.ext': () => console.log('Event_FlipCard.ext'), - '.Event_DestroyCard.ext': () => console.log('Event_DestroyCard.ext'), - '.Event_AttachCard.ext': () => console.log('Event_AttachCard.ext'), - '.Event_CreateToken.ext': () => console.log('Event_CreateToken.ext'), - '.Event_SetCardAttribute.ext': () => console.log('Event_SetCardAttribute.ext'), - '.Event_SetCardCounter.ext': () => console.log('Event_SetCardCounter.ext'), - '.Event_SetActivePlayer.ext': () => console.log('Event_SetActivePlayer.ext'), - '.Event_SetActivePhase.ext': () => console.log('Event_SetActivePhase.ext'), - '.Event_DumpZone.ext': () => console.log('Event_DumpZone.ext'), - '.Event_ChangeZoneProperties.ext': () => console.log('Event_ChangeZoneProperties.ext'), - '.Event_ReverseTurn.ext': () => console.log('Event_ReverseTurn.ext'), + '.Event_GameClosed.ext': gameClosed, + '.Event_GameHostChanged.ext': gameHostChanged, + '.Event_Kicked.ext': kicked, + '.Event_GameStateChanged.ext': gameStateChanged, + '.Event_PlayerPropertiesChanged.ext': playerPropertiesChanged, + '.Event_GameSay.ext': gameSay, + '.Event_CreateArrow.ext': createArrow, + '.Event_DeleteArrow.ext': deleteArrow, + '.Event_CreateCounter.ext': createCounter, + '.Event_SetCounter.ext': setCounter, + '.Event_DelCounter.ext': delCounter, + '.Event_DrawCards.ext': drawCards, + '.Event_RevealCards.ext': revealCards, + '.Event_Shuffle.ext': shuffle, + '.Event_RollDie.ext': rollDie, + '.Event_MoveCard.ext': moveCard, + '.Event_FlipCard.ext': flipCard, + '.Event_DestroyCard.ext': destroyCard, + '.Event_AttachCard.ext': attachCard, + '.Event_CreateToken.ext': createToken, + '.Event_SetCardAttr.ext': setCardAttr, + '.Event_SetCardCounter.ext': setCardCounter, + '.Event_SetActivePlayer.ext': setActivePlayer, + '.Event_SetActivePhase.ext': setActivePhase, + '.Event_DumpZone.ext': dumpZone, + '.Event_ChangeZoneProperties.ext': changeZoneProperties, + '.Event_ReverseTurn.ext': reverseTurn, }; diff --git a/webclient/src/websocket/events/game/joinGame.ts b/webclient/src/websocket/events/game/joinGame.ts index 30cef87dc..b0150937b 100644 --- a/webclient/src/websocket/events/game/joinGame.ts +++ b/webclient/src/websocket/events/game/joinGame.ts @@ -1,6 +1,6 @@ import { GamePersistence } from '../../persistence'; -import { PlayerGamePropertiesData } from '../session/interfaces'; +import { GameEventMeta, PlayerProperties } from 'types'; -export function joinGame(playerGamePropertiesData: PlayerGamePropertiesData): void { - GamePersistence.joinGame(playerGamePropertiesData); +export function joinGame(data: { playerProperties: PlayerProperties }, meta: GameEventMeta): void { + GamePersistence.playerJoined(meta.gameId, data.playerProperties); } diff --git a/webclient/src/websocket/events/game/kicked.ts b/webclient/src/websocket/events/game/kicked.ts new file mode 100644 index 000000000..7bf94d5ad --- /dev/null +++ b/webclient/src/websocket/events/game/kicked.ts @@ -0,0 +1,6 @@ +import { GameEventMeta } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function kicked(_data: {}, meta: GameEventMeta): void { + GamePersistence.kicked(meta.gameId); +} diff --git a/webclient/src/websocket/events/game/leaveGame.ts b/webclient/src/websocket/events/game/leaveGame.ts index 74a4dc6c5..9367ba5bb 100644 --- a/webclient/src/websocket/events/game/leaveGame.ts +++ b/webclient/src/websocket/events/game/leaveGame.ts @@ -1,7 +1,6 @@ -import { LeaveGameReason } from 'types'; +import { GameEventMeta } from 'types'; import { GamePersistence } from '../../persistence'; - -export function leaveGame(reason: LeaveGameReason): void { - GamePersistence.leaveGame(reason); +export function leaveGame(data: { reason: number }, meta: GameEventMeta): void { + GamePersistence.playerLeft(meta.gameId, meta.playerId, data.reason ?? 1); } diff --git a/webclient/src/websocket/events/game/moveCard.ts b/webclient/src/websocket/events/game/moveCard.ts new file mode 100644 index 000000000..8a0f3d167 --- /dev/null +++ b/webclient/src/websocket/events/game/moveCard.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, MoveCardData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function moveCard(data: MoveCardData, meta: GameEventMeta): void { + GamePersistence.cardMoved(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/playerPropertiesChanged.ts b/webclient/src/websocket/events/game/playerPropertiesChanged.ts new file mode 100644 index 000000000..e55f05251 --- /dev/null +++ b/webclient/src/websocket/events/game/playerPropertiesChanged.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, PlayerProperties } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function playerPropertiesChanged(data: { playerProperties: PlayerProperties }, meta: GameEventMeta): void { + GamePersistence.playerPropertiesChanged(meta.gameId, meta.playerId, data.playerProperties); +} diff --git a/webclient/src/websocket/events/game/revealCards.ts b/webclient/src/websocket/events/game/revealCards.ts new file mode 100644 index 000000000..1402f3b87 --- /dev/null +++ b/webclient/src/websocket/events/game/revealCards.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, RevealCardsData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function revealCards(data: RevealCardsData, meta: GameEventMeta): void { + GamePersistence.cardsRevealed(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/reverseTurn.ts b/webclient/src/websocket/events/game/reverseTurn.ts new file mode 100644 index 000000000..d7194890a --- /dev/null +++ b/webclient/src/websocket/events/game/reverseTurn.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, ReverseTurnData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function reverseTurn(data: ReverseTurnData, meta: GameEventMeta): void { + GamePersistence.turnReversed(meta.gameId, data.reversed); +} diff --git a/webclient/src/websocket/events/game/rollDie.ts b/webclient/src/websocket/events/game/rollDie.ts new file mode 100644 index 000000000..27b00d395 --- /dev/null +++ b/webclient/src/websocket/events/game/rollDie.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, RollDieData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function rollDie(data: RollDieData, meta: GameEventMeta): void { + GamePersistence.dieRolled(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/setActivePhase.ts b/webclient/src/websocket/events/game/setActivePhase.ts new file mode 100644 index 000000000..013250088 --- /dev/null +++ b/webclient/src/websocket/events/game/setActivePhase.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, SetActivePhaseData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function setActivePhase(data: SetActivePhaseData, meta: GameEventMeta): void { + GamePersistence.activePhaseSet(meta.gameId, data.phase); +} diff --git a/webclient/src/websocket/events/game/setActivePlayer.ts b/webclient/src/websocket/events/game/setActivePlayer.ts new file mode 100644 index 000000000..878fab9f6 --- /dev/null +++ b/webclient/src/websocket/events/game/setActivePlayer.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, SetActivePlayerData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function setActivePlayer(data: SetActivePlayerData, meta: GameEventMeta): void { + GamePersistence.activePlayerSet(meta.gameId, data.activePlayerId); +} diff --git a/webclient/src/websocket/events/game/setCardAttr.ts b/webclient/src/websocket/events/game/setCardAttr.ts new file mode 100644 index 000000000..3461f9cc0 --- /dev/null +++ b/webclient/src/websocket/events/game/setCardAttr.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, SetCardAttrData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function setCardAttr(data: SetCardAttrData, meta: GameEventMeta): void { + GamePersistence.cardAttrChanged(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/setCardCounter.ts b/webclient/src/websocket/events/game/setCardCounter.ts new file mode 100644 index 000000000..364a30585 --- /dev/null +++ b/webclient/src/websocket/events/game/setCardCounter.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, SetCardCounterData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function setCardCounter(data: SetCardCounterData, meta: GameEventMeta): void { + GamePersistence.cardCounterChanged(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/setCounter.ts b/webclient/src/websocket/events/game/setCounter.ts new file mode 100644 index 000000000..b729c1718 --- /dev/null +++ b/webclient/src/websocket/events/game/setCounter.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, SetCounterData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function setCounter(data: SetCounterData, meta: GameEventMeta): void { + GamePersistence.counterSet(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/events/game/shuffle.ts b/webclient/src/websocket/events/game/shuffle.ts new file mode 100644 index 000000000..f94703555 --- /dev/null +++ b/webclient/src/websocket/events/game/shuffle.ts @@ -0,0 +1,6 @@ +import { GameEventMeta, ShuffleData } from 'types'; +import { GamePersistence } from '../../persistence'; + +export function shuffle(data: ShuffleData, meta: GameEventMeta): void { + GamePersistence.zoneShuffled(meta.gameId, meta.playerId, data); +} diff --git a/webclient/src/websocket/persistence/GamePersistence.spec.ts b/webclient/src/websocket/persistence/GamePersistence.spec.ts index 2720b58ca..29377c339 100644 --- a/webclient/src/websocket/persistence/GamePersistence.spec.ts +++ b/webclient/src/websocket/persistence/GamePersistence.spec.ts @@ -1,18 +1,25 @@ import { GamePersistence } from './GamePersistence'; +jest.mock('store', () => ({ + GameDispatch: { + playerJoined: jest.fn(), + playerLeft: jest.fn(), + }, +})); + +import { GameDispatch } from 'store'; + +beforeEach(() => jest.clearAllMocks()); + describe('GamePersistence', () => { - it('joinGame logs to console', () => { - const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + it('playerJoined dispatches via GameDispatch', () => { const data = { playerId: 1 } as any; - GamePersistence.joinGame(data); - expect(spy).toHaveBeenCalledWith('joinGame', data); - spy.mockRestore(); + GamePersistence.playerJoined(5, data); + expect(GameDispatch.playerJoined).toHaveBeenCalledWith(5, data); }); - it('leaveGame logs to console', () => { - const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); - GamePersistence.leaveGame(0 as any); - expect(spy).toHaveBeenCalledWith('leaveGame', 0); - spy.mockRestore(); + it('playerLeft dispatches via GameDispatch', () => { + GamePersistence.playerLeft(5, 1, 3); + expect(GameDispatch.playerLeft).toHaveBeenCalledWith(5, 1, 3); }); }); diff --git a/webclient/src/websocket/persistence/GamePersistence.ts b/webclient/src/websocket/persistence/GamePersistence.ts index e39c1b5a9..16eced49e 100644 --- a/webclient/src/websocket/persistence/GamePersistence.ts +++ b/webclient/src/websocket/persistence/GamePersistence.ts @@ -1,12 +1,142 @@ -import { PlayerGamePropertiesData } from '../events/session/interfaces'; -import { LeaveGameReason } from '../../types'; +import { GameDispatch } from 'store'; +import { + AttachCardData, + ChangeZonePropertiesData, + CreateArrowData, + CreateCounterData, + CreateTokenData, + DelCounterData, + DeleteArrowData, + DestroyCardData, + DrawCardsData, + DumpZoneData, + FlipCardData, + GameStateChangedData, + MoveCardData, + PlayerProperties, + RevealCardsData, + RollDieData, + SetCardAttrData, + SetCardCounterData, + SetCounterData, + ShuffleData, +} from 'types'; export class GamePersistence { - static joinGame(playerGamePropertiesData: PlayerGamePropertiesData) { - console.log('joinGame', playerGamePropertiesData); + static gameStateChanged(gameId: number, data: GameStateChangedData): void { + GameDispatch.gameStateChanged(gameId, data); } - static leaveGame(reason: LeaveGameReason) { - console.log('leaveGame', reason); + static playerJoined(gameId: number, playerProperties: PlayerProperties): void { + GameDispatch.playerJoined(gameId, playerProperties); + } + + static playerLeft(gameId: number, playerId: number, reason: number): void { + GameDispatch.playerLeft(gameId, playerId, reason); + } + + static playerPropertiesChanged(gameId: number, playerId: number, properties: PlayerProperties): void { + GameDispatch.playerPropertiesChanged(gameId, playerId, properties); + } + + static gameClosed(gameId: number): void { + GameDispatch.gameClosed(gameId); + } + + static gameHostChanged(gameId: number, hostId: number): void { + GameDispatch.gameHostChanged(gameId, hostId); + } + + static kicked(gameId: number): void { + GameDispatch.kicked(gameId); + } + + static gameSay(gameId: number, playerId: number, message: string): void { + GameDispatch.gameSay(gameId, playerId, message); + } + + static cardMoved(gameId: number, playerId: number, data: MoveCardData): void { + GameDispatch.cardMoved(gameId, playerId, data); + } + + static cardFlipped(gameId: number, playerId: number, data: FlipCardData): void { + GameDispatch.cardFlipped(gameId, playerId, data); + } + + static cardDestroyed(gameId: number, playerId: number, data: DestroyCardData): void { + GameDispatch.cardDestroyed(gameId, playerId, data); + } + + static cardAttached(gameId: number, playerId: number, data: AttachCardData): void { + GameDispatch.cardAttached(gameId, playerId, data); + } + + static tokenCreated(gameId: number, playerId: number, data: CreateTokenData): void { + GameDispatch.tokenCreated(gameId, playerId, data); + } + + static cardAttrChanged(gameId: number, playerId: number, data: SetCardAttrData): void { + GameDispatch.cardAttrChanged(gameId, playerId, data); + } + + static cardCounterChanged(gameId: number, playerId: number, data: SetCardCounterData): void { + GameDispatch.cardCounterChanged(gameId, playerId, data); + } + + static arrowCreated(gameId: number, playerId: number, data: CreateArrowData): void { + GameDispatch.arrowCreated(gameId, playerId, data); + } + + static arrowDeleted(gameId: number, playerId: number, data: DeleteArrowData): void { + GameDispatch.arrowDeleted(gameId, playerId, data); + } + + static counterCreated(gameId: number, playerId: number, data: CreateCounterData): void { + GameDispatch.counterCreated(gameId, playerId, data); + } + + static counterSet(gameId: number, playerId: number, data: SetCounterData): void { + GameDispatch.counterSet(gameId, playerId, data); + } + + static counterDeleted(gameId: number, playerId: number, data: DelCounterData): void { + GameDispatch.counterDeleted(gameId, playerId, data); + } + + static cardsDrawn(gameId: number, playerId: number, data: DrawCardsData): void { + GameDispatch.cardsDrawn(gameId, playerId, data); + } + + static cardsRevealed(gameId: number, playerId: number, data: RevealCardsData): void { + GameDispatch.cardsRevealed(gameId, playerId, data); + } + + static zoneShuffled(gameId: number, playerId: number, data: ShuffleData): void { + GameDispatch.zoneShuffled(gameId, playerId, data); + } + + static dieRolled(gameId: number, playerId: number, data: RollDieData): void { + GameDispatch.dieRolled(gameId, playerId, data); + } + + static activePlayerSet(gameId: number, activePlayerId: number): void { + GameDispatch.activePlayerSet(gameId, activePlayerId); + } + + static activePhaseSet(gameId: number, phase: number): void { + GameDispatch.activePhaseSet(gameId, phase); + } + + static turnReversed(gameId: number, reversed: boolean): void { + GameDispatch.turnReversed(gameId, reversed); + } + + static zoneDumped(gameId: number, playerId: number, data: DumpZoneData): void { + GameDispatch.zoneDumped(gameId, playerId, data); + } + + static zonePropertiesChanged(gameId: number, playerId: number, data: ChangeZonePropertiesData): void { + GameDispatch.zonePropertiesChanged(gameId, playerId, data); } } + diff --git a/webclient/src/websocket/persistence/SessionPersistence.spec.ts b/webclient/src/websocket/persistence/SessionPersistence.spec.ts index d5dd8e1b5..38080d578 100644 --- a/webclient/src/websocket/persistence/SessionPersistence.spec.ts +++ b/webclient/src/websocket/persistence/SessionPersistence.spec.ts @@ -54,6 +54,10 @@ jest.mock('store', () => ({ replayModifyMatch: jest.fn(), replayDeleteMatch: jest.fn(), }, + GameDispatch: { + gameJoined: jest.fn(), + playerPropertiesChanged: jest.fn(), + }, })); jest.mock('websocket/utils', () => ({ @@ -68,7 +72,7 @@ jest.mock('../utils/NormalizeService', () => ({ })); import { SessionPersistence } from './SessionPersistence'; -import { ServerDispatch } from 'store'; +import { ServerDispatch, GameDispatch } from 'store'; import { sanitizeHtml } from 'websocket/utils'; import NormalizeService from '../utils/NormalizeService'; import { StatusEnum } from 'types'; @@ -318,11 +322,9 @@ describe('SessionPersistence', () => { expect(ServerDispatch.notifyUser).toHaveBeenCalledWith(notif); }); - it('playerPropertiesChanged logs to console', () => { - const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); - SessionPersistence.playerPropertiesChanged({} as any); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); + it('playerPropertiesChanged dispatches via GameDispatch', () => { + SessionPersistence.playerPropertiesChanged(5, 1, {} as any); + expect(GameDispatch.playerPropertiesChanged).toHaveBeenCalledWith(5, 1, {}); }); it('serverShutdown passes data', () => { diff --git a/webclient/src/websocket/persistence/SessionPersistence.ts b/webclient/src/websocket/persistence/SessionPersistence.ts index 9962af968..960a844f6 100644 --- a/webclient/src/websocket/persistence/SessionPersistence.ts +++ b/webclient/src/websocket/persistence/SessionPersistence.ts @@ -1,5 +1,6 @@ -import { ServerDispatch } from 'store'; +import { GameDispatch, ServerDispatch } from 'store'; import { DeckList, DeckStorageTreeItem, ReplayMatch, StatusEnum, User, WebSocketConnectOptions } from 'types'; +import { GameEntry } from 'store/game/game.interfaces'; import { sanitizeHtml } from 'websocket/utils'; import { @@ -180,15 +181,32 @@ export class SessionPersistence { } static gameJoined(gameJoinedData: GameJoinedData): void { - console.log('gameJoined', gameJoinedData); + const { gameInfo, hostId, playerId, spectator, judge } = gameJoinedData; + const gameEntry: GameEntry = { + gameId: gameInfo.gameId, + roomId: gameInfo.roomId, + description: gameInfo.description, + hostId, + localPlayerId: playerId, + spectator, + judge, + started: gameInfo.started, + activePlayerId: -1, + activePhase: -1, + secondsElapsed: 0, + reversed: false, + players: {}, + messages: [], + }; + GameDispatch.gameJoined(gameInfo.gameId, gameEntry); } static notifyUser(notification: NotifyUserData): void { ServerDispatch.notifyUser(notification); } - static playerPropertiesChanged(payload: PlayerGamePropertiesData): void { - console.log('playerPropertiesChanged', payload); + static playerPropertiesChanged(gameId: number, playerId: number, payload: PlayerGamePropertiesData): void { + GameDispatch.playerPropertiesChanged(gameId, playerId, payload); } static serverShutdown(data: ServerShutdownData): void { diff --git a/webclient/src/websocket/services/BackendService.ts b/webclient/src/websocket/services/BackendService.ts index fce741d35..94319c47e 100644 --- a/webclient/src/websocket/services/BackendService.ts +++ b/webclient/src/websocket/services/BackendService.ts @@ -10,6 +10,16 @@ export interface CommandOptions { } export class BackendService { + static sendGameCommand(gameId: number, commandName: string, params: any, options: CommandOptions = {}): void { + const command = ProtoController.root[commandName].create(params || {}); + const gc = ProtoController.root.GameCommand.create({ + [`.${commandName}.ext`]: command, + }); + webClient.protobuf.sendGameCommand(gameId, gc, (raw: any) => { + BackendService.handleResponse(commandName, raw, options); + }); + } + static sendSessionCommand(commandName: string, params: any, options: CommandOptions): void { const command = ProtoController.root[commandName].create(params || {}); const sc = ProtoController.root.SessionCommand.create({ diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 20c4e8599..626e65913 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -2,6 +2,7 @@ import { CommonEvents, GameEvents, RoomEvents, SessionEvents } from '../events'; import { WebClient } from '../WebClient'; import { SessionCommands } from 'websocket'; import { ProtoController } from './ProtoController'; +import { GameEventMeta } from 'types'; export interface ProtobufEvents { [event: string]: Function; @@ -23,6 +24,15 @@ export class ProtobufService { this.pendingCommands = {}; } + public sendGameCommand(gameId: number, gameCmd: any, callback?: Function) { + const cmd = ProtoController.root.CommandContainer.create({ + gameId, + gameCommand: [gameCmd], + }); + + this.sendCommand(cmd, (raw: any) => callback && callback(raw)); + } + public sendRoomCommand(roomId: number, roomCmd: any, callback?: Function) { const cmd = ProtoController.root.CommandContainer.create({ 'roomId': roomId, @@ -88,7 +98,7 @@ export class ProtobufService { this.processSessionEvent(msg.sessionEvent, msg); break; case ProtoController.root.ServerMessage.MessageType.GAME_EVENT_CONTAINER: - this.processGameEvent(msg.gameEvent, msg); + this.processGameEvent(msg.gameEventContainer, msg); break; default: console.log(msg); @@ -121,8 +131,42 @@ export class ProtobufService { this.processEvent(response, SessionEvents, raw); } - private processGameEvent(response: any, raw: any): void { - this.processEvent(response, GameEvents, raw); + private processGameEvent(container: any, raw: any): void { + if (!container?.eventList?.length) { + return; + } + + const { gameId, context, secondsElapsed, forcedByJudge } = container; + + for (const event of container.eventList) { + const meta: GameEventMeta = { + gameId: gameId ?? -1, + playerId: event.playerId ?? -1, + context, + secondsElapsed: secondsElapsed ?? 0, + forcedByJudge: forcedByJudge ?? 0, + }; + + // Try registered game event handlers first, then common event handlers + let handled = false; + for (const key of Object.keys(GameEvents)) { + const payload = event[key]; + if (payload !== undefined && payload !== null) { + (GameEvents[key] as Function)(payload, meta); + handled = true; + break; + } + } + if (!handled) { + for (const key of Object.keys(CommonEvents)) { + const payload = event[key]; + if (payload !== undefined && payload !== null) { + (CommonEvents[key] as Function)(payload, meta); + break; + } + } + } + } } private processEvent(response: any, events: ProtobufEvents, raw: any) {