From d4de9e27fb2e440c724f91e86af5be3200d049b1 Mon Sep 17 00:00:00 2001 From: Nicolo Davis Date: Wed, 18 Mar 2020 16:37:43 +0800 Subject: [PATCH] move log out of game state --- src/core/initialize.ts | 10 -- src/master/master.test.ts | 21 +--- src/master/master.ts | 72 ++++++------ src/server/api.test.ts | 193 +++++++++++++++++---------------- src/server/api.ts | 68 ++++++------ src/server/db/base.ts | 38 ++++--- src/server/db/flatfile.test.ts | 41 +++++-- src/server/db/flatfile.ts | 46 ++++++-- src/server/db/inmemory.test.ts | 4 +- src/server/db/inmemory.ts | 37 +++++-- src/types.ts | 2 - 11 files changed, 294 insertions(+), 238 deletions(-) diff --git a/src/core/initialize.ts b/src/core/initialize.ts index 4def1050e..7972cd012 100644 --- a/src/core/initialize.ts +++ b/src/core/initialize.ts @@ -57,20 +57,10 @@ export function InitializeGame({ // state updates are only allowed from clients that // are at the same version that the server. _stateID: 0, - // A copy of the initial state so that - // the log can replay actions on top of it. - // TODO: This should really be stored in a different - // part of the DB and not inside the state object. - _initial: {}, }; initial = game.flow.init(initial); initial = plugins.Flush(initial, { game }); - function deepCopy(obj: T): T { - return parse(stringify(obj)); - } - initial._initial = deepCopy(initial); - return initial; } diff --git a/src/master/master.test.ts b/src/master/master.test.ts index 3cdcb44e0..de9b2fa1b 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -125,22 +125,6 @@ describe('update', () => { expect(value.args[1]).toMatchObject({ G: {}, deltalog: undefined, - log: undefined, - _initial: { - G: {}, - _initial: {}, - _redo: [], - _stateID: 0, - _undo: [], - ctx: { - currentPlayer: '0', - numPlayers: 2, - phase: null, - playOrder: ['0', '1'], - playOrderPos: 0, - turn: 1, - }, - }, _redo: [], _stateID: 1, _undo: [], @@ -400,6 +384,11 @@ describe('authentication', () => { }); describe('redactLog', () => { + test('no-op with undefined log', () => { + const result = redactLog(undefined, '0'); + expect(result).toBeUndefined(); + }); + test('no redactedMoves', () => { const logEvents = [ { diff --git a/src/master/master.ts b/src/master/master.ts index 15713b381..09b89f756 100644 --- a/src/master/master.ts +++ b/src/master/master.ts @@ -184,15 +184,17 @@ export class Master { ? action.payload.credentials : undefined; if (IsSynchronous(this.storageAPI)) { - const gameMetadata = this.storageAPI.getMetadata(gameID); - const playerMetadata = getPlayerMetadata(gameMetadata, playerID); - isActionAuthentic = this.shouldAuth(gameMetadata) + const { metadata } = this.storageAPI.fetch(gameID, { metadata: true }); + const playerMetadata = getPlayerMetadata(metadata, playerID); + isActionAuthentic = this.shouldAuth(metadata) ? this.auth(credentials, playerMetadata) : true; } else { - const gameMetadata = await this.storageAPI.getMetadata(gameID); - const playerMetadata = getPlayerMetadata(gameMetadata, playerID); - isActionAuthentic = this.shouldAuth(gameMetadata) + const { metadata } = await this.storageAPI.fetch(gameID, { + metadata: true, + }); + const playerMetadata = getPlayerMetadata(metadata, playerID); + isActionAuthentic = this.shouldAuth(metadata) ? await this.auth(credentials, playerMetadata) : true; } @@ -205,11 +207,13 @@ export class Master { const key = gameID; let state: State; + let result: StorageAPI.FetchResult; if (IsSynchronous(this.storageAPI)) { - state = this.storageAPI.getState(key); + result = this.storageAPI.fetch(key, { state: true }); } else { - state = await this.storageAPI.getState(key); + result = await this.storageAPI.fetch(key, { state: true }); } + state = result.state; if (state === undefined) { logging.error(`game not found, gameID=[${key}]`); @@ -263,8 +267,6 @@ export class Master { return; } - let log = store.getState().log || []; - // Update server's version of the store. store.dispatch(action); state = store.getState(); @@ -279,16 +281,9 @@ export class Master { const filteredState = { ...state, G: this.game.playerView(state.G, state.ctx, playerID), - ctx: { ...state.ctx, _random: undefined }, - log: undefined, deltalog: undefined, _undo: [], _redo: [], - _initial: { - ...state._initial, - _undo: [], - _redo: [], - }, }; const log = redactLog(state.deltalog, playerID); @@ -299,16 +294,10 @@ export class Master { }; }); - // TODO: We currently attach the log back into the state - // object before storing it, but this should probably - // sit in a different part of the database eventually. - log = [...log, ...state.deltalog]; - const stateWithLog = { ...state, log }; - if (IsSynchronous(this.storageAPI)) { - this.storageAPI.setState(key, stateWithLog); + this.storageAPI.setState(key, state); } else { - await this.storageAPI.setState(key, stateWithLog); + await this.storageAPI.setState(key, state); } } @@ -320,22 +309,36 @@ export class Master { const key = gameID; let state: State; + let log: LogEntry[]; let gameMetadata: Server.GameMetadata; let filteredGameMetadata: { id: number; name?: string }[]; + let result: StorageAPI.FetchResult; if (IsSynchronous(this.storageAPI)) { const api = this.storageAPI as StorageAPI.Sync; - state = api.getState(key); - gameMetadata = api.getMetadata(gameID); + result = api.fetch(key, { + state: true, + metadata: true, + log: true, + }); } else { - state = await this.storageAPI.getState(key); - gameMetadata = await this.storageAPI.getMetadata(gameID); + result = await this.storageAPI.fetch(key, { + state: true, + metadata: true, + log: true, + }); } + + state = result.state; + log = result.log; + gameMetadata = result.metadata; + if (gameMetadata) { filteredGameMetadata = Object.values(gameMetadata.players).map(player => { return { id: player.id, name: player.name }; }); } + // If the game doesn't exist, then create one on demand. // TODO: Move this out of the sync call. if (state === undefined) { @@ -349,29 +352,20 @@ export class Master { if (IsSynchronous(this.storageAPI)) { const api = this.storageAPI as StorageAPI.Sync; api.setState(key, state); - state = api.getState(key); } else { await this.storageAPI.setState(key, state); - state = await this.storageAPI.getState(key); } } const filteredState = { ...state, G: this.game.playerView(state.G, state.ctx, playerID), - ctx: { ...state.ctx, _random: undefined }, - log: undefined, deltalog: undefined, _undo: [], _redo: [], - _initial: { - ...state._initial, - _undo: [], - _redo: [], - }, }; - const log = redactLog(state.log, playerID); + log = redactLog(log, playerID); this.transportAPI.send({ playerID, diff --git a/src/server/api.test.ts b/src/server/api.test.ts index a94bc8ef2..7f4890374 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -19,21 +19,12 @@ class AsyncStorage extends StorageAPI.Async { constructor(args: any = {}) { super(); - const { - setState, - getState, - has, - getMetadata, - setMetadata, - listGames, - remove, - } = args; + const { fetch, setState, has, setMetadata, listGames, remove } = args; this.mocks = { setState: setState || jest.fn(), - getState: getState || jest.fn(() => ({})), + fetch: fetch || jest.fn(() => ({})), has: has || jest.fn(() => true), setMetadata: setMetadata || jest.fn(), - getMetadata: getMetadata || jest.fn(() => ({})), listGames: listGames || jest.fn(() => []), remove: remove || jest.fn(), }; @@ -41,6 +32,10 @@ class AsyncStorage extends StorageAPI.Async { async connect() {} + async fetch(...args) { + return this.mocks.fetch(...args); + } + async setState(...args) { this.mocks.setState(...args); } @@ -57,10 +52,6 @@ class AsyncStorage extends StorageAPI.Async { this.mocks.setMetadata(...args); } - async getMetadata(...args) { - return this.mocks.getMetadata(...args); - } - async remove(...args) { this.mocks.remove(...args); } @@ -230,7 +221,9 @@ describe('.createApiServer', () => { describe('when the game does not exist', () => { beforeEach(async () => { - db = new AsyncStorage({ getMetadata: () => null }); + db = new AsyncStorage({ + fetch: () => ({ metadata: null }), + }); const app = createApiServer({ db, games }); response = await request(app.callback()) @@ -246,10 +239,12 @@ describe('.createApiServer', () => { describe('when the game does exist', () => { beforeEach(async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': {}, + metadata: { + players: { + '0': {}, + }, }, }; }, @@ -335,12 +330,14 @@ describe('.createApiServer', () => { describe('when the playerID is not available', () => { beforeEach(async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - credentials, - name: 'bob', + metadata: { + players: { + '0': { + credentials, + name: 'bob', + }, }, }, }; @@ -378,7 +375,7 @@ describe('.createApiServer', () => { describe('when the game does not exist', () => { test('throws a "not found" error', async () => { db = new AsyncStorage({ - getMetadata: async () => null, + fetch: async () => ({ metadata: null }), }); const app = createApiServer({ db, games }); response = await request(app.callback()) @@ -392,16 +389,18 @@ describe('.createApiServer', () => { describe('when the playerID does exist', () => { beforeEach(async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - name: 'alice', - credentials: 'SECRET1', - }, - '1': { - name: 'bob', - credentials: 'SECRET2', + metadata: { + players: { + '0': { + name: 'alice', + credentials: 'SECRET1', + }, + '1': { + name: 'bob', + credentials: 'SECRET2', + }, }, }, }; @@ -495,7 +494,7 @@ describe('.createApiServer', () => { describe('when the game does not exist', () => { test('throws a "not found" error', async () => { db = new AsyncStorage({ - getMetadata: async () => null, + fetch: async () => ({ metadata: null }), }); const app = createApiServer({ db, games }); response = await request(app.callback()) @@ -509,16 +508,18 @@ describe('.createApiServer', () => { describe('when the playerID does exist', () => { beforeEach(async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - name: 'alice', - credentials: 'SECRET1', - }, - '1': { - name: 'bob', - credentials: 'SECRET2', + metadata: { + players: { + '0': { + name: 'alice', + credentials: 'SECRET1', + }, + '1': { + name: 'bob', + credentials: 'SECRET2', + }, }, }, }; @@ -552,15 +553,17 @@ describe('.createApiServer', () => { describe('when there are not players left', () => { test('removes the game', async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - name: 'alice', - credentials: 'SECRET1', - }, - '1': { - credentials: 'SECRET2', + metadata: { + players: { + '0': { + name: 'alice', + credentials: 'SECRET1', + }, + '1': { + credentials: 'SECRET2', + }, }, }, }; @@ -643,16 +646,18 @@ describe('.createApiServer', () => { games = [Game({ name: 'foo' })]; delete process.env.API_SECRET; db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - name: 'alice', - credentials: 'SECRET1', - }, - '1': { - name: 'bob', - credentials: 'SECRET2', + metadata: { + players: { + '0': { + name: 'alice', + credentials: 'SECRET1', + }, + '1': { + name: 'bob', + credentials: 'SECRET2', + }, }, }, }; @@ -681,19 +686,21 @@ describe('.createApiServer', () => { test('fetches next id', async () => { db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - name: 'alice', - credentials: 'SECRET1', - }, - '1': { - name: 'bob', - credentials: 'SECRET2', + metadata: { + players: { + '0': { + name: 'alice', + credentials: 'SECRET1', + }, + '1': { + name: 'bob', + credentials: 'SECRET2', + }, }, + nextRoomID: '12345', }, - nextRoomID: '12345', }; }, }); @@ -706,7 +713,7 @@ describe('.createApiServer', () => { test('when the game does not exist throws a "not found" error', async () => { db = new AsyncStorage({ - getMetadata: async () => null, + fetch: async () => ({ metadata: null }), }); const app = createApiServer({ db, games }); response = await request(app.callback()) @@ -753,16 +760,18 @@ describe('.createApiServer', () => { beforeEach(() => { delete process.env.API_SECRET; db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - id: 0, - credentials: 'SECRET1', - }, - '1': { - id: 1, - credentials: 'SECRET2', + metadata: { + players: { + '0': { + id: 0, + credentials: 'SECRET1', + }, + '1': { + id: 1, + credentials: 'SECRET2', + }, }, }, }; @@ -803,16 +812,18 @@ describe('.createApiServer', () => { beforeEach(() => { delete process.env.API_SECRET; db = new AsyncStorage({ - getMetadata: async () => { + fetch: async () => { return { - players: { - '0': { - id: 0, - credentials: 'SECRET1', - }, - '1': { - id: 1, - credentials: 'SECRET2', + metadata: { + players: { + '0': { + id: 0, + credentials: 'SECRET1', + }, + '1': { + id: 1, + credentials: 'SECRET2', + }, }, }, }; @@ -846,7 +857,7 @@ describe('.createApiServer', () => { let response; beforeEach(async () => { db = new AsyncStorage({ - getMetadata: async () => null, + fetch: async () => ({ metadata: null }), }); let games = [Game({ name: 'foo' })]; let app = createApiServer({ db, games }); diff --git a/src/server/api.ts b/src/server/api.ts index 15afe9dcf..74aa54955 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -131,7 +131,7 @@ export const addApiToServer = ({ const gameList = await db.listGames(gameName); let rooms = []; for (let gameID of gameList) { - const metadata = await db.getMetadata(gameID); + const { metadata } = await db.fetch(gameID, { metadata: true }); rooms.push({ gameID, players: Object.values(metadata.players).map((player: any) => { @@ -148,16 +148,16 @@ export const addApiToServer = ({ router.get('/games/:name/:id', async ctx => { const gameID = ctx.params.id; - const room = await db.getMetadata(gameID); - if (!room) { + const { metadata } = await db.fetch(gameID, { metadata: true }); + if (!metadata) { ctx.throw(404, 'Room ' + gameID + ' not found'); } const strippedRoom = { roomID: gameID, - players: Object.values(room.players).map((player: any) => { + players: Object.values(metadata.players).map((player: any) => { return { id: player.id, name: player.name }; }), - setupData: room.setupData, + setupData: metadata.setupData, }; ctx.body = strippedRoom; }); @@ -172,22 +172,22 @@ export const addApiToServer = ({ ctx.throw(403, 'playerName is required'); } const gameID = ctx.params.id; - const gameMetadata = await db.getMetadata(gameID); - if (!gameMetadata) { + const { metadata } = await db.fetch(gameID, { metadata: true }); + if (!metadata) { ctx.throw(404, 'Game ' + gameID + ' not found'); } - if (!gameMetadata.players[playerID]) { + if (!metadata.players[playerID]) { ctx.throw(404, 'Player ' + playerID + ' not found'); } - if (gameMetadata.players[playerID].name) { + if (metadata.players[playerID].name) { ctx.throw(409, 'Player ' + playerID + ' not available'); } - gameMetadata.players[playerID].name = playerName; + metadata.players[playerID].name = playerName; const playerCredentials = await lobbyConfig.generateCredentials(ctx); - gameMetadata.players[playerID].credentials = playerCredentials; + metadata.players[playerID].credentials = playerCredentials; - await db.setMetadata(gameID, gameMetadata); + await db.setMetadata(gameID, metadata); ctx.body = { playerCredentials, @@ -198,25 +198,25 @@ export const addApiToServer = ({ const gameID = ctx.params.id; const playerID = ctx.request.body.playerID; const credentials = ctx.request.body.credentials; - const gameMetadata = await db.getMetadata(gameID); + const { metadata } = await db.fetch(gameID, { metadata: true }); if (typeof playerID === 'undefined' || playerID === null) { ctx.throw(403, 'playerID is required'); } - if (!gameMetadata) { + if (!metadata) { ctx.throw(404, 'Game ' + gameID + ' not found'); } - if (!gameMetadata.players[playerID]) { + if (!metadata.players[playerID]) { ctx.throw(404, 'Player ' + playerID + ' not found'); } - if (credentials !== gameMetadata.players[playerID].credentials) { + if (credentials !== metadata.players[playerID].credentials) { ctx.throw(403, 'Invalid credentials ' + credentials); } - delete gameMetadata.players[playerID].name; - delete gameMetadata.players[playerID].credentials; - if (Object.values(gameMetadata.players).some((val: any) => val.name)) { - await db.setMetadata(gameID, gameMetadata); + delete metadata.players[playerID].name; + delete metadata.players[playerID].credentials; + if (Object.values(metadata.players).some((val: any) => val.name)) { + await db.setMetadata(gameID, metadata); } else { // remove room await db.remove(gameID); @@ -229,7 +229,7 @@ export const addApiToServer = ({ const gameID = ctx.params.id; const playerID = ctx.request.body.playerID; const credentials = ctx.request.body.credentials; - const gameMetadata = await db.getMetadata(gameID); + const { metadata } = await db.fetch(gameID, { metadata: true }); // User-data to pass to the game setup function. const setupData = ctx.request.body.setupData; // The number of players for this game instance. @@ -242,19 +242,19 @@ export const addApiToServer = ({ ctx.throw(403, 'playerID is required'); } - if (!gameMetadata) { + if (!metadata) { ctx.throw(404, 'Game ' + gameID + ' not found'); } - if (!gameMetadata.players[playerID]) { + if (!metadata.players[playerID]) { ctx.throw(404, 'Player ' + playerID + ' not found'); } - if (credentials !== gameMetadata.players[playerID].credentials) { + if (credentials !== metadata.players[playerID].credentials) { ctx.throw(403, 'Invalid credentials ' + credentials); } // Check if nextRoom is already set, if so, return that id. - if (gameMetadata.nextRoomID) { - ctx.body = { nextRoomID: gameMetadata.nextRoomID }; + if (metadata.nextRoomID) { + ctx.body = { nextRoomID: metadata.nextRoomID }; return; } @@ -266,9 +266,9 @@ export const addApiToServer = ({ setupData, lobbyConfig ); - gameMetadata.nextRoomID = nextRoomID; + metadata.nextRoomID = nextRoomID; - await db.setMetadata(gameID, gameMetadata); + await db.setMetadata(gameID, metadata); ctx.body = { nextRoomID, @@ -280,25 +280,25 @@ export const addApiToServer = ({ const playerID = ctx.request.body.playerID; const credentials = ctx.request.body.credentials; const newName = ctx.request.body.newName; - const gameMetadata = await db.getMetadata(gameID); + const { metadata } = await db.fetch(gameID, { metadata: true }); if (typeof playerID === 'undefined') { ctx.throw(403, 'playerID is required'); } if (!newName) { ctx.throw(403, 'newName is required'); } - if (!gameMetadata) { + if (!metadata) { ctx.throw(404, 'Game ' + gameID + ' not found'); } - if (!gameMetadata.players[playerID]) { + if (!metadata.players[playerID]) { ctx.throw(404, 'Player ' + playerID + ' not found'); } - if (credentials !== gameMetadata.players[playerID].credentials) { + if (credentials !== metadata.players[playerID].credentials) { ctx.throw(403, 'Invalid credentials ' + credentials); } - gameMetadata.players[playerID].name = newName; - await db.setMetadata(gameID, gameMetadata); + metadata.players[playerID].name = newName; + await db.setMetadata(gameID, metadata); ctx.body = {}; }); diff --git a/src/server/db/base.ts b/src/server/db/base.ts index 67838f1b7..befb60db3 100644 --- a/src/server/db/base.ts +++ b/src/server/db/base.ts @@ -1,10 +1,28 @@ -import { State, Server } from '../../types'; +import { State, Server, LogEntry } from '../../types'; export enum Type { SYNC = 0, ASYNC = 1, } +/** + * Indicates which fields the fetch operation should return. + */ +export interface FetchOpts { + state?: boolean; + log?: boolean; + metadata?: boolean; +} + +/** + * The result of the fetch operation. + */ +export interface FetchResult { + state?: State; + log?: LogEntry[]; + metadata?: Server.GameMetadata; +} + export abstract class Async { /* istanbul ignore next */ type() { @@ -23,9 +41,9 @@ export abstract class Async { abstract setState(gameID: string, state: State): Promise; /** - * Read the latest game state. + * Fetch the game state. */ - abstract getState(gameID: string): Promise; + abstract fetch(gameID: string, opts: FetchOpts): Promise; /** * Check if a particular game id exists. @@ -40,11 +58,6 @@ export abstract class Async { metadata: Server.GameMetadata ): Promise; - /** - * Fetch the game metadata. - */ - abstract getMetadata(gameID: string): Promise; - /** * Remove the game state. */ @@ -74,9 +87,9 @@ export abstract class Sync { abstract setState(gameID: string, state: State): void; /** - * Read the latest game state. + * Fetch the game state. */ - abstract getState(gameID: string): State; + abstract fetch(gameID: string, opts: FetchOpts): FetchResult; /** * Check if a particular game id exists. @@ -88,11 +101,6 @@ export abstract class Sync { */ abstract setMetadata(gameID: string, metadata: Server.GameMetadata): void; - /** - * Fetch the game metadata. - */ - abstract getMetadata(gameID: string): Server.GameMetadata; - /** * Remove the game state. */ diff --git a/src/server/db/flatfile.test.ts b/src/server/db/flatfile.test.ts index 603a89e72..748ed5382 100644 --- a/src/server/db/flatfile.test.ts +++ b/src/server/db/flatfile.test.ts @@ -7,7 +7,7 @@ */ import { FlatFile } from './flatfile'; -import { State, Server } from '../../types'; +import { State, Server, LogEntry } from '../../types'; describe('FlatFile', () => { let db; @@ -17,13 +17,13 @@ describe('FlatFile', () => { await db.connect(); }); - afterAll(async () => { + afterEach(async () => { await db.clear(); }); test('basic', async () => { // Must return undefined when no game exists. - let state: unknown = await db.getState('gameID'); + let { state } = await db.fetch('gameID', { state: true }); expect(state).toEqual(undefined); // Create game. @@ -33,10 +33,9 @@ describe('FlatFile', () => { await db.setMetadata('gameID', metadata as Server.GameMetadata); // Must return created game. - state = await db.getState('gameID'); - metadata = await db.getMetadata('gameID'); - expect(state).toEqual({ a: 1 }); - expect(metadata).toEqual({ metadata: true }); + const result = await db.fetch('gameID', { state: true, metadata: true }); + expect(result.state).toEqual({ a: 1 }); + expect(result.metadata).toEqual({ metadata: true }); let has = await db.has('gameID'); expect(has).toEqual(true); @@ -58,4 +57,32 @@ describe('FlatFile', () => { let keys2 = await db.listGames(); expect(keys2).toHaveLength(0); }); + + test('log', async () => { + const logEntry1: LogEntry = { + _stateID: 0, + action: { + type: 'MAKE_MOVE', + payload: { type: '', playerID: '0', args: [] }, + }, + turn: 0, + phase: '', + }; + + const logEntry2: LogEntry = { + _stateID: 1, + action: { + type: 'MAKE_MOVE', + payload: { type: '', playerID: '0', args: [] }, + }, + turn: 1, + phase: '', + }; + + await db.setState('gameID', { deltalog: [logEntry1] }); + await db.setState('gameID', { deltalog: [logEntry2] }); + + const result = await db.fetch('gameID', { log: true }); + expect(result.log).toEqual([logEntry1, logEntry2]); + }); }); diff --git a/src/server/db/flatfile.ts b/src/server/db/flatfile.ts index 9c49ffe2f..44d9a42c6 100644 --- a/src/server/db/flatfile.ts +++ b/src/server/db/flatfile.ts @@ -1,5 +1,5 @@ import * as StorageAPI from './base'; -import { State, Server } from '../../types'; +import { State, Server, LogEntry } from '../../types'; /* * Copyright 2017 The boardgame.io Authors @@ -16,7 +16,7 @@ export class FlatFile extends StorageAPI.Async { private games: { init: (opts: object) => void; setItem: (id: string, value: any) => void; - getItem: (id: string) => State | Server.GameMetadata; + getItem: (id: string) => State | Server.GameMetadata | LogEntry[]; removeItem: (id: string) => void; clear: () => {}; keys: () => string[]; @@ -50,23 +50,43 @@ export class FlatFile extends StorageAPI.Async { return; } + async fetch( + gameID: string, + opts: StorageAPI.FetchOpts + ): Promise { + let result: StorageAPI.FetchResult = {}; + + if (opts.state) { + result.state = (await this.games.getItem(gameID)) as State; + } + + if (opts.metadata) { + const key = MetadataKey(gameID); + result.metadata = (await this.games.getItem(key)) as Server.GameMetadata; + } + + if (opts.log) { + const key = LogKey(gameID); + result.log = (await this.games.getItem(key)) as LogEntry[]; + } + + return result; + } + async clear() { return this.games.clear(); } async setState(id: string, state: State) { + let log: LogEntry[] = + ((await this.games.getItem(LogKey(id))) as LogEntry[]) || []; + if (state.deltalog) { + log = log.concat(state.deltalog); + } + await this.games.setItem(LogKey(id), log); return await this.games.setItem(id, state); } - async getState(id: string): Promise { - return (await this.games.getItem(id)) as State; - } - - async getMetadata(id: string): Promise { - const key = MetadataKey(id); - return (await this.games.getItem(key)) as Server.GameMetadata; - } - async setMetadata(id: string, metadata: Server.GameMetadata): Promise { const key = MetadataKey(id); return await this.games.setItem(key, metadata); @@ -95,3 +115,7 @@ export class FlatFile extends StorageAPI.Async { function MetadataKey(gameID: string) { return `${gameID}:metadata`; } + +function LogKey(gameID: string) { + return `${gameID}:log`; +} diff --git a/src/server/db/inmemory.test.ts b/src/server/db/inmemory.test.ts index 42b672e79..273973be0 100644 --- a/src/server/db/inmemory.test.ts +++ b/src/server/db/inmemory.test.ts @@ -19,7 +19,7 @@ describe('InMemory', () => { // Must return undefined when no game exists. test('must return undefined when no game exists', () => { - let state = db.getState('gameID'); + const { state } = db.fetch('gameID', { state: true }); expect(state).toEqual(undefined); }); @@ -32,7 +32,7 @@ describe('InMemory', () => { } as Server.GameMetadata); db.setState('gameID', stateEntry as State); // Must return created game. - const state = db.getState('gameID'); + const { state } = db.fetch('gameID', { state: true }); expect(state).toEqual(stateEntry); }); diff --git a/src/server/db/inmemory.ts b/src/server/db/inmemory.ts index 8fd707513..d4501841b 100644 --- a/src/server/db/inmemory.ts +++ b/src/server/db/inmemory.ts @@ -1,4 +1,4 @@ -import { State, Server } from '../../types'; +import { State, Server, LogEntry } from '../../types'; import * as StorageAPI from './base'; /* @@ -15,6 +15,7 @@ import * as StorageAPI from './base'; export class InMemory extends StorageAPI.Sync { private games: Map; private metadata: Map; + private log: Map; /** * Creates a new InMemory storage. @@ -23,6 +24,7 @@ export class InMemory extends StorageAPI.Sync { super(); this.games = new Map(); this.metadata = new Map(); + this.log = new Map(); } /** @@ -32,25 +34,38 @@ export class InMemory extends StorageAPI.Sync { this.metadata.set(gameID, metadata); } - /** - * Read the game metadata from the in-memory object. - */ - getMetadata(gameID: string): Server.GameMetadata { - return this.metadata.get(gameID); - } - /** * Write the game state to the in-memory object. */ setState(gameID: string, state: State): void { this.games.set(gameID, state); + + let log = this.log.get(gameID) || []; + if (state.deltalog) { + log = log.concat(state.deltalog); + } + this.log.set(gameID, log); } /** - * Read the game state from the in-memory object. + * Fetches state for a particular gameID. */ - getState(gameID: string): State { - return this.games.get(gameID); + fetch(gameID: string, opts: StorageAPI.FetchOpts): StorageAPI.FetchResult { + let result: StorageAPI.FetchResult = {}; + + if (opts.state) { + result.state = this.games.get(gameID); + } + + if (opts.metadata) { + result.metadata = this.metadata.get(gameID); + } + + if (opts.log) { + result.log = this.log.get(gameID) || []; + } + + return result; } /** diff --git a/src/types.ts b/src/types.ts index 14a41d43f..f7b5a578b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,6 @@ import { Flow } from './core/flow'; export interface State { G: object; ctx: Ctx; - log?: Array; deltalog?: Array; plugins: { [pluginName: string]: PluginState; @@ -13,7 +12,6 @@ export interface State { _undo: Array; _redo: Array; _stateID: number; - _initial?: Omit | {}; } export type GameState = Pick;