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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/common/actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { isString, without } from 'lodash';

import { ActionTypeWithin } from '../types';

import * as game from './game';
import * as socket from './socket';

/** Like isString, but with a more precise type guard in its signature. */
const isStringTyped = <T>(val: T): val is Extract<T, string> => isString(val);

/** Given an actions/* module export T, return just the action type strings, discarding the action implementations. */
const allActionTypes = <T>(exports: T): Array<ActionTypeWithin<T>> => (
Object.values(exports).filter<ActionTypeWithin<T>>(isStringTyped)
);

/** Game and socket action types that are *ignored* by the websocket middleware and not passed along the ws connection. */
const EXCLUDED_ACTION_TYPES: Array<ActionTypeWithin<typeof game> | ActionTypeWithin<typeof socket>> = [
// game.END_GAME, // we actually pass along the END_GAME action now, purely for tracking whether a player is still in a singleplayer game
game.SET_VOLUME, // purely client-side
// the socket connection doesn't care about what happens in single-player modes:
game.AI_RESPONSE,
game.TUTORIAL_STEP,
// the socket connection tracks connection state on its own:
socket.CONNECTING,
socket.CONNECTED,
socket.DISCONNECTED
];

/** The array of action types that the websocket middleware passes along the websocket connection. */
// eslint-disable-next-line import/prefer-default-export
export const SOCKET_ACTION_TYPES: Array<ActionTypeWithin<typeof game> | ActionTypeWithin<typeof socket>> = without(
[...allActionTypes(game), ...allActionTypes(socket)],
...EXCLUDED_ACTION_TYPES
);
11 changes: 2 additions & 9 deletions src/common/components/play/Lobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,6 @@ export default class Lobby extends React.Component<LobbyProps, LobbyState> {
return availableDecks.map((deck) => unpackDeck(deck, cards, sets));
}

get playersInLobby(): string[] {
// TODO note that we don't currently track players who are in single-player modes (tutorial, practice, or sandbox),
// so this will include them as being "in the lobby" -AN
const { games, playersOnline } = this.props.socket;
return playersOnline.filter((clientId) => !games.some(g => g.players.includes(clientId)));
}

