From 43685fd7ff83ed03342c273851e69228adde8dc7 Mon Sep 17 00:00:00 2001 From: Joffrey Bion Date: Mon, 24 Jul 2017 19:40:23 +0200 Subject: Decouple Seven Wonders API from saga channels API --- frontend/src/api/model.js | 76 +++++++++++++++++++++++++++++++++++++ frontend/src/api/sevenWondersApi.js | 76 ++++++++++++++++++++++--------------- frontend/src/api/websocket.js | 57 ++++++++++++++++++---------- frontend/src/sagas.js | 4 +- frontend/src/sagas/gameBrowser.js | 12 ++++-- frontend/src/sagas/home.js | 3 +- frontend/src/sagas/lobby.js | 5 ++- frontend/src/sagas/utils.js | 10 +++++ 8 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 frontend/src/api/model.js create mode 100644 frontend/src/sagas/utils.js diff --git a/frontend/src/api/model.js b/frontend/src/api/model.js new file mode 100644 index 00000000..eb7ad9c5 --- /dev/null +++ b/frontend/src/api/model.js @@ -0,0 +1,76 @@ +export class ApiError { + message: string; + details: ApiErrorDetail[]; +} + +export class ApiErrorDetail { + message: string; +} + +export type ApiGameState = "LOBBY" | "PLAYING"; + +export class ApiLobby { + id: number; + name: string; + owner: ApiPlayer; + players: ApiPlayer[]; + settings: ApiSettings; + state: ApiGameState; +} + +export type ApiWonderSidePickMethod = "EACH_RANDOM" | "ALL_A" | "ALL_B" | "SAME_RANDOM_FOR_ALL"; + +export class ApiSettings { + randomSeedForTests: number; + timeLimitInSeconds: number; + wonderSidePickMethod: ApiWonderSidePickMethod; + initialGold: number; + discardedCardGold: number; + defaultTradingCost: number; + pointsPer3Gold: number; + lostPointsPerDefeat: number; + wonPointsPerVictoryPerAge: Map; +} + +export class ApiPlayer { + username: string; + displayName: string; + index: number; + ready: boolean; +} + +export class ApiTable {} + +export class ApiHandCard {} + +export class ApiCard {} + +export class ApiPreparedCard {} + +export class ApiPlayerTurnInfo { + playerIndex: number; + table: ApiTable; + currentAge: number; + action: Action; + hand: ApiHandCard[]; + neighbourGuildCards: ApiCard[]; + message: string; +} + +export type ApiMoveType = "PLAY" | "PLAY_FREE" | "UPGRADE_WONDER" | "DISCARD" | "COPY_GUILD"; +export type ApiProvider = "LEFT_NEIGHBOUR" | "RIGHT_NEIGHBOUR"; +export type ApiResourceType = "WOOD" | "STONE" | "ORE" | "CLAY" | "GLASS" | "PAPYRUS" | "LOOM"; + +export class ApiResources { + quantities: Map; +} +export class ApiBoughtResources { + provider: ApiProvider; + resources: ApiResources; +} + +export class ApiPlayerMove { + type: ApiMoveType; + cardName: string; + boughtResources: ApiBoughtResources[]; +} diff --git a/frontend/src/api/sevenWondersApi.js b/frontend/src/api/sevenWondersApi.js index 4ba0fff3..1004c81e 100644 --- a/frontend/src/api/sevenWondersApi.js +++ b/frontend/src/api/sevenWondersApi.js @@ -1,66 +1,82 @@ -import { createJsonSubscriptionChannel, createStompSession } from './websocket'; -import type { Client } from 'webstomp-client'; -import type { Channel } from 'redux-saga'; +import { createJsonStompClient, JsonStompClient, Callback } from './websocket'; +import { ApiError, ApiLobby, ApiPlayer, ApiPlayerMove, ApiPlayerTurnInfo, ApiPreparedCard, ApiTable } from './model'; const wsURL = '/seven-wonders-websocket'; export class SevenWondersSession { - client: Client; + client: JsonStompClient; - constructor(client: Client) { + constructor(client: JsonStompClient) { this.client = client; } - watchErrors(): Channel { - return createJsonSubscriptionChannel(this.client, '/user/queue/errors'); + watchErrors(callback: Callback): void { + return this.client.subscribe('/user/queue/errors', callback); } chooseName(displayName: string): void { - this.client.send('/app/chooseName', JSON.stringify({ playerName: displayName })); + this.client.send('/app/chooseName', { playerName: displayName }); } - watchNameChoice(): Channel { - return createJsonSubscriptionChannel(this.client, '/user/queue/nameChoice'); + watchNameChoice(callback: Callback): void { + return this.client.subscribe('/user/queue/nameChoice', callback); } - watchGames(): Channel { - return createJsonSubscriptionChannel(this.client, '/topic/games'); + watchGames(callback: Callback): void { + return this.client.subscribe('/topic/games', callback); } - watchLobbyJoined(): Channel { - return createJsonSubscriptionChannel(this.client, '/user/queue/lobby/joined'); + watchLobbyJoined(callback: Callback): void { + return this.client.subscribe('/user/queue/lobby/joined', callback); } - watchLobbyUpdated(currentGameId: number): Channel { - return createJsonSubscriptionChannel(this.client, `/topic/lobby/${currentGameId}/updated`); + watchLobbyUpdated(currentGameId: number, callback: Callback): void { + return this.client.subscribe(`/topic/lobby/${currentGameId}/updated`, callback); } - watchGameStarted(currentGameId: number): Channel { - return createJsonSubscriptionChannel(this.client, `/topic/lobby/${currentGameId}/started`); + watchGameStarted(currentGameId: number, callback: Callback): void { + return this.client.subscribe(`/topic/lobby/${currentGameId}/started`, callback); } createGame(gameName: string): void { - this.client.send('/app/lobby/create', JSON.stringify({ gameName })); + this.client.send('/app/lobby/create', { gameName }); } joinGame(gameId: number): void { - this.client.send('/app/lobby/join', JSON.stringify({ gameId })); + this.client.send('/app/lobby/join', { gameId }); } startGame(): void { - this.client.send('/app/lobby/startGame', {}); + this.client.send('/app/lobby/startGame'); } -} -export function createSession(): Promise { - return createStompSession(wsURL).then(client => new SevenWondersSession(client)); -} + watchPlayerReady(currentGameId: number, callback: Callback): void { + return this.client.subscribe(`/topic/game/${currentGameId}/playerReady`, callback); + } + + watchTableUpdates(currentGameId: number, callback: Callback): void { + return this.client.subscribe(`/topic/game/${currentGameId}/tableUpdates`, callback); + } + + watchPreparedMove(currentGameId: number, callback: Callback): void { + return this.client.subscribe(`/topic/game/${currentGameId}/prepared`, callback); + } + + watchTurnInfo(callback: Callback): void { + return this.client.subscribe('/user/queue/game/turnInfo', callback); + } -export class ApiError { - message: string; - details: ApiErrorDetail[]; + sayReady(): void { + this.client.send('/app/game/sayReady'); + } + + prepareMove(move: ApiPlayerMove): void { + this.client.send('/app/game/sayReady', { move }); + } } -export class ApiErrorDetail { - message: string; +export async function connectToGame(): Promise { + const jsonStompClient: JsonStompClient = createJsonStompClient(wsURL); + await jsonStompClient.connect(); + return new SevenWondersSession(jsonStompClient); } diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js index 6dc6e1a0..d411587a 100644 --- a/frontend/src/api/websocket.js +++ b/frontend/src/api/websocket.js @@ -1,32 +1,49 @@ // @flow import SockJS from 'sockjs-client'; import Stomp from 'webstomp-client'; -import type { Client, Frame, Subscription } from 'webstomp-client'; +import type { Client, Frame, Options, Subscription } from 'webstomp-client'; -import { eventChannel } from 'redux-saga'; -import type { Channel } from 'redux-saga'; +const DEFAULT_DEBUG_OPTIONS = { + debug: process.env.NODE_ENV !== 'production', +}; -function createStompClient(url: string): Client { - return Stomp.over(new SockJS(url), { - debug: process.env.NODE_ENV !== 'production', - }); -} +export type Callback = (value: T) => void; -export function createStompSession(url: string, headers: Object = {}): Promise { - return new Promise((resolve, reject) => { - const client: Client = createStompClient(url); - const onSuccess = (frame: Frame) => resolve(client); - client.connect(headers, onSuccess, reject); - }); -} +export type Callable = () => void; + +export class JsonStompClient { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + connect(headers: Object = {}): Promise { + return new Promise((resolve, reject) => { + this.client.connect(headers, resolve, reject); + }); + } -export function createJsonSubscriptionChannel(client: Client, path: string): Channel { - return eventChannel((emitter: (data: any) => void) => { - const socketSubscription: Subscription = client.subscribe(path, (frame: Frame) => { + subscribe(path: string, callback: Callback): Callable { + const socketSubscription: Subscription = this.client.subscribe(path, (frame: Frame) => { // not all frames have a JSON body const value = frame && frame.body && JSON.parse(frame.body); - emitter(value); + callback(value); }); return () => socketSubscription.unsubscribe(); - }); + } + + send(url: string, body: Object) { + const strBody = body ? JSON.stringify(body) : ''; + this.client.send(url, strBody); + } +} + +function createStompClient(url: string, options: Options = {}): Client { + const optionsWithDebug = Object.assign({}, DEFAULT_DEBUG_OPTIONS, options); + return Stomp.over(new SockJS(url), optionsWithDebug); +} + +export function createJsonStompClient(url: string, options: Options = {}): JsonStompClient { + return new JsonStompClient(createStompClient(url, options)); } diff --git a/frontend/src/sagas.js b/frontend/src/sagas.js index 2b690a02..b7fc4122 100644 --- a/frontend/src/sagas.js +++ b/frontend/src/sagas.js @@ -6,12 +6,12 @@ import { makeSagaRoutes } from './routes'; import errorHandlingSaga from './sagas/errors'; import type { History } from 'react-router'; -import { SevenWondersSession, createSession } from './api/sevenWondersApi'; +import { SevenWondersSession, connectToGame } from './api/sevenWondersApi'; export default function* rootSaga(history: History): * { let sevenWondersSession: SevenWondersSession | void; try { - sevenWondersSession = yield call(createSession); + sevenWondersSession = yield call(connectToGame); } catch (error) { console.error('Could not connect to socket', error); return; diff --git a/frontend/src/sagas/gameBrowser.js b/frontend/src/sagas/gameBrowser.js index 17eb9287..871908f6 100644 --- a/frontend/src/sagas/gameBrowser.js +++ b/frontend/src/sagas/gameBrowser.js @@ -8,9 +8,10 @@ import { game as gameSchema, gameList as gameListSchema } from '../schemas/games import { actions as gameActions, types } from '../redux/games'; import { actions as playerActions } from '../redux/players'; import type { SevenWondersSession } from '../api/sevenWondersApi'; +import { createChannel } from './utils'; function* watchGames(session: SevenWondersSession): * { - const gamesChannel = yield apply(session, session.watchGames, []); + const gamesChannel = yield createChannel(session, session.watchGames); try { while (true) { const gameList = yield take(gamesChannel); @@ -25,7 +26,7 @@ function* watchGames(session: SevenWondersSession): * { } function* watchLobbyJoined(session: SevenWondersSession): * { - const joinedLobbyChannel = yield apply(session, session.watchLobbyJoined, []); + const joinedLobbyChannel = yield createChannel(session, session.watchLobbyJoined); try { const joinedLobby = yield take(joinedLobbyChannel); const normalized = normalize(joinedLobby, gameSchema); @@ -54,7 +55,12 @@ function* joinGame(session: SevenWondersSession): * { } function* gameBrowserSaga(session: SevenWondersSession): * { - yield [call(watchGames, session), call(watchLobbyJoined, session), call(createGame, session), call(joinGame, session)]; + yield [ + call(watchGames, session), + call(watchLobbyJoined, session), + call(createGame, session), + call(joinGame, session), + ]; } export default gameBrowserSaga; diff --git a/frontend/src/sagas/home.js b/frontend/src/sagas/home.js index eb65097b..b51bf4dc 100644 --- a/frontend/src/sagas/home.js +++ b/frontend/src/sagas/home.js @@ -3,6 +3,7 @@ import { push } from 'react-router-redux'; import { actions, types } from '../redux/players'; import type { SevenWondersSession } from '../api/sevenWondersApi'; +import { createChannel } from './utils'; function* sendUsername(session: SevenWondersSession) { while (true) { @@ -12,7 +13,7 @@ function* sendUsername(session: SevenWondersSession) { } function* validateUsername(session: SevenWondersSession) { - const usernameChannel = yield apply(session, session.watchNameChoice, []); + const usernameChannel = yield createChannel(session, session.watchNameChoice); while (true) { const user = yield take(usernameChannel); yield put(actions.setCurrentPlayer(user)); diff --git a/frontend/src/sagas/lobby.js b/frontend/src/sagas/lobby.js index 0c264dde..cc704086 100644 --- a/frontend/src/sagas/lobby.js +++ b/frontend/src/sagas/lobby.js @@ -9,6 +9,7 @@ import { game as gameSchema } from '../schemas/games'; import { actions as gameActions, types } from '../redux/games'; import { actions as playerActions } from '../redux/players'; import { SevenWondersSession } from '../api/sevenWondersApi'; +import { createChannel } from './utils'; function getCurrentGameId(): number { const path = window.location.pathname; @@ -17,7 +18,7 @@ function getCurrentGameId(): number { function* watchLobbyUpdates(session: SevenWondersSession) { const currentGameId: number = getCurrentGameId(); - const lobbyUpdatesChannel: Channel = yield apply(session, session.watchLobbyUpdated, [currentGameId]); + const lobbyUpdatesChannel: Channel = yield createChannel(session, session.watchLobbyUpdated, currentGameId); try { while (true) { const lobby = yield take(lobbyUpdatesChannel); @@ -32,7 +33,7 @@ function* watchLobbyUpdates(session: SevenWondersSession) { function* watchGameStart(session: SevenWondersSession) { const currentGameId = getCurrentGameId(); - const gameStartedChannel = yield apply(session, session.watchGameStarted, [currentGameId]); + const gameStartedChannel = yield createChannel(session, session.watchGameStarted, currentGameId); try { yield take(gameStartedChannel); yield put(gameActions.enterGame()); diff --git a/frontend/src/sagas/utils.js b/frontend/src/sagas/utils.js new file mode 100644 index 00000000..28017c87 --- /dev/null +++ b/frontend/src/sagas/utils.js @@ -0,0 +1,10 @@ +import { SevenWondersSession } from '../api/sevenWondersApi'; +import { eventChannel } from 'redux-saga'; + +type Emitter = (value: any) => void; + +export function createChannel(session: SevenWondersSession, methodWithCallback: () => void, ...args: Array) { + return eventChannel((emitter: Emitter) => { + return methodWithCallback.call(session, ...args, emitter); + }); +} -- cgit