Implement game layer from protobuf to redux

This commit is contained in:
seavor 2026-04-12 05:05:16 -05:00
parent d96d5e1589
commit 74803442d2
82 changed files with 2455 additions and 88 deletions

View file

@ -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,
}),
};

View file

@ -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));
},
};

View file

@ -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;
}

View file

@ -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<GameEntry>): 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<PlayerEntry>
): 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<ZoneEntry>
): 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<GameEntry> = {};
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<CardInfo> = {};
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<ZoneEntry> = {};
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;
}
};

View file

@ -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),
};

View file

@ -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',
};

View file

@ -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';

View file

@ -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,

View file

@ -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,