public render(): JSX.Element {
const {
availableDecks, cards, sets, history, socket, user,
Expand All @@ -76,7 +69,7 @@ export default class Lobby extends React.Component<LobbyProps, LobbyState> {
const {
clientId, connected, connecting, games,
hosting, queuing, queueSize,
playersOnline, userDataByClientId, waitingPlayers
playersOnline, playersInLobby, userDataByClientId, waitingPlayers
} = socket;
const { casualGameBeingJoined, queueFormat } = this.state;

Expand Down Expand Up @@ -129,7 +122,7 @@ export default class Lobby extends React.Component<LobbyProps, LobbyState> {
connecting={connecting}
connected={connected}
myClientId={clientId!}
playersInLobby={this.playersInLobby}
playersInLobby={playersInLobby}
playersOnline={playersOnline}
userDataByClientId={userDataByClientId}
onConnect={onConnect}
Expand Down
8 changes: 4 additions & 4 deletions src/common/middleware/socketMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import * as sa from '../actions/socket';
const KEEPALIVE_INTERVAL_SECS = 5; // (Heroku kills connection after 55 idle sec.)

interface SocketMiddlewareOpts {
excludedActions: w.ActionType[]
forwardedActionTypes: w.ActionType[]
}

// Middleware creator that builds socketMiddleware given SocketMiddlewareOpts.
function socketMiddleware({ excludedActions }: SocketMiddlewareOpts): Middleware {
function socketMiddleware({ forwardedActionTypes }: SocketMiddlewareOpts): Middleware {
return (store: MiddlewareAPI<Dispatch<AnyAction>, w.State>) => {
let socket: WebSocket;
let keepaliveNeeded = false;
Expand Down Expand Up @@ -42,7 +42,7 @@ function socketMiddleware({ excludedActions }: SocketMiddlewareOpts): Middleware
user = undefined;
send(sa.sendUserData(undefined));
}
} else {
} else if (forwardedActionTypes.includes(action.type) && !action.fromServer) {
send(action);
}
}
Expand Down Expand Up @@ -80,7 +80,7 @@ function socketMiddleware({ excludedActions }: SocketMiddlewareOpts): Middleware
}

function send(action: AnyAction): void {
if (socket && !action.fromServer && !excludedActions.includes(action.type)) {
if (socket) {
// Either send the action or queue it to send later.
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(action));
Expand Down
1 change: 1 addition & 0 deletions src/common/reducers/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default function socket(oldState: State = cloneDeep(defaultState), action
waitingPlayers: action.payload.waitingPlayers,
userDataByClientId: action.payload.userData,
playersOnline: action.payload.playersOnline,
playersInLobby: action.payload.playersInLobby,
queueSize: action.payload.queueSize
};

Expand Down
13 changes: 2 additions & 11 deletions src/common/store/configureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import {
createStore, DeepPartial, Middleware, Store, StoreEnhancer
} from 'redux';

import * as gameActions from '../actions/game';
import * as socketActions from '../actions/socket';
import { SOCKET_ACTION_TYPES } from '../actions';
import { ALWAYS_ENABLE_DEV_TOOLS, ENABLE_REDUX_TIME_TRAVEL } from '../constants';
import multipleDispatchMiddleware from '../middleware/multipleDispatchMiddleware';
import createSocketMiddleware from '../middleware/socketMiddleware';
Expand All @@ -19,15 +18,7 @@ const DEV_TOOLS_ENABLED = ALWAYS_ENABLE_DEV_TOOLS || !['production', 'test'].inc

const selectStoreEnhancers = (): StoreEnhancer[] => {
if (process.browser) {
const socketMiddleware: Middleware = createSocketMiddleware({
excludedActions: [
gameActions.END_GAME,
gameActions.SET_VOLUME,
socketActions.CONNECTING,
socketActions.CONNECTED,
socketActions.DISCONNECTED
]
});
const socketMiddleware: Middleware = createSocketMiddleware({ forwardedActionTypes: SOCKET_ACTION_TYPES });

if (DEV_TOOLS_ENABLED) {
const createLogger: () => Middleware = require('redux-logger').createLogger;
Expand Down
1 change: 1 addition & 0 deletions src/common/store/defaultSocketState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const defaultSocketState: w.SocketState = {

games: [],
playersOnline: [],
playersInLobby: [],
waitingPlayers: [],
userDataByClientId: {},

Expand Down
8 changes: 6 additions & 2 deletions src/common/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export type StringRepresentationOf<_T> = string; // Not actually typechecked bu
export type ActionType = string;
export type ActionPayload = any;

export interface Action {
type: ActionType
export interface Action<AT extends ActionType = ActionType> {
type: AT
payload?: ActionPayload
}

Expand All @@ -50,6 +50,9 @@ export interface MultiDispatch {
<T extends AnyAction | AnyAction[]>(action: T): T
}

/** Given actions/* module export T, produces the union type of action type strings (but not action implementations) exported by T. */
export type ActionTypeWithin<T> = Extract<T[keyof T], string>;

// General types

export interface DeckInGame extends DeckInStore {
Expand Down Expand Up @@ -300,6 +303,7 @@ export interface SocketState {
games: m.Game[]
hosting: boolean
playersOnline: m.ClientID[]
playersInLobby: m.ClientID[]
queuing: boolean
queueSize: number
userDataByClientId: Record<m.ClientID, m.UserData>
Expand Down
42 changes: 29 additions & 13 deletions src/server/multiplayer/MultiplayerServerState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chunk, compact, find, flatMap, fromPairs, groupBy, isNil, mapValues, pick, pull, reject, remove } from 'lodash';
import { chunk, compact, find, flatMap, fromPairs, groupBy, isNil, mapValues, pick, pull, reject, remove, without } from 'lodash';
import * as WebSocket from 'ws';

import { ENABLE_OBFUSCATION_ON_SERVER } from '../../common/constants';
Expand All @@ -17,13 +17,14 @@ import { getPeopleInGame, withoutClient } from './util';
/* eslint-disable no-console */
export default class MultiplayerServerState {
private state: m.ServerState = {
connections: {}, // map of { clientID: websocket }
gameObjects: {}, // map of { gameID: game }
games: [], // array of { id, name, format, players, playerColors, spectators, actions, decks, usernames, startingSeed }
matchmakingQueue: [], // array of { clientID, deck }
playersOnline: [], // array of clientIDs
userData: {}, // map of { clientID: { uid, displayName, ... } }
waitingPlayers: [], // array of { id, name, format, deck, players }
connections: {},
gameObjects: {},
games: [],
matchmakingQueue: [],
playersOnline: [],
playersInSinglePlayerGames: [],
userData: {},
waitingPlayers: [],
};

/*
Expand All @@ -37,6 +38,7 @@ export default class MultiplayerServerState {
games,
waitingPlayers,
playersOnline,
playersInLobby: this.getAllPlayersInLobby(),
userData: fromPairs(Object.keys(userData).map((id) =>
[id, pick(this.getClientUserData(id), ['uid', 'displayName'])]
)),
Expand Down Expand Up @@ -87,12 +89,16 @@ export default class MultiplayerServerState {
return game ? getPeopleInGame(game).filter((id) => id !== clientID) : [];
}

/** Returns whether the given player is currently known to be in a singleplayer game mode. */
public isPlayerInSingleplayerGame = (clientID: m.ClientID): boolean => (
this.state.playersInSinglePlayerGames.includes(clientID)
)

// Returns all players currently in the lobby.
public getAllPlayersInLobby = (): m.ClientID[] => {
const inGamePlayerIds = this.state.games.reduce((acc: m.ClientID[], game: m.Game) => (
acc.concat(game.players)
), []);
return this.state.playersOnline.filter((id) => !inGamePlayerIds.includes(id));
const { games, playersOnline, playersInSinglePlayerGames } = this.state;
const playersInMultiplayerGames: m.ClientID[] = games.flatMap(g => [...g.players, ...g.spectators]);
return without(playersOnline, ...playersInMultiplayerGames, ...playersInSinglePlayerGames);
}

// Returns all *other* players currently in the lobby.
Expand Down Expand Up @@ -158,6 +164,16 @@ export default class MultiplayerServerState {
this.state.userData[clientID] = userData;
}

/** Mark a given player as having entered a singleplayer game (thus leaving the lobby). */
public enterSingleplayerGame = (clientID: m.ClientID): void => {
this.state.playersInSinglePlayerGames = compact([...this.state.playersInSinglePlayerGames, clientID]);
}

/** Mark a given player as having exited a singleplayer game (thus entering the lobby). */
public exitSingleplayerGame = (clientID: m.ClientID): void => {
pull(this.state.playersInSinglePlayerGames, clientID);
}

// Add an player action to the game that player is in.
// Also, updates the game state and checks if the game has been won.
// Returns whether the game has ended.
Expand Down Expand Up @@ -266,7 +282,7 @@ export default class MultiplayerServerState {
// Check if the client is a player (*not a spectator*) in a game
const game = this.lookupGameByClient(clientID);
if (game?.players.includes(clientID)) {
const forfeitAction = { type: 'ws:FORFEIT', payload: { winner: opponentOf(game.playerColors[clientID]) } };
const forfeitAction = { type: 'ws:FORFEIT' as const, payload: { winner: opponentOf(game.playerColors[clientID]) } };
this.appendGameAction(clientID, forfeitAction); // this will call state.endGame().
}

Expand Down
9 changes: 7 additions & 2 deletions src/server/multiplayer/multiplayer.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as WebSocket from 'ws';

import * as w from '../../common/types';
import * as gameActions from '../../common/actions/game';
import * as socketActions from '../../common/actions/socket';

export type Action = w.Action;
export type Action = w.Action<ActionType>;
export type ActionPayload = w.ActionPayload;
export type ActionType = w.ActionType;
export type ActionType = w.ActionTypeWithin<typeof gameActions> | w.ActionTypeWithin<typeof socketActions>;

export type Card = w.Card;
export type CardInGame = w.CardInGame;
export type CardInStore = w.CardInStore;
Expand Down Expand Up @@ -65,13 +68,15 @@ export interface ServerState {
waitingPlayers: GameWaitingForPlayers[]
matchmakingQueue: PlayerInQueue[]
playersOnline: ClientID[]
playersInSinglePlayerGames: ClientID[]
userData: { [clientID: string]: UserData | null }
}

export interface SerializedServerState {
games: Game[]
waitingPlayers: GameWaitingForPlayers[]
playersOnline: ClientID[]
playersInLobby: ClientID[]
userData: { [clientID: string]: UserData | null }
queueSize: number
}
21 changes: 20 additions & 1 deletion src/server/multiplayer/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export default function launchWebsocketServer(server: Server, path: string): voi
const inGame = state.getAllOpponents(clientID);
const payloadWithSender = { ...payload, sender: clientID };
(inGame.length > 0 ? sendMessageInGame : sendMessageInLobby)(clientID, 'ws:CHAT', payloadWithSender);
} else if (['START_PRACTICE', 'START_TUTORIAL', 'START_SANDBOX'].includes(type)) {
enterSingleplayerGame(clientID);
} else if (type === 'END_GAME') {
// We only track client-side END_GAME actions for singleplayer games - multiplayer games track their own end state.
exitSingleplayerGame(clientID);
} else if (type !== 'ws:KEEPALIVE' && state.lookupGameByClient(clientID)) {
// Broadcast in-game actions if the client is a player in a game.
revealVisibleCardsInGame(state.lookupGameByClient(clientID)!, [{ type, payload }, clientID]);
Expand All @@ -110,7 +115,7 @@ export default function launchWebsocketServer(server: Server, path: string): voi

function sendMessage(type: string, payload: Record<string, unknown> = {}, recipientIDs: m.ClientID[] | null = null): void {
const message = JSON.stringify({ type, payload });
console.log(`${recipientIDs?.join(',') || '&'}< ${truncateMessage(message)}`);
console.log(`${(recipientIDs?.length || 0) > 2 ? `(${recipientIDs!.length})` : recipientIDs?.join(',') || '&'}< ${truncateMessage(message)}`);
state.getClientSockets(recipientIDs).forEach((socket) => {
try {
socket.send(message);
Expand Down Expand Up @@ -184,6 +189,20 @@ export default function launchWebsocketServer(server: Server, path: string): voi
broadcastInfo();
}

function enterSingleplayerGame(clientID: m.ClientID): void {
state.enterSingleplayerGame(clientID);
sendChatToLobby(`${state.getClientUsername(clientID)} has left the lobby to play a singleplayer game mode.`);
broadcastInfo();
}

function exitSingleplayerGame(clientID: m.ClientID): void {
if (state.isPlayerInSingleplayerGame(clientID)) {
state.exitSingleplayerGame(clientID);
sendChatToLobby(`${state.getClientUsername(clientID)} has rejoined the lobby.`);
broadcastInfo();
}
}

function spectateGame(clientID: m.ClientID, gameID: m.ClientID): void {
const game: m.Game | undefined = state.spectateGame(clientID, gameID);
if (game) {
Expand Down
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载