@mnbroatch/boardgame.io 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/ai/package.json +7 -0
- package/client/package.json +7 -0
- package/core/package.json +7 -0
- package/debug/package.json +7 -0
- package/dist/boardgameio.es.js +14238 -0
- package/dist/boardgameio.js +14277 -0
- package/dist/boardgameio.min.js +16 -0
- package/dist/cjs/Debug-9d141c06.js +9586 -0
- package/dist/cjs/ai-e0e8a768.js +377 -0
- package/dist/cjs/ai.js +20 -0
- package/dist/cjs/client-76dec77b.js +258 -0
- package/dist/cjs/client-a22d7500.js +524 -0
- package/dist/cjs/client.js +26 -0
- package/dist/cjs/core.js +52 -0
- package/dist/cjs/debug.js +18 -0
- package/dist/cjs/filter-player-view-bb02e2f6.js +89 -0
- package/dist/cjs/initialize-267fcd69.js +61 -0
- package/dist/cjs/internal.js +25 -0
- package/dist/cjs/master-2904879d.js +320 -0
- package/dist/cjs/master.js +18 -0
- package/dist/cjs/multiplayer.js +23 -0
- package/dist/cjs/plugin-random-7425844d.js +229 -0
- package/dist/cjs/plugins.js +59 -0
- package/dist/cjs/react-native.js +182 -0
- package/dist/cjs/react.js +727 -0
- package/dist/cjs/reducer-16eec232.js +1203 -0
- package/dist/cjs/server.js +4087 -0
- package/dist/cjs/socketio-7a0837eb.js +478 -0
- package/dist/cjs/testing.js +30 -0
- package/dist/cjs/transport-b1874dfa.js +37 -0
- package/dist/cjs/turn-order-b2ff8740.js +1136 -0
- package/dist/cjs/util-fcfd8fb8.js +140 -0
- package/dist/esm/Debug-0141fe2d.js +9577 -0
- package/dist/esm/ai-5c06e761.js +371 -0
- package/dist/esm/ai.js +8 -0
- package/dist/esm/client-2e653027.js +522 -0
- package/dist/esm/client-5f57c3f2.js +255 -0
- package/dist/esm/client.js +16 -0
- package/dist/esm/core.js +40 -0
- package/dist/esm/debug.js +10 -0
- package/dist/esm/filter-player-view-2c6cc96f.js +87 -0
- package/dist/esm/initialize-11d626ca.js +59 -0
- package/dist/esm/internal.js +10 -0
- package/dist/esm/master-fa8f2e43.js +318 -0
- package/dist/esm/master.js +10 -0
- package/dist/esm/multiplayer.js +14 -0
- package/dist/esm/plugin-random-087f861e.js +226 -0
- package/dist/esm/plugins.js +55 -0
- package/dist/esm/react-native.js +173 -0
- package/dist/esm/react.js +716 -0
- package/dist/esm/reducer-c46da7e5.js +1198 -0
- package/dist/esm/socketio-c22ffa65.js +455 -0
- package/dist/esm/testing.js +26 -0
- package/dist/esm/transport-ce07b771.js +35 -0
- package/dist/esm/turn-order-376d315e.js +1091 -0
- package/dist/esm/util-b6147cef.js +135 -0
- package/dist/types/packages/ai.d.ts +5 -0
- package/dist/types/packages/client.d.ts +3 -0
- package/dist/types/packages/core.d.ts +5 -0
- package/dist/types/packages/debug.d.ts +2 -0
- package/dist/types/packages/internal.d.ts +8 -0
- package/dist/types/packages/master.d.ts +2 -0
- package/dist/types/packages/multiplayer.d.ts +3 -0
- package/dist/types/packages/plugins.d.ts +3 -0
- package/dist/types/packages/react-native.d.ts +2 -0
- package/dist/types/packages/react.d.ts +3 -0
- package/dist/types/packages/server.d.ts +6 -0
- package/dist/types/packages/testing.d.ts +1 -0
- package/dist/types/src/ai/ai.d.ts +53 -0
- package/dist/types/src/ai/ai.test.d.ts +1 -0
- package/dist/types/src/ai/bot.d.ts +40 -0
- package/dist/types/src/ai/mcts-bot.d.ts +60 -0
- package/dist/types/src/ai/random-bot.d.ts +27 -0
- package/dist/types/src/client/client.d.ts +104 -0
- package/dist/types/src/client/client.test.d.ts +1 -0
- package/dist/types/src/client/debug/tests/debug.test.d.ts +1 -0
- package/dist/types/src/client/manager.d.ts +61 -0
- package/dist/types/src/client/react.d.ts +75 -0
- package/dist/types/src/client/react.ssr.test.d.ts +4 -0
- package/dist/types/src/client/react.test.d.ts +1 -0
- package/dist/types/src/client/transport/dummy.d.ts +18 -0
- package/dist/types/src/client/transport/local.d.ts +59 -0
- package/dist/types/src/client/transport/local.test.d.ts +1 -0
- package/dist/types/src/client/transport/socketio.d.ts +45 -0
- package/dist/types/src/client/transport/socketio.test.d.ts +1 -0
- package/dist/types/src/client/transport/transport.d.ts +50 -0
- package/dist/types/src/client/transport/transport.test.d.ts +1 -0
- package/dist/types/src/core/action-creators.d.ts +144 -0
- package/dist/types/src/core/action-types.d.ts +10 -0
- package/dist/types/src/core/backwards-compatibility.d.ts +12 -0
- package/dist/types/src/core/constants.d.ts +6 -0
- package/dist/types/src/core/errors.d.ts +15 -0
- package/dist/types/src/core/flow.d.ts +28 -0
- package/dist/types/src/core/flow.test.d.ts +1 -0
- package/dist/types/src/core/game-methods.d.ts +9 -0
- package/dist/types/src/core/game.d.ts +26 -0
- package/dist/types/src/core/game.test.d.ts +1 -0
- package/dist/types/src/core/initialize.d.ts +9 -0
- package/dist/types/src/core/logger.d.ts +2 -0
- package/dist/types/src/core/player-view.d.ts +7 -0
- package/dist/types/src/core/player-view.test.d.ts +1 -0
- package/dist/types/src/core/reducer.d.ts +155 -0
- package/dist/types/src/core/reducer.test.d.ts +1 -0
- package/dist/types/src/core/turn-order.d.ts +179 -0
- package/dist/types/src/core/turn-order.test.d.ts +8 -0
- package/dist/types/src/lobby/client.d.ts +194 -0
- package/dist/types/src/lobby/client.test.d.ts +1 -0
- package/dist/types/src/lobby/connection.d.ts +44 -0
- package/dist/types/src/lobby/connection.test.d.ts +1 -0
- package/dist/types/src/lobby/create-match-form.d.ts +26 -0
- package/dist/types/src/lobby/login-form.d.ts +23 -0
- package/dist/types/src/lobby/match-instance.d.ts +31 -0
- package/dist/types/src/lobby/react.d.ts +113 -0
- package/dist/types/src/lobby/react.ssr.test.d.ts +4 -0
- package/dist/types/src/lobby/react.test.d.ts +1 -0
- package/dist/types/src/master/filter-player-view.d.ts +96 -0
- package/dist/types/src/master/filter-player-view.test.d.ts +1 -0
- package/dist/types/src/master/master.d.ts +94 -0
- package/dist/types/src/master/master.test.d.ts +1 -0
- package/dist/types/src/plugins/events/events.d.ts +54 -0
- package/dist/types/src/plugins/events/events.test.d.ts +1 -0
- package/dist/types/src/plugins/main.d.ts +75 -0
- package/dist/types/src/plugins/main.test.d.ts +1 -0
- package/dist/types/src/plugins/plugin-events.d.ts +5 -0
- package/dist/types/src/plugins/plugin-immer.d.ts +7 -0
- package/dist/types/src/plugins/plugin-immer.test.d.ts +1 -0
- package/dist/types/src/plugins/plugin-log.d.ts +14 -0
- package/dist/types/src/plugins/plugin-log.test.d.ts +1 -0
- package/dist/types/src/plugins/plugin-player.d.ts +29 -0
- package/dist/types/src/plugins/plugin-player.test.d.ts +1 -0
- package/dist/types/src/plugins/plugin-random.d.ts +4 -0
- package/dist/types/src/plugins/plugin-serializable.d.ts +7 -0
- package/dist/types/src/plugins/plugin-serializable.test.d.ts +1 -0
- package/dist/types/src/plugins/random/random.alea.d.ts +19 -0
- package/dist/types/src/plugins/random/random.d.ts +54 -0
- package/dist/types/src/plugins/random/random.test.d.ts +1 -0
- package/dist/types/src/server/api.d.ts +13 -0
- package/dist/types/src/server/api.test.d.ts +1 -0
- package/dist/types/src/server/auth.d.ts +38 -0
- package/dist/types/src/server/auth.test.d.ts +1 -0
- package/dist/types/src/server/cors.d.ts +4 -0
- package/dist/types/src/server/cors.test.d.ts +1 -0
- package/dist/types/src/server/db/base.d.ts +192 -0
- package/dist/types/src/server/db/flatfile.d.ts +44 -0
- package/dist/types/src/server/db/flatfile.test.d.ts +1 -0
- package/dist/types/src/server/db/index.d.ts +4 -0
- package/dist/types/src/server/db/index.test.d.ts +1 -0
- package/dist/types/src/server/db/inmemory.d.ts +43 -0
- package/dist/types/src/server/db/inmemory.test.d.ts +1 -0
- package/dist/types/src/server/db/localstorage.d.ts +7 -0
- package/dist/types/src/server/db/localstorage.test.d.ts +1 -0
- package/dist/types/src/server/index.d.ts +68 -0
- package/dist/types/src/server/index.test.d.ts +1 -0
- package/dist/types/src/server/transport/pubsub/generic-pub-sub.d.ts +6 -0
- package/dist/types/src/server/transport/pubsub/in-memory-pub-sub.d.ts +7 -0
- package/dist/types/src/server/transport/pubsub/in-memory-pub-sub.test.d.ts +1 -0
- package/dist/types/src/server/transport/socketio-simultaneous.test.d.ts +1 -0
- package/dist/types/src/server/transport/socketio.d.ts +65 -0
- package/dist/types/src/server/transport/socketio.test.d.ts +1 -0
- package/dist/types/src/server/util.d.ts +35 -0
- package/dist/types/src/testing/mock-random.d.ts +15 -0
- package/dist/types/src/testing/mock-random.test.d.ts +1 -0
- package/dist/types/src/types.d.ts +387 -0
- package/internal/package.json +7 -0
- package/master/package.json +7 -0
- package/multiplayer/package.json +7 -0
- package/package.json +211 -0
- package/plugins/package.json +7 -0
- package/react/package.json +7 -0
- package/react-native/package.json +7 -0
- package/server/package.json +6 -0
- package/src/ai/ai.test.ts +433 -0
- package/src/ai/ai.ts +84 -0
- package/src/ai/bot.ts +122 -0
- package/src/ai/mcts-bot.ts +331 -0
- package/src/ai/random-bot.ts +20 -0
- package/src/client/client.test.ts +993 -0
- package/src/client/client.ts +588 -0
- package/src/client/debug/Debug.svelte +239 -0
- package/src/client/debug/Menu.svelte +65 -0
- package/src/client/debug/ai/AI.svelte +215 -0
- package/src/client/debug/ai/Options.svelte +48 -0
- package/src/client/debug/info/Info.svelte +22 -0
- package/src/client/debug/info/Item.svelte +24 -0
- package/src/client/debug/log/Log.svelte +157 -0
- package/src/client/debug/log/LogEvent.svelte +149 -0
- package/src/client/debug/log/LogMetadata.svelte +7 -0
- package/src/client/debug/log/PhaseMarker.svelte +27 -0
- package/src/client/debug/log/TurnMarker.svelte +23 -0
- package/src/client/debug/main/ClientSwitcher.svelte +59 -0
- package/src/client/debug/main/Controls.svelte +58 -0
- package/src/client/debug/main/Hotkey.svelte +84 -0
- package/src/client/debug/main/InteractiveFunction.svelte +85 -0
- package/src/client/debug/main/Main.svelte +121 -0
- package/src/client/debug/main/Move.svelte +68 -0
- package/src/client/debug/main/PlayerInfo.svelte +70 -0
- package/src/client/debug/mcts/Action.svelte +22 -0
- package/src/client/debug/mcts/MCTS.svelte +78 -0
- package/src/client/debug/mcts/Table.svelte +98 -0
- package/src/client/debug/tests/JSONTree.mock.svelte +3 -0
- package/src/client/debug/tests/debug.test.ts +183 -0
- package/src/client/debug/utils/shortcuts.js +50 -0
- package/src/client/debug/utils/shortcuts.test.js +49 -0
- package/src/client/manager.ts +177 -0
- package/src/client/react-native.js +136 -0
- package/src/client/react-native.test.js +229 -0
- package/src/client/react.ssr.test.tsx +24 -0
- package/src/client/react.test.tsx +213 -0
- package/src/client/react.tsx +192 -0
- package/src/client/transport/dummy.ts +19 -0
- package/src/client/transport/local.test.ts +353 -0
- package/src/client/transport/local.ts +230 -0
- package/src/client/transport/socketio.test.ts +328 -0
- package/src/client/transport/socketio.ts +210 -0
- package/src/client/transport/transport.test.ts +27 -0
- package/src/client/transport/transport.ts +95 -0
- package/src/core/action-creators.ts +159 -0
- package/src/core/action-types.ts +18 -0
- package/src/core/backwards-compatibility.ts +23 -0
- package/src/core/constants.ts +6 -0
- package/src/core/errors.ts +35 -0
- package/src/core/flow.test.ts +2433 -0
- package/src/core/flow.ts +897 -0
- package/src/core/game-methods.ts +9 -0
- package/src/core/game.test.ts +286 -0
- package/src/core/game.ts +114 -0
- package/src/core/initialize.ts +77 -0
- package/src/core/logger.test.js +90 -0
- package/src/core/logger.ts +18 -0
- package/src/core/player-view.test.ts +50 -0
- package/src/core/player-view.ts +39 -0
- package/src/core/reducer.test.ts +991 -0
- package/src/core/reducer.ts +532 -0
- package/src/core/turn-order.test.ts +1123 -0
- package/src/core/turn-order.ts +473 -0
- package/src/lobby/client.test.ts +385 -0
- package/src/lobby/client.ts +358 -0
- package/src/lobby/connection.test.ts +207 -0
- package/src/lobby/connection.ts +162 -0
- package/src/lobby/create-match-form.tsx +122 -0
- package/src/lobby/login-form.tsx +75 -0
- package/src/lobby/match-instance.tsx +135 -0
- package/src/lobby/react.ssr.test.tsx +22 -0
- package/src/lobby/react.test.tsx +594 -0
- package/src/lobby/react.tsx +402 -0
- package/src/master/filter-player-view.test.ts +381 -0
- package/src/master/filter-player-view.ts +102 -0
- package/src/master/master.test.ts +1068 -0
- package/src/master/master.ts +492 -0
- package/src/plugins/events/events.test.ts +108 -0
- package/src/plugins/events/events.ts +209 -0
- package/src/plugins/main.test.ts +411 -0
- package/src/plugins/main.ts +314 -0
- package/src/plugins/plugin-events.ts +40 -0
- package/src/plugins/plugin-immer.test.ts +86 -0
- package/src/plugins/plugin-immer.ts +37 -0
- package/src/plugins/plugin-log.test.ts +37 -0
- package/src/plugins/plugin-log.ts +40 -0
- package/src/plugins/plugin-player.test.ts +172 -0
- package/src/plugins/plugin-player.ts +100 -0
- package/src/plugins/plugin-random.ts +40 -0
- package/src/plugins/plugin-serializable.test.ts +40 -0
- package/src/plugins/plugin-serializable.ts +55 -0
- package/src/plugins/random/random.alea.ts +109 -0
- package/src/plugins/random/random.test.ts +167 -0
- package/src/plugins/random/random.ts +198 -0
- package/src/server/api.test.ts +1699 -0
- package/src/server/api.ts +527 -0
- package/src/server/auth.test.ts +275 -0
- package/src/server/auth.ts +89 -0
- package/src/server/cors.test.ts +121 -0
- package/src/server/cors.ts +7 -0
- package/src/server/db/base.ts +296 -0
- package/src/server/db/flatfile.test.ts +221 -0
- package/src/server/db/flatfile.ts +228 -0
- package/src/server/db/index.test.ts +8 -0
- package/src/server/db/index.ts +12 -0
- package/src/server/db/inmemory.test.ts +143 -0
- package/src/server/db/inmemory.ts +143 -0
- package/src/server/db/localstorage.test.ts +73 -0
- package/src/server/db/localstorage.ts +44 -0
- package/src/server/index.test.ts +265 -0
- package/src/server/index.ts +175 -0
- package/src/server/transport/pubsub/generic-pub-sub.ts +11 -0
- package/src/server/transport/pubsub/in-memory-pub-sub.test.ts +47 -0
- package/src/server/transport/pubsub/in-memory-pub-sub.ts +28 -0
- package/src/server/transport/socketio-simultaneous.test.ts +603 -0
- package/src/server/transport/socketio.test.ts +303 -0
- package/src/server/transport/socketio.ts +279 -0
- package/src/server/util.ts +85 -0
- package/src/testing/mock-random.test.ts +45 -0
- package/src/testing/mock-random.ts +27 -0
- package/src/types.ts +511 -0
- package/testing/package.json +7 -0
|
@@ -0,0 +1,4087 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var Koa = require('koa');
|
|
6
|
+
var Router = require('@koa/router');
|
|
7
|
+
var koaBody = require('koa-body');
|
|
8
|
+
var nanoid = require('nanoid');
|
|
9
|
+
var cors = require('@koa/cors');
|
|
10
|
+
var produce = require('immer');
|
|
11
|
+
var isPlainObject = require('lodash.isplainobject');
|
|
12
|
+
var IO = require('koa-socket-2');
|
|
13
|
+
var PQueue = require('p-queue');
|
|
14
|
+
var rfc6902 = require('rfc6902');
|
|
15
|
+
var redux = require('redux');
|
|
16
|
+
|
|
17
|
+
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
18
|
+
|
|
19
|
+
var Koa__default = /*#__PURE__*/_interopDefaultLegacy(Koa);
|
|
20
|
+
var Router__default = /*#__PURE__*/_interopDefaultLegacy(Router);
|
|
21
|
+
var koaBody__default = /*#__PURE__*/_interopDefaultLegacy(koaBody);
|
|
22
|
+
var cors__default = /*#__PURE__*/_interopDefaultLegacy(cors);
|
|
23
|
+
var produce__default = /*#__PURE__*/_interopDefaultLegacy(produce);
|
|
24
|
+
var isPlainObject__default = /*#__PURE__*/_interopDefaultLegacy(isPlainObject);
|
|
25
|
+
var IO__default = /*#__PURE__*/_interopDefaultLegacy(IO);
|
|
26
|
+
var PQueue__default = /*#__PURE__*/_interopDefaultLegacy(PQueue);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Moves can return this when they want to indicate
|
|
30
|
+
* that the combination of arguments is illegal and
|
|
31
|
+
* the move ought to be discarded.
|
|
32
|
+
*/
|
|
33
|
+
const INVALID_MOVE = 'INVALID_MOVE';
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Copyright 2018 The boardgame.io Authors
|
|
37
|
+
*
|
|
38
|
+
* Use of this source code is governed by a MIT-style
|
|
39
|
+
* license that can be found in the LICENSE file or at
|
|
40
|
+
* https://opensource.org/licenses/MIT.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Plugin that allows using Immer to make immutable changes
|
|
44
|
+
* to G by just mutating it.
|
|
45
|
+
*/
|
|
46
|
+
const ImmerPlugin = {
|
|
47
|
+
name: 'plugin-immer',
|
|
48
|
+
fnWrap: (move) => (context, ...args) => {
|
|
49
|
+
let isInvalid = false;
|
|
50
|
+
const newG = produce__default["default"](context.G, (G) => {
|
|
51
|
+
const result = move({ ...context, G }, ...args);
|
|
52
|
+
if (result === INVALID_MOVE) {
|
|
53
|
+
isInvalid = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
});
|
|
58
|
+
if (isInvalid)
|
|
59
|
+
return INVALID_MOVE;
|
|
60
|
+
return newG;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Inlined version of Alea from https://github.com/davidbau/seedrandom.
|
|
65
|
+
// Converted to Typescript October 2020.
|
|
66
|
+
class Alea {
|
|
67
|
+
constructor(seed) {
|
|
68
|
+
const mash = Mash();
|
|
69
|
+
// Apply the seeding algorithm from Baagoe.
|
|
70
|
+
this.c = 1;
|
|
71
|
+
this.s0 = mash(' ');
|
|
72
|
+
this.s1 = mash(' ');
|
|
73
|
+
this.s2 = mash(' ');
|
|
74
|
+
this.s0 -= mash(seed);
|
|
75
|
+
if (this.s0 < 0) {
|
|
76
|
+
this.s0 += 1;
|
|
77
|
+
}
|
|
78
|
+
this.s1 -= mash(seed);
|
|
79
|
+
if (this.s1 < 0) {
|
|
80
|
+
this.s1 += 1;
|
|
81
|
+
}
|
|
82
|
+
this.s2 -= mash(seed);
|
|
83
|
+
if (this.s2 < 0) {
|
|
84
|
+
this.s2 += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
next() {
|
|
88
|
+
const t = 2091639 * this.s0 + this.c * 2.3283064365386963e-10; // 2^-32
|
|
89
|
+
this.s0 = this.s1;
|
|
90
|
+
this.s1 = this.s2;
|
|
91
|
+
return (this.s2 = t - (this.c = Math.trunc(t)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function Mash() {
|
|
95
|
+
let n = 0xefc8249d;
|
|
96
|
+
const mash = function (data) {
|
|
97
|
+
const str = data.toString();
|
|
98
|
+
for (let i = 0; i < str.length; i++) {
|
|
99
|
+
n += str.charCodeAt(i);
|
|
100
|
+
let h = 0.02519603282416938 * n;
|
|
101
|
+
n = h >>> 0;
|
|
102
|
+
h -= n;
|
|
103
|
+
h *= n;
|
|
104
|
+
n = h >>> 0;
|
|
105
|
+
h -= n;
|
|
106
|
+
n += h * 0x100000000; // 2^32
|
|
107
|
+
}
|
|
108
|
+
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
|
|
109
|
+
};
|
|
110
|
+
return mash;
|
|
111
|
+
}
|
|
112
|
+
function copy(f, t) {
|
|
113
|
+
t.c = f.c;
|
|
114
|
+
t.s0 = f.s0;
|
|
115
|
+
t.s1 = f.s1;
|
|
116
|
+
t.s2 = f.s2;
|
|
117
|
+
return t;
|
|
118
|
+
}
|
|
119
|
+
function alea(seed, state) {
|
|
120
|
+
const xg = new Alea(seed);
|
|
121
|
+
const prng = xg.next.bind(xg);
|
|
122
|
+
if (state)
|
|
123
|
+
copy(state, xg);
|
|
124
|
+
prng.state = () => copy(xg, {});
|
|
125
|
+
return prng;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Copyright 2017 The boardgame.io Authors
|
|
130
|
+
*
|
|
131
|
+
* Use of this source code is governed by a MIT-style
|
|
132
|
+
* license that can be found in the LICENSE file or at
|
|
133
|
+
* https://opensource.org/licenses/MIT.
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* Random
|
|
137
|
+
*
|
|
138
|
+
* Calls that require a pseudorandom number generator.
|
|
139
|
+
* Uses a seed from ctx, and also persists the PRNG
|
|
140
|
+
* state in ctx so that moves can stay pure.
|
|
141
|
+
*/
|
|
142
|
+
class Random {
|
|
143
|
+
/**
|
|
144
|
+
* constructor
|
|
145
|
+
* @param {object} ctx - The ctx object to initialize from.
|
|
146
|
+
*/
|
|
147
|
+
constructor(state) {
|
|
148
|
+
// If we are on the client, the seed is not present.
|
|
149
|
+
// Just use a temporary seed to execute the move without
|
|
150
|
+
// crashing it. The move state itself is discarded,
|
|
151
|
+
// so the actual value doesn't matter.
|
|
152
|
+
this.state = state || { seed: '0' };
|
|
153
|
+
this.used = false;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Generates a new seed from the current date / time.
|
|
157
|
+
*/
|
|
158
|
+
static seed() {
|
|
159
|
+
return Date.now().toString(36).slice(-10);
|
|
160
|
+
}
|
|
161
|
+
isUsed() {
|
|
162
|
+
return this.used;
|
|
163
|
+
}
|
|
164
|
+
getState() {
|
|
165
|
+
return this.state;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Generate a random number.
|
|
169
|
+
*/
|
|
170
|
+
_random() {
|
|
171
|
+
this.used = true;
|
|
172
|
+
const R = this.state;
|
|
173
|
+
const seed = R.prngstate ? '' : R.seed;
|
|
174
|
+
const rand = alea(seed, R.prngstate);
|
|
175
|
+
const number = rand();
|
|
176
|
+
this.state = {
|
|
177
|
+
...R,
|
|
178
|
+
prngstate: rand.state(),
|
|
179
|
+
};
|
|
180
|
+
return number;
|
|
181
|
+
}
|
|
182
|
+
api() {
|
|
183
|
+
const random = this._random.bind(this);
|
|
184
|
+
const SpotValue = {
|
|
185
|
+
D4: 4,
|
|
186
|
+
D6: 6,
|
|
187
|
+
D8: 8,
|
|
188
|
+
D10: 10,
|
|
189
|
+
D12: 12,
|
|
190
|
+
D20: 20,
|
|
191
|
+
};
|
|
192
|
+
// Generate functions for predefined dice values D4 - D20.
|
|
193
|
+
const predefined = {};
|
|
194
|
+
for (const key in SpotValue) {
|
|
195
|
+
const spotvalue = SpotValue[key];
|
|
196
|
+
predefined[key] = (diceCount) => {
|
|
197
|
+
return diceCount === undefined
|
|
198
|
+
? Math.floor(random() * spotvalue) + 1
|
|
199
|
+
: Array.from({ length: diceCount }).map(() => Math.floor(random() * spotvalue) + 1);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function Die(spotvalue = 6, diceCount) {
|
|
203
|
+
return diceCount === undefined
|
|
204
|
+
? Math.floor(random() * spotvalue) + 1
|
|
205
|
+
: Array.from({ length: diceCount }).map(() => Math.floor(random() * spotvalue) + 1);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
/**
|
|
209
|
+
* Similar to Die below, but with fixed spot values.
|
|
210
|
+
* Supports passing a diceCount
|
|
211
|
+
* if not defined, defaults to 1 and returns the value directly.
|
|
212
|
+
* if defined, returns an array containing the random dice values.
|
|
213
|
+
*
|
|
214
|
+
* D4: (diceCount) => value
|
|
215
|
+
* D6: (diceCount) => value
|
|
216
|
+
* D8: (diceCount) => value
|
|
217
|
+
* D10: (diceCount) => value
|
|
218
|
+
* D12: (diceCount) => value
|
|
219
|
+
* D20: (diceCount) => value
|
|
220
|
+
*/
|
|
221
|
+
...predefined,
|
|
222
|
+
/**
|
|
223
|
+
* Roll a die of specified spot value.
|
|
224
|
+
*
|
|
225
|
+
* @param {number} spotvalue - The die dimension (default: 6).
|
|
226
|
+
* @param {number} diceCount - number of dice to throw.
|
|
227
|
+
* if not defined, defaults to 1 and returns the value directly.
|
|
228
|
+
* if defined, returns an array containing the random dice values.
|
|
229
|
+
*/
|
|
230
|
+
Die,
|
|
231
|
+
/**
|
|
232
|
+
* Generate a random number between 0 and 1.
|
|
233
|
+
*/
|
|
234
|
+
Number: () => {
|
|
235
|
+
return random();
|
|
236
|
+
},
|
|
237
|
+
/**
|
|
238
|
+
* Shuffle an array.
|
|
239
|
+
*
|
|
240
|
+
* @param {Array} deck - The array to shuffle. Does not mutate
|
|
241
|
+
* the input, but returns the shuffled array.
|
|
242
|
+
*/
|
|
243
|
+
Shuffle: (deck) => {
|
|
244
|
+
const clone = [...deck];
|
|
245
|
+
let sourceIndex = deck.length;
|
|
246
|
+
let destinationIndex = 0;
|
|
247
|
+
const shuffled = Array.from({ length: sourceIndex });
|
|
248
|
+
while (sourceIndex) {
|
|
249
|
+
const randomIndex = Math.trunc(sourceIndex * random());
|
|
250
|
+
shuffled[destinationIndex++] = clone[randomIndex];
|
|
251
|
+
clone[randomIndex] = clone[--sourceIndex];
|
|
252
|
+
}
|
|
253
|
+
return shuffled;
|
|
254
|
+
},
|
|
255
|
+
_private: this,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/*
|
|
261
|
+
* Copyright 2018 The boardgame.io Authors
|
|
262
|
+
*
|
|
263
|
+
* Use of this source code is governed by a MIT-style
|
|
264
|
+
* license that can be found in the LICENSE file or at
|
|
265
|
+
* https://opensource.org/licenses/MIT.
|
|
266
|
+
*/
|
|
267
|
+
const RandomPlugin = {
|
|
268
|
+
name: 'random',
|
|
269
|
+
noClient: ({ api }) => {
|
|
270
|
+
return api._private.isUsed();
|
|
271
|
+
},
|
|
272
|
+
flush: ({ api }) => {
|
|
273
|
+
return api._private.getState();
|
|
274
|
+
},
|
|
275
|
+
api: ({ data }) => {
|
|
276
|
+
const random = new Random(data);
|
|
277
|
+
return random.api();
|
|
278
|
+
},
|
|
279
|
+
setup: ({ game }) => {
|
|
280
|
+
let { seed } = game;
|
|
281
|
+
if (seed === undefined) {
|
|
282
|
+
seed = Random.seed();
|
|
283
|
+
}
|
|
284
|
+
return { seed };
|
|
285
|
+
},
|
|
286
|
+
playerView: () => undefined,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/*
|
|
290
|
+
* Copyright 2017 The boardgame.io Authors
|
|
291
|
+
*
|
|
292
|
+
* Use of this source code is governed by a MIT-style
|
|
293
|
+
* license that can be found in the LICENSE file or at
|
|
294
|
+
* https://opensource.org/licenses/MIT.
|
|
295
|
+
*/
|
|
296
|
+
const MAKE_MOVE = 'MAKE_MOVE';
|
|
297
|
+
const GAME_EVENT = 'GAME_EVENT';
|
|
298
|
+
const REDO = 'REDO';
|
|
299
|
+
const RESET = 'RESET';
|
|
300
|
+
const SYNC = 'SYNC';
|
|
301
|
+
const UNDO = 'UNDO';
|
|
302
|
+
const UPDATE = 'UPDATE';
|
|
303
|
+
const PATCH = 'PATCH';
|
|
304
|
+
const PLUGIN = 'PLUGIN';
|
|
305
|
+
const STRIP_TRANSIENTS = 'STRIP_TRANSIENTS';
|
|
306
|
+
|
|
307
|
+
/*
|
|
308
|
+
* Copyright 2017 The boardgame.io Authors
|
|
309
|
+
*
|
|
310
|
+
* Use of this source code is governed by a MIT-style
|
|
311
|
+
* license that can be found in the LICENSE file or at
|
|
312
|
+
* https://opensource.org/licenses/MIT.
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* Generate a game event to be dispatched to the flow reducer.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} type - The event type.
|
|
318
|
+
* @param {Array} args - Additional arguments.
|
|
319
|
+
* @param {string} playerID - The ID of the player making this action.
|
|
320
|
+
* @param {string} credentials - (optional) The credentials for the player making this action.
|
|
321
|
+
*/
|
|
322
|
+
const gameEvent = (type, args, playerID, credentials) => ({
|
|
323
|
+
type: GAME_EVENT,
|
|
324
|
+
payload: { type, args, playerID, credentials },
|
|
325
|
+
});
|
|
326
|
+
/**
|
|
327
|
+
* Generate an automatic game event that is a side-effect of a move.
|
|
328
|
+
* @param {string} type - The event type.
|
|
329
|
+
* @param {Array} args - Additional arguments.
|
|
330
|
+
* @param {string} playerID - The ID of the player making this action.
|
|
331
|
+
* @param {string} credentials - (optional) The credentials for the player making this action.
|
|
332
|
+
*/
|
|
333
|
+
const automaticGameEvent = (type, args, playerID, credentials) => ({
|
|
334
|
+
type: GAME_EVENT,
|
|
335
|
+
payload: { type, args, playerID, credentials },
|
|
336
|
+
automatic: true,
|
|
337
|
+
});
|
|
338
|
+
/**
|
|
339
|
+
* Private action used to strip transient metadata (e.g. errors) from the game
|
|
340
|
+
* state.
|
|
341
|
+
*/
|
|
342
|
+
const stripTransients = () => ({
|
|
343
|
+
type: STRIP_TRANSIENTS,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
var GameMethod;
|
|
347
|
+
(function (GameMethod) {
|
|
348
|
+
GameMethod["MOVE"] = "MOVE";
|
|
349
|
+
GameMethod["GAME_ON_END"] = "GAME_ON_END";
|
|
350
|
+
GameMethod["PHASE_ON_BEGIN"] = "PHASE_ON_BEGIN";
|
|
351
|
+
GameMethod["PHASE_ON_END"] = "PHASE_ON_END";
|
|
352
|
+
GameMethod["TURN_ON_BEGIN"] = "TURN_ON_BEGIN";
|
|
353
|
+
GameMethod["TURN_ON_MOVE"] = "TURN_ON_MOVE";
|
|
354
|
+
GameMethod["TURN_ON_END"] = "TURN_ON_END";
|
|
355
|
+
})(GameMethod || (GameMethod = {}));
|
|
356
|
+
|
|
357
|
+
/*
|
|
358
|
+
* Copyright 2018 The boardgame.io Authors
|
|
359
|
+
*
|
|
360
|
+
* Use of this source code is governed by a MIT-style
|
|
361
|
+
* license that can be found in the LICENSE file or at
|
|
362
|
+
* https://opensource.org/licenses/MIT.
|
|
363
|
+
*/
|
|
364
|
+
var Errors;
|
|
365
|
+
(function (Errors) {
|
|
366
|
+
Errors["CalledOutsideHook"] = "Events must be called from moves or the `onBegin`, `onEnd`, and `onMove` hooks.\nThis error probably means you called an event from other game code, like an `endIf` trigger or one of the `turn.order` methods.";
|
|
367
|
+
Errors["EndTurnInOnEnd"] = "`endTurn` is disallowed in `onEnd` hooks \u2014 the turn is already ending.";
|
|
368
|
+
Errors["MaxTurnEndings"] = "Maximum number of turn endings exceeded for this update.\nThis likely means game code is triggering an infinite loop.";
|
|
369
|
+
Errors["PhaseEventInOnEnd"] = "`setPhase` & `endPhase` are disallowed in a phase\u2019s `onEnd` hook \u2014 the phase is already ending.\nIf you\u2019re trying to dynamically choose the next phase when a phase ends, use the phase\u2019s `next` trigger.";
|
|
370
|
+
Errors["StageEventInOnEnd"] = "`setStage`, `endStage` & `setActivePlayers` are disallowed in `onEnd` hooks.";
|
|
371
|
+
Errors["StageEventInPhaseBegin"] = "`setStage`, `endStage` & `setActivePlayers` are disallowed in a phase\u2019s `onBegin` hook.\nUse `setActivePlayers` in a `turn.onBegin` hook or declare stages with `turn.activePlayers` instead.";
|
|
372
|
+
Errors["StageEventInTurnBegin"] = "`setStage` & `endStage` are disallowed in `turn.onBegin`.\nUse `setActivePlayers` or declare stages with `turn.activePlayers` instead.";
|
|
373
|
+
})(Errors || (Errors = {}));
|
|
374
|
+
/**
|
|
375
|
+
* Events
|
|
376
|
+
*/
|
|
377
|
+
class Events {
|
|
378
|
+
constructor(flow, ctx, playerID) {
|
|
379
|
+
this.flow = flow;
|
|
380
|
+
this.playerID = playerID;
|
|
381
|
+
this.dispatch = [];
|
|
382
|
+
this.initialTurn = ctx.turn;
|
|
383
|
+
this.updateTurnContext(ctx, undefined);
|
|
384
|
+
// This is an arbitrarily large upper threshold, which could be made
|
|
385
|
+
// configurable via a game option if the need arises.
|
|
386
|
+
this.maxEndedTurnsPerAction = ctx.numPlayers * 100;
|
|
387
|
+
}
|
|
388
|
+
api() {
|
|
389
|
+
const events = {
|
|
390
|
+
_private: this,
|
|
391
|
+
};
|
|
392
|
+
for (const type of this.flow.eventNames) {
|
|
393
|
+
events[type] = (...args) => {
|
|
394
|
+
this.dispatch.push({
|
|
395
|
+
type,
|
|
396
|
+
args,
|
|
397
|
+
phase: this.currentPhase,
|
|
398
|
+
turn: this.currentTurn,
|
|
399
|
+
calledFrom: this.currentMethod,
|
|
400
|
+
// Used to capture a stack trace in case it is needed later.
|
|
401
|
+
error: new Error('Events Plugin Error'),
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return events;
|
|
406
|
+
}
|
|
407
|
+
isUsed() {
|
|
408
|
+
return this.dispatch.length > 0;
|
|
409
|
+
}
|
|
410
|
+
updateTurnContext(ctx, methodType) {
|
|
411
|
+
this.currentPhase = ctx.phase;
|
|
412
|
+
this.currentTurn = ctx.turn;
|
|
413
|
+
this.currentMethod = methodType;
|
|
414
|
+
}
|
|
415
|
+
unsetCurrentMethod() {
|
|
416
|
+
this.currentMethod = undefined;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Updates ctx with the triggered events.
|
|
420
|
+
* @param {object} state - The state object { G, ctx }.
|
|
421
|
+
*/
|
|
422
|
+
update(state) {
|
|
423
|
+
const initialState = state;
|
|
424
|
+
const stateWithError = ({ stack }, message) => ({
|
|
425
|
+
...initialState,
|
|
426
|
+
plugins: {
|
|
427
|
+
...initialState.plugins,
|
|
428
|
+
events: {
|
|
429
|
+
...initialState.plugins.events,
|
|
430
|
+
data: { error: message + '\n' + stack },
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
EventQueue: for (let i = 0; i < this.dispatch.length; i++) {
|
|
435
|
+
const event = this.dispatch[i];
|
|
436
|
+
const turnHasEnded = event.turn !== state.ctx.turn;
|
|
437
|
+
// This protects against potential infinite loops if specific events are called on hooks.
|
|
438
|
+
// The moment we exceed the defined threshold, we just bail out of all phases.
|
|
439
|
+
const endedTurns = this.currentTurn - this.initialTurn;
|
|
440
|
+
if (endedTurns >= this.maxEndedTurnsPerAction) {
|
|
441
|
+
return stateWithError(event.error, Errors.MaxTurnEndings);
|
|
442
|
+
}
|
|
443
|
+
if (event.calledFrom === undefined) {
|
|
444
|
+
return stateWithError(event.error, Errors.CalledOutsideHook);
|
|
445
|
+
}
|
|
446
|
+
// Stop processing events once the game has finished.
|
|
447
|
+
if (state.ctx.gameover)
|
|
448
|
+
break EventQueue;
|
|
449
|
+
switch (event.type) {
|
|
450
|
+
case 'endStage':
|
|
451
|
+
case 'setStage':
|
|
452
|
+
case 'setActivePlayers': {
|
|
453
|
+
switch (event.calledFrom) {
|
|
454
|
+
// Disallow all stage events in onEnd and phase.onBegin hooks.
|
|
455
|
+
case GameMethod.TURN_ON_END:
|
|
456
|
+
case GameMethod.PHASE_ON_END:
|
|
457
|
+
return stateWithError(event.error, Errors.StageEventInOnEnd);
|
|
458
|
+
case GameMethod.PHASE_ON_BEGIN:
|
|
459
|
+
return stateWithError(event.error, Errors.StageEventInPhaseBegin);
|
|
460
|
+
// Disallow setStage & endStage in turn.onBegin hooks.
|
|
461
|
+
case GameMethod.TURN_ON_BEGIN:
|
|
462
|
+
if (event.type === 'setActivePlayers')
|
|
463
|
+
break;
|
|
464
|
+
return stateWithError(event.error, Errors.StageEventInTurnBegin);
|
|
465
|
+
}
|
|
466
|
+
// If the turn already ended, don't try to process stage events.
|
|
467
|
+
if (turnHasEnded)
|
|
468
|
+
continue EventQueue;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case 'endTurn': {
|
|
472
|
+
if (event.calledFrom === GameMethod.TURN_ON_END ||
|
|
473
|
+
event.calledFrom === GameMethod.PHASE_ON_END) {
|
|
474
|
+
return stateWithError(event.error, Errors.EndTurnInOnEnd);
|
|
475
|
+
}
|
|
476
|
+
// If the turn already ended some other way,
|
|
477
|
+
// don't try to end the turn again.
|
|
478
|
+
if (turnHasEnded)
|
|
479
|
+
continue EventQueue;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
case 'endPhase':
|
|
483
|
+
case 'setPhase': {
|
|
484
|
+
if (event.calledFrom === GameMethod.PHASE_ON_END) {
|
|
485
|
+
return stateWithError(event.error, Errors.PhaseEventInOnEnd);
|
|
486
|
+
}
|
|
487
|
+
// If the phase already ended some other way,
|
|
488
|
+
// don't try to end the phase again.
|
|
489
|
+
if (event.phase !== state.ctx.phase)
|
|
490
|
+
continue EventQueue;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const action = automaticGameEvent(event.type, event.args, this.playerID);
|
|
495
|
+
state = this.flow.processEvent(state, action);
|
|
496
|
+
}
|
|
497
|
+
return state;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/*
|
|
502
|
+
* Copyright 2020 The boardgame.io Authors
|
|
503
|
+
*
|
|
504
|
+
* Use of this source code is governed by a MIT-style
|
|
505
|
+
* license that can be found in the LICENSE file or at
|
|
506
|
+
* https://opensource.org/licenses/MIT.
|
|
507
|
+
*/
|
|
508
|
+
const EventsPlugin = {
|
|
509
|
+
name: 'events',
|
|
510
|
+
noClient: ({ api }) => api._private.isUsed(),
|
|
511
|
+
isInvalid: ({ data }) => data.error || false,
|
|
512
|
+
// Update the events plugin’s internal turn context each time a move
|
|
513
|
+
// or hook is called. This allows events called after turn or phase
|
|
514
|
+
// endings to dispatch the current turn and phase correctly.
|
|
515
|
+
fnWrap: (method, methodType) => (context, ...args) => {
|
|
516
|
+
const api = context.events;
|
|
517
|
+
if (api)
|
|
518
|
+
api._private.updateTurnContext(context.ctx, methodType);
|
|
519
|
+
const G = method(context, ...args);
|
|
520
|
+
if (api)
|
|
521
|
+
api._private.unsetCurrentMethod();
|
|
522
|
+
return G;
|
|
523
|
+
},
|
|
524
|
+
dangerouslyFlushRawState: ({ state, api }) => api._private.update(state),
|
|
525
|
+
api: ({ game, ctx, playerID }) => new Events(game.flow, ctx, playerID).api(),
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/*
|
|
529
|
+
* Copyright 2018 The boardgame.io Authors
|
|
530
|
+
*
|
|
531
|
+
* Use of this source code is governed by a MIT-style
|
|
532
|
+
* license that can be found in the LICENSE file or at
|
|
533
|
+
* https://opensource.org/licenses/MIT.
|
|
534
|
+
*/
|
|
535
|
+
/**
|
|
536
|
+
* Plugin that makes it possible to add metadata to log entries.
|
|
537
|
+
* During a move, you can set metadata using ctx.log.setMetadata and it will be
|
|
538
|
+
* available on the log entry for that move.
|
|
539
|
+
*/
|
|
540
|
+
const LogPlugin = {
|
|
541
|
+
name: 'log',
|
|
542
|
+
flush: () => ({}),
|
|
543
|
+
api: ({ data }) => {
|
|
544
|
+
return {
|
|
545
|
+
setMetadata: (metadata) => {
|
|
546
|
+
data.metadata = metadata;
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
setup: () => ({}),
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Check if a value can be serialized (e.g. using `JSON.stringify`).
|
|
555
|
+
* Adapted from: https://stackoverflow.com/a/30712764/3829557
|
|
556
|
+
*/
|
|
557
|
+
function isSerializable(value) {
|
|
558
|
+
// Primitives are OK.
|
|
559
|
+
if (value === undefined ||
|
|
560
|
+
value === null ||
|
|
561
|
+
typeof value === 'boolean' ||
|
|
562
|
+
typeof value === 'number' ||
|
|
563
|
+
typeof value === 'string') {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
// A non-primitive value that is neither a POJO or an array cannot be serialized.
|
|
567
|
+
if (!isPlainObject__default["default"](value) && !Array.isArray(value)) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
// Recurse entries if the value is an object or array.
|
|
571
|
+
for (const key in value) {
|
|
572
|
+
if (!isSerializable(value[key]))
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Plugin that checks whether state is serializable, in order to avoid
|
|
579
|
+
* network serialization bugs.
|
|
580
|
+
*/
|
|
581
|
+
const SerializablePlugin = {
|
|
582
|
+
name: 'plugin-serializable',
|
|
583
|
+
fnWrap: (move) => (context, ...args) => {
|
|
584
|
+
const result = move(context, ...args);
|
|
585
|
+
// Check state in non-production environments.
|
|
586
|
+
if (process.env.NODE_ENV !== 'production' && !isSerializable(result)) {
|
|
587
|
+
throw new Error('Move state is not JSON-serialiazable.\n' +
|
|
588
|
+
'See https://boardgame.io/documentation/#/?id=state for more information.');
|
|
589
|
+
}
|
|
590
|
+
return result;
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/*
|
|
595
|
+
* Copyright 2018 The boardgame.io Authors
|
|
596
|
+
*
|
|
597
|
+
* Use of this source code is governed by a MIT-style
|
|
598
|
+
* license that can be found in the LICENSE file or at
|
|
599
|
+
* https://opensource.org/licenses/MIT.
|
|
600
|
+
*/
|
|
601
|
+
const production = process.env.NODE_ENV === 'production';
|
|
602
|
+
const logfn = production ? () => { } : (...msg) => console.log(...msg);
|
|
603
|
+
const errorfn = (...msg) => console.error(...msg);
|
|
604
|
+
function info(msg) {
|
|
605
|
+
logfn(`INFO: ${msg}`);
|
|
606
|
+
}
|
|
607
|
+
function error(error) {
|
|
608
|
+
errorfn('ERROR:', error);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/*
|
|
612
|
+
* Copyright 2018 The boardgame.io Authors
|
|
613
|
+
*
|
|
614
|
+
* Use of this source code is governed by a MIT-style
|
|
615
|
+
* license that can be found in the LICENSE file or at
|
|
616
|
+
* https://opensource.org/licenses/MIT.
|
|
617
|
+
*/
|
|
618
|
+
/**
|
|
619
|
+
* List of plugins that are always added.
|
|
620
|
+
*/
|
|
621
|
+
const CORE_PLUGINS = [ImmerPlugin, RandomPlugin, LogPlugin, SerializablePlugin];
|
|
622
|
+
const DEFAULT_PLUGINS = [...CORE_PLUGINS, EventsPlugin];
|
|
623
|
+
/**
|
|
624
|
+
* Allow plugins to intercept actions and process them.
|
|
625
|
+
*/
|
|
626
|
+
const ProcessAction = (state, action, opts) => {
|
|
627
|
+
// TODO(#723): Extend error handling to plugins.
|
|
628
|
+
opts.game.plugins
|
|
629
|
+
.filter((plugin) => plugin.action !== undefined)
|
|
630
|
+
.filter((plugin) => plugin.name === action.payload.type)
|
|
631
|
+
.forEach((plugin) => {
|
|
632
|
+
const name = plugin.name;
|
|
633
|
+
const pluginState = state.plugins[name] || { data: {} };
|
|
634
|
+
const data = plugin.action(pluginState.data, action.payload);
|
|
635
|
+
state = {
|
|
636
|
+
...state,
|
|
637
|
+
plugins: {
|
|
638
|
+
...state.plugins,
|
|
639
|
+
[name]: { ...pluginState, data },
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
});
|
|
643
|
+
return state;
|
|
644
|
+
};
|
|
645
|
+
/**
|
|
646
|
+
* The APIs created by various plugins are stored in the plugins
|
|
647
|
+
* section of the state object:
|
|
648
|
+
*
|
|
649
|
+
* {
|
|
650
|
+
* G: {},
|
|
651
|
+
* ctx: {},
|
|
652
|
+
* plugins: {
|
|
653
|
+
* plugin-a: {
|
|
654
|
+
* data: {}, // this is generated by the plugin at Setup / Flush.
|
|
655
|
+
* api: {}, // this is ephemeral and generated by Enhance.
|
|
656
|
+
* }
|
|
657
|
+
* }
|
|
658
|
+
* }
|
|
659
|
+
*
|
|
660
|
+
* This function retrieves plugin APIs and returns them as an object
|
|
661
|
+
* for consumption as used by move contexts.
|
|
662
|
+
*/
|
|
663
|
+
const GetAPIs = ({ plugins }) => Object.entries(plugins || {}).reduce((apis, [name, { api }]) => {
|
|
664
|
+
apis[name] = api;
|
|
665
|
+
return apis;
|
|
666
|
+
}, {});
|
|
667
|
+
/**
|
|
668
|
+
* Applies the provided plugins to the given move / flow function.
|
|
669
|
+
*
|
|
670
|
+
* @param methodToWrap - The move function or hook to apply the plugins to.
|
|
671
|
+
* @param methodType - The type of the move or hook being wrapped.
|
|
672
|
+
* @param plugins - The list of plugins.
|
|
673
|
+
*/
|
|
674
|
+
const FnWrap = (methodToWrap, methodType, plugins) => {
|
|
675
|
+
return [...CORE_PLUGINS, ...plugins, EventsPlugin]
|
|
676
|
+
.filter((plugin) => plugin.fnWrap !== undefined)
|
|
677
|
+
.reduce((method, { fnWrap }) => fnWrap(method, methodType), methodToWrap);
|
|
678
|
+
};
|
|
679
|
+
/**
|
|
680
|
+
* Allows the plugin to generate its initial state.
|
|
681
|
+
*/
|
|
682
|
+
const Setup = (state, opts) => {
|
|
683
|
+
[...DEFAULT_PLUGINS, ...opts.game.plugins]
|
|
684
|
+
.filter((plugin) => plugin.setup !== undefined)
|
|
685
|
+
.forEach((plugin) => {
|
|
686
|
+
const name = plugin.name;
|
|
687
|
+
const data = plugin.setup({
|
|
688
|
+
G: state.G,
|
|
689
|
+
ctx: state.ctx,
|
|
690
|
+
game: opts.game,
|
|
691
|
+
});
|
|
692
|
+
state = {
|
|
693
|
+
...state,
|
|
694
|
+
plugins: {
|
|
695
|
+
...state.plugins,
|
|
696
|
+
[name]: { data },
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
});
|
|
700
|
+
return state;
|
|
701
|
+
};
|
|
702
|
+
/**
|
|
703
|
+
* Invokes the plugin before a move or event.
|
|
704
|
+
* The API that the plugin generates is stored inside
|
|
705
|
+
* the `plugins` section of the state (which is subsequently
|
|
706
|
+
* merged into ctx).
|
|
707
|
+
*/
|
|
708
|
+
const Enhance = (state, opts) => {
|
|
709
|
+
[...DEFAULT_PLUGINS, ...opts.game.plugins]
|
|
710
|
+
.filter((plugin) => plugin.api !== undefined)
|
|
711
|
+
.forEach((plugin) => {
|
|
712
|
+
const name = plugin.name;
|
|
713
|
+
const pluginState = state.plugins[name] || { data: {} };
|
|
714
|
+
const api = plugin.api({
|
|
715
|
+
G: state.G,
|
|
716
|
+
ctx: state.ctx,
|
|
717
|
+
data: pluginState.data,
|
|
718
|
+
game: opts.game,
|
|
719
|
+
playerID: opts.playerID,
|
|
720
|
+
});
|
|
721
|
+
state = {
|
|
722
|
+
...state,
|
|
723
|
+
plugins: {
|
|
724
|
+
...state.plugins,
|
|
725
|
+
[name]: { ...pluginState, api },
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
});
|
|
729
|
+
return state;
|
|
730
|
+
};
|
|
731
|
+
/**
|
|
732
|
+
* Allows plugins to update their state after a move / event.
|
|
733
|
+
*/
|
|
734
|
+
const Flush = (state, opts) => {
|
|
735
|
+
// We flush the events plugin first, then custom plugins and the core plugins.
|
|
736
|
+
// This means custom plugins cannot use the events API but will be available in event hooks.
|
|
737
|
+
// Note that plugins are flushed in reverse, to allow custom plugins calling each other.
|
|
738
|
+
[...CORE_PLUGINS, ...opts.game.plugins, EventsPlugin]
|
|
739
|
+
.reverse()
|
|
740
|
+
.forEach((plugin) => {
|
|
741
|
+
const name = plugin.name;
|
|
742
|
+
const pluginState = state.plugins[name] || { data: {} };
|
|
743
|
+
if (plugin.flush) {
|
|
744
|
+
const newData = plugin.flush({
|
|
745
|
+
G: state.G,
|
|
746
|
+
ctx: state.ctx,
|
|
747
|
+
game: opts.game,
|
|
748
|
+
api: pluginState.api,
|
|
749
|
+
data: pluginState.data,
|
|
750
|
+
});
|
|
751
|
+
state = {
|
|
752
|
+
...state,
|
|
753
|
+
plugins: {
|
|
754
|
+
...state.plugins,
|
|
755
|
+
[plugin.name]: { data: newData },
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
else if (plugin.dangerouslyFlushRawState) {
|
|
760
|
+
state = plugin.dangerouslyFlushRawState({
|
|
761
|
+
state,
|
|
762
|
+
game: opts.game,
|
|
763
|
+
api: pluginState.api,
|
|
764
|
+
data: pluginState.data,
|
|
765
|
+
});
|
|
766
|
+
// Remove everything other than data.
|
|
767
|
+
const data = state.plugins[name].data;
|
|
768
|
+
state = {
|
|
769
|
+
...state,
|
|
770
|
+
plugins: {
|
|
771
|
+
...state.plugins,
|
|
772
|
+
[plugin.name]: { data },
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
return state;
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* Allows plugins to indicate if they should not be materialized on the client.
|
|
781
|
+
* This will cause the client to discard the state update and wait for the
|
|
782
|
+
* master instead.
|
|
783
|
+
*/
|
|
784
|
+
const NoClient = (state, opts) => {
|
|
785
|
+
return [...DEFAULT_PLUGINS, ...opts.game.plugins]
|
|
786
|
+
.filter((plugin) => plugin.noClient !== undefined)
|
|
787
|
+
.map((plugin) => {
|
|
788
|
+
const name = plugin.name;
|
|
789
|
+
const pluginState = state.plugins[name];
|
|
790
|
+
if (pluginState) {
|
|
791
|
+
return plugin.noClient({
|
|
792
|
+
G: state.G,
|
|
793
|
+
ctx: state.ctx,
|
|
794
|
+
game: opts.game,
|
|
795
|
+
api: pluginState.api,
|
|
796
|
+
data: pluginState.data,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return false;
|
|
800
|
+
})
|
|
801
|
+
.includes(true);
|
|
802
|
+
};
|
|
803
|
+
/**
|
|
804
|
+
* Allows plugins to indicate if the entire action should be thrown out
|
|
805
|
+
* as invalid. This will cancel the entire state update.
|
|
806
|
+
*/
|
|
807
|
+
const IsInvalid = (state, opts) => {
|
|
808
|
+
const firstInvalidReturn = [...DEFAULT_PLUGINS, ...opts.game.plugins]
|
|
809
|
+
.filter((plugin) => plugin.isInvalid !== undefined)
|
|
810
|
+
.map((plugin) => {
|
|
811
|
+
const { name } = plugin;
|
|
812
|
+
const pluginState = state.plugins[name];
|
|
813
|
+
const message = plugin.isInvalid({
|
|
814
|
+
G: state.G,
|
|
815
|
+
ctx: state.ctx,
|
|
816
|
+
game: opts.game,
|
|
817
|
+
data: pluginState && pluginState.data,
|
|
818
|
+
});
|
|
819
|
+
return message ? { plugin: name, message } : false;
|
|
820
|
+
})
|
|
821
|
+
.find((value) => value);
|
|
822
|
+
return firstInvalidReturn || false;
|
|
823
|
+
};
|
|
824
|
+
/**
|
|
825
|
+
* Update plugin state after move/event & check if plugins consider the update to be valid.
|
|
826
|
+
* @returns Tuple of `[updatedState]` or `[originalState, invalidError]`.
|
|
827
|
+
*/
|
|
828
|
+
const FlushAndValidate = (state, opts) => {
|
|
829
|
+
const updatedState = Flush(state, opts);
|
|
830
|
+
const isInvalid = IsInvalid(updatedState, opts);
|
|
831
|
+
if (!isInvalid)
|
|
832
|
+
return [updatedState];
|
|
833
|
+
const { plugin, message } = isInvalid;
|
|
834
|
+
error(`${plugin} plugin declared action invalid:\n${message}`);
|
|
835
|
+
return [state, isInvalid];
|
|
836
|
+
};
|
|
837
|
+
/**
|
|
838
|
+
* Allows plugins to customize their data for specific players.
|
|
839
|
+
* For example, a plugin may want to share no data with the client, or
|
|
840
|
+
* want to keep some player data secret from opponents.
|
|
841
|
+
*/
|
|
842
|
+
const PlayerView = ({ G, ctx, plugins = {} }, { game, playerID }) => {
|
|
843
|
+
[...DEFAULT_PLUGINS, ...game.plugins].forEach(({ name, playerView }) => {
|
|
844
|
+
if (!playerView)
|
|
845
|
+
return;
|
|
846
|
+
const { data } = plugins[name] || { data: {} };
|
|
847
|
+
const newData = playerView({ G, ctx, game, data, playerID });
|
|
848
|
+
plugins = {
|
|
849
|
+
...plugins,
|
|
850
|
+
[name]: { data: newData },
|
|
851
|
+
};
|
|
852
|
+
});
|
|
853
|
+
return plugins;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Adjust the given options to use the new minMoves/maxMoves if a legacy moveLimit was given
|
|
858
|
+
* @param options The options object to apply backwards compatibility to
|
|
859
|
+
* @param enforceMinMoves Use moveLimit to set both minMoves and maxMoves
|
|
860
|
+
*/
|
|
861
|
+
function supportDeprecatedMoveLimit(options, enforceMinMoves = false) {
|
|
862
|
+
if (options.moveLimit) {
|
|
863
|
+
if (enforceMinMoves) {
|
|
864
|
+
options.minMoves = options.moveLimit;
|
|
865
|
+
}
|
|
866
|
+
options.maxMoves = options.moveLimit;
|
|
867
|
+
delete options.moveLimit;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/*
|
|
872
|
+
* Copyright 2017 The boardgame.io Authors
|
|
873
|
+
*
|
|
874
|
+
* Use of this source code is governed by a MIT-style
|
|
875
|
+
* license that can be found in the LICENSE file or at
|
|
876
|
+
* https://opensource.org/licenses/MIT.
|
|
877
|
+
*/
|
|
878
|
+
function SetActivePlayers(ctx, arg) {
|
|
879
|
+
let activePlayers = {};
|
|
880
|
+
let _prevActivePlayers = [];
|
|
881
|
+
let _nextActivePlayers = null;
|
|
882
|
+
let _activePlayersMinMoves = {};
|
|
883
|
+
let _activePlayersMaxMoves = {};
|
|
884
|
+
if (Array.isArray(arg)) {
|
|
885
|
+
// support a simple array of player IDs as active players
|
|
886
|
+
const value = {};
|
|
887
|
+
arg.forEach((v) => (value[v] = Stage.NULL));
|
|
888
|
+
activePlayers = value;
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
// process active players argument object
|
|
892
|
+
// stages previously did not enforce minMoves, this behaviour is kept intentionally
|
|
893
|
+
supportDeprecatedMoveLimit(arg);
|
|
894
|
+
if (arg.next) {
|
|
895
|
+
_nextActivePlayers = arg.next;
|
|
896
|
+
}
|
|
897
|
+
if (arg.revert) {
|
|
898
|
+
_prevActivePlayers = [
|
|
899
|
+
...ctx._prevActivePlayers,
|
|
900
|
+
{
|
|
901
|
+
activePlayers: ctx.activePlayers,
|
|
902
|
+
_activePlayersMinMoves: ctx._activePlayersMinMoves,
|
|
903
|
+
_activePlayersMaxMoves: ctx._activePlayersMaxMoves,
|
|
904
|
+
_activePlayersNumMoves: ctx._activePlayersNumMoves,
|
|
905
|
+
},
|
|
906
|
+
];
|
|
907
|
+
}
|
|
908
|
+
if (arg.currentPlayer !== undefined) {
|
|
909
|
+
ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, ctx.currentPlayer, arg.currentPlayer);
|
|
910
|
+
}
|
|
911
|
+
if (arg.others !== undefined) {
|
|
912
|
+
for (let i = 0; i < ctx.playOrder.length; i++) {
|
|
913
|
+
const id = ctx.playOrder[i];
|
|
914
|
+
if (id !== ctx.currentPlayer) {
|
|
915
|
+
ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.others);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (arg.all !== undefined) {
|
|
920
|
+
for (let i = 0; i < ctx.playOrder.length; i++) {
|
|
921
|
+
const id = ctx.playOrder[i];
|
|
922
|
+
ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.all);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (arg.value) {
|
|
926
|
+
for (const id in arg.value) {
|
|
927
|
+
ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.value[id]);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (arg.minMoves) {
|
|
931
|
+
for (const id in activePlayers) {
|
|
932
|
+
if (_activePlayersMinMoves[id] === undefined) {
|
|
933
|
+
_activePlayersMinMoves[id] = arg.minMoves;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (arg.maxMoves) {
|
|
938
|
+
for (const id in activePlayers) {
|
|
939
|
+
if (_activePlayersMaxMoves[id] === undefined) {
|
|
940
|
+
_activePlayersMaxMoves[id] = arg.maxMoves;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (Object.keys(activePlayers).length === 0) {
|
|
946
|
+
activePlayers = null;
|
|
947
|
+
}
|
|
948
|
+
if (Object.keys(_activePlayersMinMoves).length === 0) {
|
|
949
|
+
_activePlayersMinMoves = null;
|
|
950
|
+
}
|
|
951
|
+
if (Object.keys(_activePlayersMaxMoves).length === 0) {
|
|
952
|
+
_activePlayersMaxMoves = null;
|
|
953
|
+
}
|
|
954
|
+
const _activePlayersNumMoves = {};
|
|
955
|
+
for (const id in activePlayers) {
|
|
956
|
+
_activePlayersNumMoves[id] = 0;
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
...ctx,
|
|
960
|
+
activePlayers,
|
|
961
|
+
_activePlayersMinMoves,
|
|
962
|
+
_activePlayersMaxMoves,
|
|
963
|
+
_activePlayersNumMoves,
|
|
964
|
+
_prevActivePlayers,
|
|
965
|
+
_nextActivePlayers,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Update activePlayers, setting it to previous, next or null values
|
|
970
|
+
* when it becomes empty.
|
|
971
|
+
* @param ctx
|
|
972
|
+
*/
|
|
973
|
+
function UpdateActivePlayersOnceEmpty(ctx) {
|
|
974
|
+
let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, } = ctx;
|
|
975
|
+
if (activePlayers && Object.keys(activePlayers).length === 0) {
|
|
976
|
+
if (_nextActivePlayers) {
|
|
977
|
+
ctx = SetActivePlayers(ctx, _nextActivePlayers);
|
|
978
|
+
({
|
|
979
|
+
activePlayers,
|
|
980
|
+
_activePlayersMinMoves,
|
|
981
|
+
_activePlayersMaxMoves,
|
|
982
|
+
_activePlayersNumMoves,
|
|
983
|
+
_prevActivePlayers,
|
|
984
|
+
} = ctx);
|
|
985
|
+
}
|
|
986
|
+
else if (_prevActivePlayers.length > 0) {
|
|
987
|
+
const lastIndex = _prevActivePlayers.length - 1;
|
|
988
|
+
({
|
|
989
|
+
activePlayers,
|
|
990
|
+
_activePlayersMinMoves,
|
|
991
|
+
_activePlayersMaxMoves,
|
|
992
|
+
_activePlayersNumMoves,
|
|
993
|
+
} = _prevActivePlayers[lastIndex]);
|
|
994
|
+
_prevActivePlayers = _prevActivePlayers.slice(0, lastIndex);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
activePlayers = null;
|
|
998
|
+
_activePlayersMinMoves = null;
|
|
999
|
+
_activePlayersMaxMoves = null;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
...ctx,
|
|
1004
|
+
activePlayers,
|
|
1005
|
+
_activePlayersMinMoves,
|
|
1006
|
+
_activePlayersMaxMoves,
|
|
1007
|
+
_activePlayersNumMoves,
|
|
1008
|
+
_prevActivePlayers,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Apply an active player argument to the given player ID
|
|
1013
|
+
* @param {Object} activePlayers
|
|
1014
|
+
* @param {Object} _activePlayersMinMoves
|
|
1015
|
+
* @param {Object} _activePlayersMaxMoves
|
|
1016
|
+
* @param {String} playerID The player to apply the parameter to
|
|
1017
|
+
* @param {(String|Object)} arg An active player argument
|
|
1018
|
+
*/
|
|
1019
|
+
function ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, playerID, arg) {
|
|
1020
|
+
if (typeof arg !== 'object' || arg === Stage.NULL) {
|
|
1021
|
+
arg = { stage: arg };
|
|
1022
|
+
}
|
|
1023
|
+
if (arg.stage !== undefined) {
|
|
1024
|
+
// stages previously did not enforce minMoves, this behaviour is kept intentionally
|
|
1025
|
+
supportDeprecatedMoveLimit(arg);
|
|
1026
|
+
activePlayers[playerID] = arg.stage;
|
|
1027
|
+
if (arg.minMoves)
|
|
1028
|
+
_activePlayersMinMoves[playerID] = arg.minMoves;
|
|
1029
|
+
if (arg.maxMoves)
|
|
1030
|
+
_activePlayersMaxMoves[playerID] = arg.maxMoves;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Converts a playOrderPos index into its value in playOrder.
|
|
1035
|
+
* @param {Array} playOrder - An array of player ID's.
|
|
1036
|
+
* @param {number} playOrderPos - An index into the above.
|
|
1037
|
+
*/
|
|
1038
|
+
function getCurrentPlayer(playOrder, playOrderPos) {
|
|
1039
|
+
// convert to string in case playOrder is set to number[]
|
|
1040
|
+
return playOrder[playOrderPos] + '';
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Called at the start of a turn to initialize turn order state.
|
|
1044
|
+
*
|
|
1045
|
+
* TODO: This is called inside StartTurn, which is called from
|
|
1046
|
+
* both UpdateTurn and StartPhase (so it's called at the beginning
|
|
1047
|
+
* of a new phase as well as between turns). We should probably
|
|
1048
|
+
* split it into two.
|
|
1049
|
+
*/
|
|
1050
|
+
function InitTurnOrderState(state, turn) {
|
|
1051
|
+
let { G, ctx } = state;
|
|
1052
|
+
const { numPlayers } = ctx;
|
|
1053
|
+
const pluginAPIs = GetAPIs(state);
|
|
1054
|
+
const context = { ...pluginAPIs, G, ctx };
|
|
1055
|
+
const order = turn.order;
|
|
1056
|
+
let playOrder = [...Array.from({ length: numPlayers })].map((_, i) => i + '');
|
|
1057
|
+
if (order.playOrder !== undefined) {
|
|
1058
|
+
playOrder = order.playOrder(context);
|
|
1059
|
+
}
|
|
1060
|
+
const playOrderPos = order.first(context);
|
|
1061
|
+
const posType = typeof playOrderPos;
|
|
1062
|
+
if (posType !== 'number') {
|
|
1063
|
+
error(`invalid value returned by turn.order.first — expected number got ${posType} “${playOrderPos}”.`);
|
|
1064
|
+
}
|
|
1065
|
+
const currentPlayer = getCurrentPlayer(playOrder, playOrderPos);
|
|
1066
|
+
ctx = { ...ctx, currentPlayer, playOrderPos, playOrder };
|
|
1067
|
+
ctx = SetActivePlayers(ctx, turn.activePlayers || {});
|
|
1068
|
+
return ctx;
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Called at the end of each turn to update the turn order state.
|
|
1072
|
+
* @param {object} G - The game object G.
|
|
1073
|
+
* @param {object} ctx - The game object ctx.
|
|
1074
|
+
* @param {object} turn - A turn object for this phase.
|
|
1075
|
+
* @param {string} endTurnArg - An optional argument to endTurn that
|
|
1076
|
+
may specify the next player.
|
|
1077
|
+
*/
|
|
1078
|
+
function UpdateTurnOrderState(state, currentPlayer, turn, endTurnArg) {
|
|
1079
|
+
const order = turn.order;
|
|
1080
|
+
let { G, ctx } = state;
|
|
1081
|
+
let playOrderPos = ctx.playOrderPos;
|
|
1082
|
+
let endPhase = false;
|
|
1083
|
+
if (endTurnArg && endTurnArg !== true) {
|
|
1084
|
+
if (typeof endTurnArg !== 'object') {
|
|
1085
|
+
error(`invalid argument to endTurn: ${endTurnArg}`);
|
|
1086
|
+
}
|
|
1087
|
+
Object.keys(endTurnArg).forEach((arg) => {
|
|
1088
|
+
switch (arg) {
|
|
1089
|
+
case 'remove':
|
|
1090
|
+
currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos);
|
|
1091
|
+
break;
|
|
1092
|
+
case 'next':
|
|
1093
|
+
playOrderPos = ctx.playOrder.indexOf(endTurnArg.next);
|
|
1094
|
+
currentPlayer = endTurnArg.next;
|
|
1095
|
+
break;
|
|
1096
|
+
default:
|
|
1097
|
+
error(`invalid argument to endTurn: ${arg}`);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
const pluginAPIs = GetAPIs(state);
|
|
1103
|
+
const context = { ...pluginAPIs, G, ctx };
|
|
1104
|
+
const t = order.next(context);
|
|
1105
|
+
const type = typeof t;
|
|
1106
|
+
if (t !== undefined && type !== 'number') {
|
|
1107
|
+
error(`invalid value returned by turn.order.next — expected number or undefined got ${type} “${t}”.`);
|
|
1108
|
+
}
|
|
1109
|
+
if (t === undefined) {
|
|
1110
|
+
endPhase = true;
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
playOrderPos = t;
|
|
1114
|
+
currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
ctx = {
|
|
1118
|
+
...ctx,
|
|
1119
|
+
playOrderPos,
|
|
1120
|
+
currentPlayer,
|
|
1121
|
+
};
|
|
1122
|
+
return { endPhase, ctx };
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Set of different turn orders possible in a phase.
|
|
1126
|
+
* These are meant to be passed to the `turn` setting
|
|
1127
|
+
* in the flow objects.
|
|
1128
|
+
*
|
|
1129
|
+
* Each object defines the first player when the phase / game
|
|
1130
|
+
* begins, and also a function `next` to determine who the
|
|
1131
|
+
* next player is when the turn ends.
|
|
1132
|
+
*
|
|
1133
|
+
* The phase ends if next() returns undefined.
|
|
1134
|
+
*/
|
|
1135
|
+
const TurnOrder = {
|
|
1136
|
+
/**
|
|
1137
|
+
* DEFAULT
|
|
1138
|
+
*
|
|
1139
|
+
* The default round-robin turn order.
|
|
1140
|
+
*/
|
|
1141
|
+
DEFAULT: {
|
|
1142
|
+
first: ({ ctx }) => ctx.turn === 0
|
|
1143
|
+
? ctx.playOrderPos
|
|
1144
|
+
: (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1145
|
+
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1146
|
+
},
|
|
1147
|
+
/**
|
|
1148
|
+
* RESET
|
|
1149
|
+
*
|
|
1150
|
+
* Similar to DEFAULT, but starts from 0 each time.
|
|
1151
|
+
*/
|
|
1152
|
+
RESET: {
|
|
1153
|
+
first: () => 0,
|
|
1154
|
+
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1155
|
+
},
|
|
1156
|
+
/**
|
|
1157
|
+
* CONTINUE
|
|
1158
|
+
*
|
|
1159
|
+
* Similar to DEFAULT, but starts with the player who ended the last phase.
|
|
1160
|
+
*/
|
|
1161
|
+
CONTINUE: {
|
|
1162
|
+
first: ({ ctx }) => ctx.playOrderPos,
|
|
1163
|
+
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1164
|
+
},
|
|
1165
|
+
/**
|
|
1166
|
+
* ONCE
|
|
1167
|
+
*
|
|
1168
|
+
* Another round-robin turn order, but goes around just once.
|
|
1169
|
+
* The phase ends after all players have played.
|
|
1170
|
+
*/
|
|
1171
|
+
ONCE: {
|
|
1172
|
+
first: () => 0,
|
|
1173
|
+
next: ({ ctx }) => {
|
|
1174
|
+
if (ctx.playOrderPos < ctx.playOrder.length - 1) {
|
|
1175
|
+
return ctx.playOrderPos + 1;
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
/**
|
|
1180
|
+
* CUSTOM
|
|
1181
|
+
*
|
|
1182
|
+
* Identical to DEFAULT, but also sets playOrder at the
|
|
1183
|
+
* beginning of the phase.
|
|
1184
|
+
*
|
|
1185
|
+
* @param {Array} playOrder - The play order.
|
|
1186
|
+
*/
|
|
1187
|
+
CUSTOM: (playOrder) => ({
|
|
1188
|
+
playOrder: () => playOrder,
|
|
1189
|
+
first: () => 0,
|
|
1190
|
+
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1191
|
+
}),
|
|
1192
|
+
/**
|
|
1193
|
+
* CUSTOM_FROM
|
|
1194
|
+
*
|
|
1195
|
+
* Identical to DEFAULT, but also sets playOrder at the
|
|
1196
|
+
* beginning of the phase to a value specified by a field
|
|
1197
|
+
* in G.
|
|
1198
|
+
*
|
|
1199
|
+
* @param {string} playOrderField - Field in G.
|
|
1200
|
+
*/
|
|
1201
|
+
CUSTOM_FROM: (playOrderField) => ({
|
|
1202
|
+
playOrder: ({ G }) => G[playOrderField],
|
|
1203
|
+
first: () => 0,
|
|
1204
|
+
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
|
|
1205
|
+
}),
|
|
1206
|
+
};
|
|
1207
|
+
const Stage = {
|
|
1208
|
+
NULL: null,
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
/*
|
|
1212
|
+
* Copyright 2017 The boardgame.io Authors
|
|
1213
|
+
*
|
|
1214
|
+
* Use of this source code is governed by a MIT-style
|
|
1215
|
+
* license that can be found in the LICENSE file or at
|
|
1216
|
+
* https://opensource.org/licenses/MIT.
|
|
1217
|
+
*/
|
|
1218
|
+
/**
|
|
1219
|
+
* Flow
|
|
1220
|
+
*
|
|
1221
|
+
* Creates a reducer that updates ctx (analogous to how moves update G).
|
|
1222
|
+
*/
|
|
1223
|
+
function Flow({ moves, phases, endIf, onEnd, turn, events, plugins, }) {
|
|
1224
|
+
// Attach defaults.
|
|
1225
|
+
if (moves === undefined) {
|
|
1226
|
+
moves = {};
|
|
1227
|
+
}
|
|
1228
|
+
if (events === undefined) {
|
|
1229
|
+
events = {};
|
|
1230
|
+
}
|
|
1231
|
+
if (plugins === undefined) {
|
|
1232
|
+
plugins = [];
|
|
1233
|
+
}
|
|
1234
|
+
if (phases === undefined) {
|
|
1235
|
+
phases = {};
|
|
1236
|
+
}
|
|
1237
|
+
if (!endIf)
|
|
1238
|
+
endIf = () => undefined;
|
|
1239
|
+
if (!onEnd)
|
|
1240
|
+
onEnd = ({ G }) => G;
|
|
1241
|
+
if (!turn)
|
|
1242
|
+
turn = {};
|
|
1243
|
+
const phaseMap = { ...phases };
|
|
1244
|
+
if ('' in phaseMap) {
|
|
1245
|
+
error('cannot specify phase with empty name');
|
|
1246
|
+
}
|
|
1247
|
+
phaseMap[''] = {};
|
|
1248
|
+
const moveMap = {};
|
|
1249
|
+
const moveNames = new Set();
|
|
1250
|
+
let startingPhase = null;
|
|
1251
|
+
Object.keys(moves).forEach((name) => moveNames.add(name));
|
|
1252
|
+
const HookWrapper = (hook, hookType) => {
|
|
1253
|
+
const withPlugins = FnWrap(hook, hookType, plugins);
|
|
1254
|
+
return (state) => {
|
|
1255
|
+
const pluginAPIs = GetAPIs(state);
|
|
1256
|
+
return withPlugins({
|
|
1257
|
+
...pluginAPIs,
|
|
1258
|
+
G: state.G,
|
|
1259
|
+
ctx: state.ctx,
|
|
1260
|
+
playerID: state.playerID,
|
|
1261
|
+
});
|
|
1262
|
+
};
|
|
1263
|
+
};
|
|
1264
|
+
const TriggerWrapper = (trigger) => {
|
|
1265
|
+
return (state) => {
|
|
1266
|
+
const pluginAPIs = GetAPIs(state);
|
|
1267
|
+
return trigger({
|
|
1268
|
+
...pluginAPIs,
|
|
1269
|
+
G: state.G,
|
|
1270
|
+
ctx: state.ctx,
|
|
1271
|
+
});
|
|
1272
|
+
};
|
|
1273
|
+
};
|
|
1274
|
+
const wrapped = {
|
|
1275
|
+
onEnd: HookWrapper(onEnd, GameMethod.GAME_ON_END),
|
|
1276
|
+
endIf: TriggerWrapper(endIf),
|
|
1277
|
+
};
|
|
1278
|
+
for (const phase in phaseMap) {
|
|
1279
|
+
const phaseConfig = phaseMap[phase];
|
|
1280
|
+
if (phaseConfig.start === true) {
|
|
1281
|
+
startingPhase = phase;
|
|
1282
|
+
}
|
|
1283
|
+
if (phaseConfig.moves !== undefined) {
|
|
1284
|
+
for (const move of Object.keys(phaseConfig.moves)) {
|
|
1285
|
+
moveMap[phase + '.' + move] = phaseConfig.moves[move];
|
|
1286
|
+
moveNames.add(move);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (phaseConfig.endIf === undefined) {
|
|
1290
|
+
phaseConfig.endIf = () => undefined;
|
|
1291
|
+
}
|
|
1292
|
+
if (phaseConfig.onBegin === undefined) {
|
|
1293
|
+
phaseConfig.onBegin = ({ G }) => G;
|
|
1294
|
+
}
|
|
1295
|
+
if (phaseConfig.onEnd === undefined) {
|
|
1296
|
+
phaseConfig.onEnd = ({ G }) => G;
|
|
1297
|
+
}
|
|
1298
|
+
if (phaseConfig.turn === undefined) {
|
|
1299
|
+
phaseConfig.turn = turn;
|
|
1300
|
+
}
|
|
1301
|
+
if (phaseConfig.turn.order === undefined) {
|
|
1302
|
+
phaseConfig.turn.order = TurnOrder.DEFAULT;
|
|
1303
|
+
}
|
|
1304
|
+
if (phaseConfig.turn.onBegin === undefined) {
|
|
1305
|
+
phaseConfig.turn.onBegin = ({ G }) => G;
|
|
1306
|
+
}
|
|
1307
|
+
if (phaseConfig.turn.onEnd === undefined) {
|
|
1308
|
+
phaseConfig.turn.onEnd = ({ G }) => G;
|
|
1309
|
+
}
|
|
1310
|
+
if (phaseConfig.turn.endIf === undefined) {
|
|
1311
|
+
phaseConfig.turn.endIf = () => false;
|
|
1312
|
+
}
|
|
1313
|
+
if (phaseConfig.turn.onMove === undefined) {
|
|
1314
|
+
phaseConfig.turn.onMove = ({ G }) => G;
|
|
1315
|
+
}
|
|
1316
|
+
if (phaseConfig.turn.stages === undefined) {
|
|
1317
|
+
phaseConfig.turn.stages = {};
|
|
1318
|
+
}
|
|
1319
|
+
// turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally
|
|
1320
|
+
supportDeprecatedMoveLimit(phaseConfig.turn, true);
|
|
1321
|
+
for (const stage in phaseConfig.turn.stages) {
|
|
1322
|
+
const stageConfig = phaseConfig.turn.stages[stage];
|
|
1323
|
+
const moves = stageConfig.moves || {};
|
|
1324
|
+
for (const move of Object.keys(moves)) {
|
|
1325
|
+
const key = phase + '.' + stage + '.' + move;
|
|
1326
|
+
moveMap[key] = moves[move];
|
|
1327
|
+
moveNames.add(move);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
phaseConfig.wrapped = {
|
|
1331
|
+
onBegin: HookWrapper(phaseConfig.onBegin, GameMethod.PHASE_ON_BEGIN),
|
|
1332
|
+
onEnd: HookWrapper(phaseConfig.onEnd, GameMethod.PHASE_ON_END),
|
|
1333
|
+
endIf: TriggerWrapper(phaseConfig.endIf),
|
|
1334
|
+
};
|
|
1335
|
+
phaseConfig.turn.wrapped = {
|
|
1336
|
+
onMove: HookWrapper(phaseConfig.turn.onMove, GameMethod.TURN_ON_MOVE),
|
|
1337
|
+
onBegin: HookWrapper(phaseConfig.turn.onBegin, GameMethod.TURN_ON_BEGIN),
|
|
1338
|
+
onEnd: HookWrapper(phaseConfig.turn.onEnd, GameMethod.TURN_ON_END),
|
|
1339
|
+
endIf: TriggerWrapper(phaseConfig.turn.endIf),
|
|
1340
|
+
};
|
|
1341
|
+
if (typeof phaseConfig.next !== 'function') {
|
|
1342
|
+
const { next } = phaseConfig;
|
|
1343
|
+
phaseConfig.next = () => next || null;
|
|
1344
|
+
}
|
|
1345
|
+
phaseConfig.wrapped.next = TriggerWrapper(phaseConfig.next);
|
|
1346
|
+
}
|
|
1347
|
+
function GetPhase(ctx) {
|
|
1348
|
+
return ctx.phase ? phaseMap[ctx.phase] : phaseMap[''];
|
|
1349
|
+
}
|
|
1350
|
+
function OnMove(state) {
|
|
1351
|
+
return state;
|
|
1352
|
+
}
|
|
1353
|
+
function Process(state, events) {
|
|
1354
|
+
const phasesEnded = new Set();
|
|
1355
|
+
const turnsEnded = new Set();
|
|
1356
|
+
for (let i = 0; i < events.length; i++) {
|
|
1357
|
+
const { fn, arg, ...rest } = events[i];
|
|
1358
|
+
// Detect a loop of EndPhase calls.
|
|
1359
|
+
// This could potentially even be an infinite loop
|
|
1360
|
+
// if the endIf condition of each phase blindly
|
|
1361
|
+
// returns true. The moment we detect a single
|
|
1362
|
+
// loop, we just bail out of all phases.
|
|
1363
|
+
if (fn === EndPhase) {
|
|
1364
|
+
turnsEnded.clear();
|
|
1365
|
+
const phase = state.ctx.phase;
|
|
1366
|
+
if (phasesEnded.has(phase)) {
|
|
1367
|
+
const ctx = { ...state.ctx, phase: null };
|
|
1368
|
+
return { ...state, ctx };
|
|
1369
|
+
}
|
|
1370
|
+
phasesEnded.add(phase);
|
|
1371
|
+
}
|
|
1372
|
+
// Process event.
|
|
1373
|
+
const next = [];
|
|
1374
|
+
state = fn(state, {
|
|
1375
|
+
...rest,
|
|
1376
|
+
arg,
|
|
1377
|
+
next,
|
|
1378
|
+
});
|
|
1379
|
+
if (fn === EndGame) {
|
|
1380
|
+
break;
|
|
1381
|
+
}
|
|
1382
|
+
// Check if we should end the game.
|
|
1383
|
+
const shouldEndGame = ShouldEndGame(state);
|
|
1384
|
+
if (shouldEndGame) {
|
|
1385
|
+
events.push({
|
|
1386
|
+
fn: EndGame,
|
|
1387
|
+
arg: shouldEndGame,
|
|
1388
|
+
turn: state.ctx.turn,
|
|
1389
|
+
phase: state.ctx.phase,
|
|
1390
|
+
automatic: true,
|
|
1391
|
+
});
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
// Check if we should end the phase.
|
|
1395
|
+
const shouldEndPhase = ShouldEndPhase(state);
|
|
1396
|
+
if (shouldEndPhase) {
|
|
1397
|
+
events.push({
|
|
1398
|
+
fn: EndPhase,
|
|
1399
|
+
arg: shouldEndPhase,
|
|
1400
|
+
turn: state.ctx.turn,
|
|
1401
|
+
phase: state.ctx.phase,
|
|
1402
|
+
automatic: true,
|
|
1403
|
+
});
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
// Check if we should end the turn.
|
|
1407
|
+
if ([OnMove, UpdateStage, UpdateActivePlayers].includes(fn)) {
|
|
1408
|
+
const shouldEndTurn = ShouldEndTurn(state);
|
|
1409
|
+
if (shouldEndTurn) {
|
|
1410
|
+
events.push({
|
|
1411
|
+
fn: EndTurn,
|
|
1412
|
+
arg: shouldEndTurn,
|
|
1413
|
+
turn: state.ctx.turn,
|
|
1414
|
+
phase: state.ctx.phase,
|
|
1415
|
+
automatic: true,
|
|
1416
|
+
});
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
events.push(...next);
|
|
1421
|
+
}
|
|
1422
|
+
return state;
|
|
1423
|
+
}
|
|
1424
|
+
///////////
|
|
1425
|
+
// Start //
|
|
1426
|
+
///////////
|
|
1427
|
+
function StartGame(state, { next }) {
|
|
1428
|
+
next.push({ fn: StartPhase });
|
|
1429
|
+
return state;
|
|
1430
|
+
}
|
|
1431
|
+
function StartPhase(state, { next }) {
|
|
1432
|
+
let { G, ctx } = state;
|
|
1433
|
+
const phaseConfig = GetPhase(ctx);
|
|
1434
|
+
// Run any phase setup code provided by the user.
|
|
1435
|
+
G = phaseConfig.wrapped.onBegin(state);
|
|
1436
|
+
next.push({ fn: StartTurn });
|
|
1437
|
+
return { ...state, G, ctx };
|
|
1438
|
+
}
|
|
1439
|
+
function StartTurn(state, { currentPlayer }) {
|
|
1440
|
+
let { ctx } = state;
|
|
1441
|
+
const phaseConfig = GetPhase(ctx);
|
|
1442
|
+
// Initialize the turn order state.
|
|
1443
|
+
if (currentPlayer) {
|
|
1444
|
+
ctx = { ...ctx, currentPlayer };
|
|
1445
|
+
if (phaseConfig.turn.activePlayers) {
|
|
1446
|
+
ctx = SetActivePlayers(ctx, phaseConfig.turn.activePlayers);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
// This is only called at the beginning of the phase
|
|
1451
|
+
// when there is no currentPlayer yet.
|
|
1452
|
+
ctx = InitTurnOrderState(state, phaseConfig.turn);
|
|
1453
|
+
}
|
|
1454
|
+
const turn = ctx.turn + 1;
|
|
1455
|
+
ctx = { ...ctx, turn, numMoves: 0, _prevActivePlayers: [] };
|
|
1456
|
+
const G = phaseConfig.turn.wrapped.onBegin({ ...state, ctx });
|
|
1457
|
+
return { ...state, G, ctx, _undo: [], _redo: [] };
|
|
1458
|
+
}
|
|
1459
|
+
////////////
|
|
1460
|
+
// Update //
|
|
1461
|
+
////////////
|
|
1462
|
+
function UpdatePhase(state, { arg, next, phase }) {
|
|
1463
|
+
const phaseConfig = GetPhase({ phase });
|
|
1464
|
+
let { ctx } = state;
|
|
1465
|
+
if (arg && arg.next) {
|
|
1466
|
+
if (arg.next in phaseMap) {
|
|
1467
|
+
ctx = { ...ctx, phase: arg.next };
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
error('invalid phase: ' + arg.next);
|
|
1471
|
+
return state;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
ctx = { ...ctx, phase: phaseConfig.wrapped.next(state) || null };
|
|
1476
|
+
}
|
|
1477
|
+
state = { ...state, ctx };
|
|
1478
|
+
// Start the new phase.
|
|
1479
|
+
next.push({ fn: StartPhase });
|
|
1480
|
+
return state;
|
|
1481
|
+
}
|
|
1482
|
+
function UpdateTurn(state, { arg, currentPlayer, next }) {
|
|
1483
|
+
let { G, ctx } = state;
|
|
1484
|
+
const phaseConfig = GetPhase(ctx);
|
|
1485
|
+
// Update turn order state.
|
|
1486
|
+
const { endPhase, ctx: newCtx } = UpdateTurnOrderState(state, currentPlayer, phaseConfig.turn, arg);
|
|
1487
|
+
ctx = newCtx;
|
|
1488
|
+
state = { ...state, G, ctx };
|
|
1489
|
+
if (endPhase) {
|
|
1490
|
+
next.push({ fn: EndPhase, turn: ctx.turn, phase: ctx.phase });
|
|
1491
|
+
}
|
|
1492
|
+
else {
|
|
1493
|
+
next.push({ fn: StartTurn, currentPlayer: ctx.currentPlayer });
|
|
1494
|
+
}
|
|
1495
|
+
return state;
|
|
1496
|
+
}
|
|
1497
|
+
function UpdateStage(state, { arg, playerID }) {
|
|
1498
|
+
if (typeof arg === 'string' || arg === Stage.NULL) {
|
|
1499
|
+
arg = { stage: arg };
|
|
1500
|
+
}
|
|
1501
|
+
if (typeof arg !== 'object')
|
|
1502
|
+
return state;
|
|
1503
|
+
// `arg` should be of type `StageArg`, loose typing as `any` here for historic reasons
|
|
1504
|
+
// stages previously did not enforce minMoves, this behaviour is kept intentionally
|
|
1505
|
+
supportDeprecatedMoveLimit(arg);
|
|
1506
|
+
let { ctx } = state;
|
|
1507
|
+
let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, } = ctx;
|
|
1508
|
+
// Checking if stage is valid, even Stage.NULL
|
|
1509
|
+
if (arg.stage !== undefined) {
|
|
1510
|
+
if (activePlayers === null) {
|
|
1511
|
+
activePlayers = {};
|
|
1512
|
+
}
|
|
1513
|
+
activePlayers[playerID] = arg.stage;
|
|
1514
|
+
_activePlayersNumMoves[playerID] = 0;
|
|
1515
|
+
if (arg.minMoves) {
|
|
1516
|
+
if (_activePlayersMinMoves === null) {
|
|
1517
|
+
_activePlayersMinMoves = {};
|
|
1518
|
+
}
|
|
1519
|
+
_activePlayersMinMoves[playerID] = arg.minMoves;
|
|
1520
|
+
}
|
|
1521
|
+
if (arg.maxMoves) {
|
|
1522
|
+
if (_activePlayersMaxMoves === null) {
|
|
1523
|
+
_activePlayersMaxMoves = {};
|
|
1524
|
+
}
|
|
1525
|
+
_activePlayersMaxMoves[playerID] = arg.maxMoves;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
ctx = {
|
|
1529
|
+
...ctx,
|
|
1530
|
+
activePlayers,
|
|
1531
|
+
_activePlayersMinMoves,
|
|
1532
|
+
_activePlayersMaxMoves,
|
|
1533
|
+
_activePlayersNumMoves,
|
|
1534
|
+
};
|
|
1535
|
+
return { ...state, ctx };
|
|
1536
|
+
}
|
|
1537
|
+
function UpdateActivePlayers(state, { arg }) {
|
|
1538
|
+
return { ...state, ctx: SetActivePlayers(state.ctx, arg) };
|
|
1539
|
+
}
|
|
1540
|
+
///////////////
|
|
1541
|
+
// ShouldEnd //
|
|
1542
|
+
///////////////
|
|
1543
|
+
function ShouldEndGame(state) {
|
|
1544
|
+
return wrapped.endIf(state);
|
|
1545
|
+
}
|
|
1546
|
+
function ShouldEndPhase(state) {
|
|
1547
|
+
const phaseConfig = GetPhase(state.ctx);
|
|
1548
|
+
return phaseConfig.wrapped.endIf(state);
|
|
1549
|
+
}
|
|
1550
|
+
function ShouldEndTurn(state) {
|
|
1551
|
+
const phaseConfig = GetPhase(state.ctx);
|
|
1552
|
+
// End the turn if the required number of moves has been made.
|
|
1553
|
+
const currentPlayerMoves = state.ctx.numMoves || 0;
|
|
1554
|
+
if (phaseConfig.turn.maxMoves &&
|
|
1555
|
+
currentPlayerMoves >= phaseConfig.turn.maxMoves) {
|
|
1556
|
+
return true;
|
|
1557
|
+
}
|
|
1558
|
+
return phaseConfig.turn.wrapped.endIf(state);
|
|
1559
|
+
}
|
|
1560
|
+
/////////
|
|
1561
|
+
// End //
|
|
1562
|
+
/////////
|
|
1563
|
+
function EndGame(state, { arg, phase }) {
|
|
1564
|
+
state = EndPhase(state, { phase });
|
|
1565
|
+
if (arg === undefined) {
|
|
1566
|
+
arg = true;
|
|
1567
|
+
}
|
|
1568
|
+
state = { ...state, ctx: { ...state.ctx, gameover: arg } };
|
|
1569
|
+
// Run game end hook.
|
|
1570
|
+
const G = wrapped.onEnd(state);
|
|
1571
|
+
return { ...state, G };
|
|
1572
|
+
}
|
|
1573
|
+
function EndPhase(state, { arg, next, turn: initialTurn, automatic }) {
|
|
1574
|
+
// End the turn first.
|
|
1575
|
+
state = EndTurn(state, { turn: initialTurn, force: true, automatic: true });
|
|
1576
|
+
const { phase, turn } = state.ctx;
|
|
1577
|
+
if (next) {
|
|
1578
|
+
next.push({ fn: UpdatePhase, arg, phase });
|
|
1579
|
+
}
|
|
1580
|
+
// If we aren't in a phase, there is nothing else to do.
|
|
1581
|
+
if (phase === null) {
|
|
1582
|
+
return state;
|
|
1583
|
+
}
|
|
1584
|
+
// Run any cleanup code for the phase that is about to end.
|
|
1585
|
+
const phaseConfig = GetPhase(state.ctx);
|
|
1586
|
+
const G = phaseConfig.wrapped.onEnd(state);
|
|
1587
|
+
// Reset the phase.
|
|
1588
|
+
const ctx = { ...state.ctx, phase: null };
|
|
1589
|
+
// Add log entry.
|
|
1590
|
+
const action = gameEvent('endPhase', arg);
|
|
1591
|
+
const { _stateID } = state;
|
|
1592
|
+
const logEntry = { action, _stateID, turn, phase };
|
|
1593
|
+
if (automatic)
|
|
1594
|
+
logEntry.automatic = true;
|
|
1595
|
+
const deltalog = [...(state.deltalog || []), logEntry];
|
|
1596
|
+
return { ...state, G, ctx, deltalog };
|
|
1597
|
+
}
|
|
1598
|
+
function EndTurn(state, { arg, next, turn: initialTurn, force, automatic, playerID }) {
|
|
1599
|
+
// This is not the turn that EndTurn was originally
|
|
1600
|
+
// called for. The turn was probably ended some other way.
|
|
1601
|
+
if (initialTurn !== state.ctx.turn) {
|
|
1602
|
+
return state;
|
|
1603
|
+
}
|
|
1604
|
+
const { currentPlayer, numMoves, phase, turn } = state.ctx;
|
|
1605
|
+
const phaseConfig = GetPhase(state.ctx);
|
|
1606
|
+
// Prevent ending the turn if minMoves haven't been reached.
|
|
1607
|
+
const currentPlayerMoves = numMoves || 0;
|
|
1608
|
+
if (!force &&
|
|
1609
|
+
phaseConfig.turn.minMoves &&
|
|
1610
|
+
currentPlayerMoves < phaseConfig.turn.minMoves) {
|
|
1611
|
+
info(`cannot end turn before making ${phaseConfig.turn.minMoves} moves`);
|
|
1612
|
+
return state;
|
|
1613
|
+
}
|
|
1614
|
+
// Run turn-end triggers.
|
|
1615
|
+
const G = phaseConfig.turn.wrapped.onEnd(state);
|
|
1616
|
+
if (next) {
|
|
1617
|
+
next.push({ fn: UpdateTurn, arg, currentPlayer });
|
|
1618
|
+
}
|
|
1619
|
+
// Reset activePlayers.
|
|
1620
|
+
let ctx = { ...state.ctx, activePlayers: null };
|
|
1621
|
+
// Remove player from playerOrder
|
|
1622
|
+
if (arg && arg.remove) {
|
|
1623
|
+
playerID = playerID || currentPlayer;
|
|
1624
|
+
const playOrder = ctx.playOrder.filter((i) => i != playerID);
|
|
1625
|
+
const playOrderPos = ctx.playOrderPos > playOrder.length - 1 ? 0 : ctx.playOrderPos;
|
|
1626
|
+
ctx = { ...ctx, playOrder, playOrderPos };
|
|
1627
|
+
if (playOrder.length === 0) {
|
|
1628
|
+
next.push({ fn: EndPhase, turn, phase });
|
|
1629
|
+
return state;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// Create log entry.
|
|
1633
|
+
const action = gameEvent('endTurn', arg);
|
|
1634
|
+
const { _stateID } = state;
|
|
1635
|
+
const logEntry = { action, _stateID, turn, phase };
|
|
1636
|
+
if (automatic)
|
|
1637
|
+
logEntry.automatic = true;
|
|
1638
|
+
const deltalog = [...(state.deltalog || []), logEntry];
|
|
1639
|
+
return { ...state, G, ctx, deltalog, _undo: [], _redo: [] };
|
|
1640
|
+
}
|
|
1641
|
+
function EndStage(state, { arg, next, automatic, playerID }) {
|
|
1642
|
+
playerID = playerID || state.ctx.currentPlayer;
|
|
1643
|
+
let { ctx, _stateID } = state;
|
|
1644
|
+
let { activePlayers, _activePlayersNumMoves, _activePlayersMinMoves, _activePlayersMaxMoves, phase, turn, } = ctx;
|
|
1645
|
+
const playerInStage = activePlayers !== null && playerID in activePlayers;
|
|
1646
|
+
const phaseConfig = GetPhase(ctx);
|
|
1647
|
+
if (!arg && playerInStage) {
|
|
1648
|
+
const stage = phaseConfig.turn.stages[activePlayers[playerID]];
|
|
1649
|
+
if (stage && stage.next) {
|
|
1650
|
+
arg = stage.next;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
// Checking if arg is a valid stage, even Stage.NULL
|
|
1654
|
+
if (next) {
|
|
1655
|
+
next.push({ fn: UpdateStage, arg, playerID });
|
|
1656
|
+
}
|
|
1657
|
+
// If player isn’t in a stage, there is nothing else to do.
|
|
1658
|
+
if (!playerInStage)
|
|
1659
|
+
return state;
|
|
1660
|
+
// Prevent ending the stage if minMoves haven't been reached.
|
|
1661
|
+
const currentPlayerMoves = _activePlayersNumMoves[playerID] || 0;
|
|
1662
|
+
if (_activePlayersMinMoves &&
|
|
1663
|
+
_activePlayersMinMoves[playerID] &&
|
|
1664
|
+
currentPlayerMoves < _activePlayersMinMoves[playerID]) {
|
|
1665
|
+
info(`cannot end stage before making ${_activePlayersMinMoves[playerID]} moves`);
|
|
1666
|
+
return state;
|
|
1667
|
+
}
|
|
1668
|
+
// Remove player from activePlayers.
|
|
1669
|
+
activePlayers = { ...activePlayers };
|
|
1670
|
+
delete activePlayers[playerID];
|
|
1671
|
+
if (_activePlayersMinMoves) {
|
|
1672
|
+
// Remove player from _activePlayersMinMoves.
|
|
1673
|
+
_activePlayersMinMoves = { ..._activePlayersMinMoves };
|
|
1674
|
+
delete _activePlayersMinMoves[playerID];
|
|
1675
|
+
}
|
|
1676
|
+
if (_activePlayersMaxMoves) {
|
|
1677
|
+
// Remove player from _activePlayersMaxMoves.
|
|
1678
|
+
_activePlayersMaxMoves = { ..._activePlayersMaxMoves };
|
|
1679
|
+
delete _activePlayersMaxMoves[playerID];
|
|
1680
|
+
}
|
|
1681
|
+
ctx = UpdateActivePlayersOnceEmpty({
|
|
1682
|
+
...ctx,
|
|
1683
|
+
activePlayers,
|
|
1684
|
+
_activePlayersMinMoves,
|
|
1685
|
+
_activePlayersMaxMoves,
|
|
1686
|
+
});
|
|
1687
|
+
// Create log entry.
|
|
1688
|
+
const action = gameEvent('endStage', arg);
|
|
1689
|
+
const logEntry = { action, _stateID, turn, phase };
|
|
1690
|
+
if (automatic)
|
|
1691
|
+
logEntry.automatic = true;
|
|
1692
|
+
const deltalog = [...(state.deltalog || []), logEntry];
|
|
1693
|
+
return { ...state, ctx, deltalog };
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Retrieves the relevant move that can be played by playerID.
|
|
1697
|
+
*
|
|
1698
|
+
* If ctx.activePlayers is set (i.e. one or more players are in some stage),
|
|
1699
|
+
* then it attempts to find the move inside the stages config for
|
|
1700
|
+
* that turn. If the stage for a player is '', then the player is
|
|
1701
|
+
* allowed to make a move (as determined by the phase config), but
|
|
1702
|
+
* isn't restricted to a particular set as defined in the stage config.
|
|
1703
|
+
*
|
|
1704
|
+
* If not, it then looks for the move inside the phase.
|
|
1705
|
+
*
|
|
1706
|
+
* If it doesn't find the move there, it looks at the global move definition.
|
|
1707
|
+
*
|
|
1708
|
+
* @param {object} ctx
|
|
1709
|
+
* @param {string} name
|
|
1710
|
+
* @param {string} playerID
|
|
1711
|
+
*/
|
|
1712
|
+
function GetMove(ctx, name, playerID) {
|
|
1713
|
+
const phaseConfig = GetPhase(ctx);
|
|
1714
|
+
const stages = phaseConfig.turn.stages;
|
|
1715
|
+
const { activePlayers } = ctx;
|
|
1716
|
+
if (activePlayers &&
|
|
1717
|
+
activePlayers[playerID] !== undefined &&
|
|
1718
|
+
activePlayers[playerID] !== Stage.NULL &&
|
|
1719
|
+
stages[activePlayers[playerID]] !== undefined &&
|
|
1720
|
+
stages[activePlayers[playerID]].moves !== undefined) {
|
|
1721
|
+
// Check if moves are defined for the player's stage.
|
|
1722
|
+
const stage = stages[activePlayers[playerID]];
|
|
1723
|
+
const moves = stage.moves;
|
|
1724
|
+
if (name in moves) {
|
|
1725
|
+
return moves[name];
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
else if (phaseConfig.moves) {
|
|
1729
|
+
// Check if moves are defined for the current phase.
|
|
1730
|
+
if (name in phaseConfig.moves) {
|
|
1731
|
+
return phaseConfig.moves[name];
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
else if (name in moves) {
|
|
1735
|
+
// Check for the move globally.
|
|
1736
|
+
return moves[name];
|
|
1737
|
+
}
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
function ProcessMove(state, action) {
|
|
1741
|
+
const { playerID, type } = action;
|
|
1742
|
+
const { currentPlayer, activePlayers, _activePlayersMaxMoves } = state.ctx;
|
|
1743
|
+
const move = GetMove(state.ctx, type, playerID);
|
|
1744
|
+
const shouldCount = !move || typeof move === 'function' || move.noLimit !== true;
|
|
1745
|
+
let { numMoves, _activePlayersNumMoves } = state.ctx;
|
|
1746
|
+
if (shouldCount) {
|
|
1747
|
+
if (playerID === currentPlayer)
|
|
1748
|
+
numMoves++;
|
|
1749
|
+
if (activePlayers)
|
|
1750
|
+
_activePlayersNumMoves[playerID]++;
|
|
1751
|
+
}
|
|
1752
|
+
state = {
|
|
1753
|
+
...state,
|
|
1754
|
+
ctx: {
|
|
1755
|
+
...state.ctx,
|
|
1756
|
+
numMoves,
|
|
1757
|
+
_activePlayersNumMoves,
|
|
1758
|
+
},
|
|
1759
|
+
};
|
|
1760
|
+
if (_activePlayersMaxMoves &&
|
|
1761
|
+
_activePlayersNumMoves[playerID] >= _activePlayersMaxMoves[playerID]) {
|
|
1762
|
+
state = EndStage(state, { playerID, automatic: true });
|
|
1763
|
+
}
|
|
1764
|
+
const phaseConfig = GetPhase(state.ctx);
|
|
1765
|
+
const G = phaseConfig.turn.wrapped.onMove({ ...state, playerID });
|
|
1766
|
+
state = { ...state, G };
|
|
1767
|
+
const events = [{ fn: OnMove }];
|
|
1768
|
+
return Process(state, events);
|
|
1769
|
+
}
|
|
1770
|
+
function SetStageEvent(state, playerID, arg) {
|
|
1771
|
+
return Process(state, [{ fn: EndStage, arg, playerID }]);
|
|
1772
|
+
}
|
|
1773
|
+
function EndStageEvent(state, playerID) {
|
|
1774
|
+
return Process(state, [{ fn: EndStage, playerID }]);
|
|
1775
|
+
}
|
|
1776
|
+
function SetActivePlayersEvent(state, _playerID, arg) {
|
|
1777
|
+
return Process(state, [{ fn: UpdateActivePlayers, arg }]);
|
|
1778
|
+
}
|
|
1779
|
+
function SetPhaseEvent(state, _playerID, newPhase) {
|
|
1780
|
+
return Process(state, [
|
|
1781
|
+
{
|
|
1782
|
+
fn: EndPhase,
|
|
1783
|
+
phase: state.ctx.phase,
|
|
1784
|
+
turn: state.ctx.turn,
|
|
1785
|
+
arg: { next: newPhase },
|
|
1786
|
+
},
|
|
1787
|
+
]);
|
|
1788
|
+
}
|
|
1789
|
+
function EndPhaseEvent(state) {
|
|
1790
|
+
return Process(state, [
|
|
1791
|
+
{ fn: EndPhase, phase: state.ctx.phase, turn: state.ctx.turn },
|
|
1792
|
+
]);
|
|
1793
|
+
}
|
|
1794
|
+
function EndTurnEvent(state, _playerID, arg) {
|
|
1795
|
+
return Process(state, [
|
|
1796
|
+
{ fn: EndTurn, turn: state.ctx.turn, phase: state.ctx.phase, arg },
|
|
1797
|
+
]);
|
|
1798
|
+
}
|
|
1799
|
+
function PassEvent(state, _playerID, arg) {
|
|
1800
|
+
return Process(state, [
|
|
1801
|
+
{
|
|
1802
|
+
fn: EndTurn,
|
|
1803
|
+
turn: state.ctx.turn,
|
|
1804
|
+
phase: state.ctx.phase,
|
|
1805
|
+
force: true,
|
|
1806
|
+
arg,
|
|
1807
|
+
},
|
|
1808
|
+
]);
|
|
1809
|
+
}
|
|
1810
|
+
function EndGameEvent(state, _playerID, arg) {
|
|
1811
|
+
return Process(state, [
|
|
1812
|
+
{ fn: EndGame, turn: state.ctx.turn, phase: state.ctx.phase, arg },
|
|
1813
|
+
]);
|
|
1814
|
+
}
|
|
1815
|
+
const eventHandlers = {
|
|
1816
|
+
endStage: EndStageEvent,
|
|
1817
|
+
setStage: SetStageEvent,
|
|
1818
|
+
endTurn: EndTurnEvent,
|
|
1819
|
+
pass: PassEvent,
|
|
1820
|
+
endPhase: EndPhaseEvent,
|
|
1821
|
+
setPhase: SetPhaseEvent,
|
|
1822
|
+
endGame: EndGameEvent,
|
|
1823
|
+
setActivePlayers: SetActivePlayersEvent,
|
|
1824
|
+
};
|
|
1825
|
+
const enabledEventNames = [];
|
|
1826
|
+
if (events.endTurn !== false) {
|
|
1827
|
+
enabledEventNames.push('endTurn');
|
|
1828
|
+
}
|
|
1829
|
+
if (events.pass !== false) {
|
|
1830
|
+
enabledEventNames.push('pass');
|
|
1831
|
+
}
|
|
1832
|
+
if (events.endPhase !== false) {
|
|
1833
|
+
enabledEventNames.push('endPhase');
|
|
1834
|
+
}
|
|
1835
|
+
if (events.setPhase !== false) {
|
|
1836
|
+
enabledEventNames.push('setPhase');
|
|
1837
|
+
}
|
|
1838
|
+
if (events.endGame !== false) {
|
|
1839
|
+
enabledEventNames.push('endGame');
|
|
1840
|
+
}
|
|
1841
|
+
if (events.setActivePlayers !== false) {
|
|
1842
|
+
enabledEventNames.push('setActivePlayers');
|
|
1843
|
+
}
|
|
1844
|
+
if (events.endStage !== false) {
|
|
1845
|
+
enabledEventNames.push('endStage');
|
|
1846
|
+
}
|
|
1847
|
+
if (events.setStage !== false) {
|
|
1848
|
+
enabledEventNames.push('setStage');
|
|
1849
|
+
}
|
|
1850
|
+
function ProcessEvent(state, action) {
|
|
1851
|
+
const { type, playerID, args } = action.payload;
|
|
1852
|
+
if (typeof eventHandlers[type] !== 'function')
|
|
1853
|
+
return state;
|
|
1854
|
+
return eventHandlers[type](state, playerID, ...(Array.isArray(args) ? args : [args]));
|
|
1855
|
+
}
|
|
1856
|
+
function IsPlayerActive(_G, ctx, playerID) {
|
|
1857
|
+
if (ctx.activePlayers) {
|
|
1858
|
+
return playerID in ctx.activePlayers;
|
|
1859
|
+
}
|
|
1860
|
+
return ctx.currentPlayer === playerID;
|
|
1861
|
+
}
|
|
1862
|
+
return {
|
|
1863
|
+
ctx: (numPlayers) => ({
|
|
1864
|
+
numPlayers,
|
|
1865
|
+
turn: 0,
|
|
1866
|
+
currentPlayer: '0',
|
|
1867
|
+
playOrder: [...Array.from({ length: numPlayers })].map((_, i) => i + ''),
|
|
1868
|
+
playOrderPos: 0,
|
|
1869
|
+
phase: startingPhase,
|
|
1870
|
+
activePlayers: null,
|
|
1871
|
+
}),
|
|
1872
|
+
init: (state) => {
|
|
1873
|
+
return Process(state, [{ fn: StartGame }]);
|
|
1874
|
+
},
|
|
1875
|
+
isPlayerActive: IsPlayerActive,
|
|
1876
|
+
eventHandlers,
|
|
1877
|
+
eventNames: Object.keys(eventHandlers),
|
|
1878
|
+
enabledEventNames,
|
|
1879
|
+
moveMap,
|
|
1880
|
+
moveNames: [...moveNames.values()],
|
|
1881
|
+
processMove: ProcessMove,
|
|
1882
|
+
processEvent: ProcessEvent,
|
|
1883
|
+
getMove: GetMove,
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/*
|
|
1888
|
+
* Copyright 2017 The boardgame.io Authors
|
|
1889
|
+
*
|
|
1890
|
+
* Use of this source code is governed by a MIT-style
|
|
1891
|
+
* license that can be found in the LICENSE file or at
|
|
1892
|
+
* https://opensource.org/licenses/MIT.
|
|
1893
|
+
*/
|
|
1894
|
+
function IsProcessed(game) {
|
|
1895
|
+
return game.processMove !== undefined;
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Helper to generate the game move reducer. The returned
|
|
1899
|
+
* reducer has the following signature:
|
|
1900
|
+
*
|
|
1901
|
+
* (G, action, ctx) => {}
|
|
1902
|
+
*
|
|
1903
|
+
* You can roll your own if you like, or use any Redux
|
|
1904
|
+
* addon to generate such a reducer.
|
|
1905
|
+
*
|
|
1906
|
+
* The convention used in this framework is to
|
|
1907
|
+
* have action.type contain the name of the move, and
|
|
1908
|
+
* action.args contain any additional arguments as an
|
|
1909
|
+
* Array.
|
|
1910
|
+
*/
|
|
1911
|
+
function ProcessGameConfig(game) {
|
|
1912
|
+
// The Game() function has already been called on this
|
|
1913
|
+
// config object, so just pass it through.
|
|
1914
|
+
if (IsProcessed(game)) {
|
|
1915
|
+
return game;
|
|
1916
|
+
}
|
|
1917
|
+
if (game.name === undefined)
|
|
1918
|
+
game.name = 'default';
|
|
1919
|
+
if (game.deltaState === undefined)
|
|
1920
|
+
game.deltaState = false;
|
|
1921
|
+
if (game.disableUndo === undefined)
|
|
1922
|
+
game.disableUndo = false;
|
|
1923
|
+
if (game.setup === undefined)
|
|
1924
|
+
game.setup = () => ({});
|
|
1925
|
+
if (game.moves === undefined)
|
|
1926
|
+
game.moves = {};
|
|
1927
|
+
if (game.playerView === undefined)
|
|
1928
|
+
game.playerView = ({ G }) => G;
|
|
1929
|
+
if (game.plugins === undefined)
|
|
1930
|
+
game.plugins = [];
|
|
1931
|
+
game.plugins.forEach((plugin) => {
|
|
1932
|
+
if (plugin.name === undefined) {
|
|
1933
|
+
throw new Error('Plugin missing name attribute');
|
|
1934
|
+
}
|
|
1935
|
+
if (plugin.name.includes(' ')) {
|
|
1936
|
+
throw new Error(plugin.name + ': Plugin name must not include spaces');
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
if (game.name.includes(' ')) {
|
|
1940
|
+
throw new Error(game.name + ': Game name must not include spaces');
|
|
1941
|
+
}
|
|
1942
|
+
const flow = Flow(game);
|
|
1943
|
+
return {
|
|
1944
|
+
...game,
|
|
1945
|
+
flow,
|
|
1946
|
+
moveNames: flow.moveNames,
|
|
1947
|
+
pluginNames: game.plugins.map((p) => p.name),
|
|
1948
|
+
processMove: (state, action) => {
|
|
1949
|
+
let moveFn = flow.getMove(state.ctx, action.type, action.playerID);
|
|
1950
|
+
if (IsLongFormMove(moveFn)) {
|
|
1951
|
+
moveFn = moveFn.move;
|
|
1952
|
+
}
|
|
1953
|
+
if (moveFn instanceof Function) {
|
|
1954
|
+
const fn = FnWrap(moveFn, GameMethod.MOVE, game.plugins);
|
|
1955
|
+
let args = [];
|
|
1956
|
+
if (action.args !== undefined) {
|
|
1957
|
+
args = Array.isArray(action.args) ? action.args : [action.args];
|
|
1958
|
+
}
|
|
1959
|
+
const context = {
|
|
1960
|
+
...GetAPIs(state),
|
|
1961
|
+
G: state.G,
|
|
1962
|
+
ctx: state.ctx,
|
|
1963
|
+
playerID: action.playerID,
|
|
1964
|
+
};
|
|
1965
|
+
return fn(context, ...args);
|
|
1966
|
+
}
|
|
1967
|
+
error(`invalid move object: ${action.type}`);
|
|
1968
|
+
return state.G;
|
|
1969
|
+
},
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
function IsLongFormMove(move) {
|
|
1973
|
+
return move instanceof Object && move.move !== undefined;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/*
|
|
1977
|
+
* Copyright 2020 The boardgame.io Authors
|
|
1978
|
+
*
|
|
1979
|
+
* Use of this source code is governed by a MIT-style
|
|
1980
|
+
* license that can be found in the LICENSE file or at
|
|
1981
|
+
* https://opensource.org/licenses/MIT.
|
|
1982
|
+
*/
|
|
1983
|
+
/**
|
|
1984
|
+
* Creates the initial game state.
|
|
1985
|
+
*/
|
|
1986
|
+
function InitializeGame({ game, numPlayers, setupData, }) {
|
|
1987
|
+
game = ProcessGameConfig(game);
|
|
1988
|
+
if (!numPlayers) {
|
|
1989
|
+
numPlayers = 2;
|
|
1990
|
+
}
|
|
1991
|
+
const ctx = game.flow.ctx(numPlayers);
|
|
1992
|
+
let state = {
|
|
1993
|
+
// User managed state.
|
|
1994
|
+
G: {},
|
|
1995
|
+
// Framework managed state.
|
|
1996
|
+
ctx,
|
|
1997
|
+
// Plugin related state.
|
|
1998
|
+
plugins: {},
|
|
1999
|
+
};
|
|
2000
|
+
// Run plugins over initial state.
|
|
2001
|
+
state = Setup(state, { game });
|
|
2002
|
+
state = Enhance(state, { game, playerID: undefined });
|
|
2003
|
+
const pluginAPIs = GetAPIs(state);
|
|
2004
|
+
state.G = game.setup({ ...pluginAPIs, ctx: state.ctx }, setupData);
|
|
2005
|
+
let initial = {
|
|
2006
|
+
...state,
|
|
2007
|
+
// List of {G, ctx} pairs that can be undone.
|
|
2008
|
+
_undo: [],
|
|
2009
|
+
// List of {G, ctx} pairs that can be redone.
|
|
2010
|
+
_redo: [],
|
|
2011
|
+
// A monotonically non-decreasing ID to ensure that
|
|
2012
|
+
// state updates are only allowed from clients that
|
|
2013
|
+
// are at the same version that the server.
|
|
2014
|
+
_stateID: 0,
|
|
2015
|
+
};
|
|
2016
|
+
initial = game.flow.init(initial);
|
|
2017
|
+
[initial] = FlushAndValidate(initial, { game });
|
|
2018
|
+
// Initialize undo stack.
|
|
2019
|
+
if (!game.disableUndo) {
|
|
2020
|
+
initial._undo = [
|
|
2021
|
+
{
|
|
2022
|
+
G: initial.G,
|
|
2023
|
+
ctx: initial.ctx,
|
|
2024
|
+
plugins: initial.plugins,
|
|
2025
|
+
},
|
|
2026
|
+
];
|
|
2027
|
+
}
|
|
2028
|
+
return initial;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Creates a new match metadata object.
|
|
2033
|
+
*/
|
|
2034
|
+
const createMetadata = ({ game, unlisted, setupData, numPlayers, }) => {
|
|
2035
|
+
const metadata = {
|
|
2036
|
+
gameName: game.name,
|
|
2037
|
+
unlisted: !!unlisted,
|
|
2038
|
+
players: {},
|
|
2039
|
+
createdAt: Date.now(),
|
|
2040
|
+
updatedAt: Date.now(),
|
|
2041
|
+
};
|
|
2042
|
+
if (setupData !== undefined)
|
|
2043
|
+
metadata.setupData = setupData;
|
|
2044
|
+
for (let playerIndex = 0; playerIndex < numPlayers; playerIndex++) {
|
|
2045
|
+
metadata.players[playerIndex] = { id: playerIndex };
|
|
2046
|
+
}
|
|
2047
|
+
return metadata;
|
|
2048
|
+
};
|
|
2049
|
+
/**
|
|
2050
|
+
* Creates initial state and metadata for a new match.
|
|
2051
|
+
* If the provided `setupData` doesn’t pass the game’s validation,
|
|
2052
|
+
* an error object is returned instead.
|
|
2053
|
+
*/
|
|
2054
|
+
const createMatch = ({ game, numPlayers, setupData, unlisted, }) => {
|
|
2055
|
+
if (!numPlayers || typeof numPlayers !== 'number')
|
|
2056
|
+
numPlayers = 2;
|
|
2057
|
+
const setupDataError = game.validateSetupData && game.validateSetupData(setupData, numPlayers);
|
|
2058
|
+
if (setupDataError !== undefined)
|
|
2059
|
+
return { setupDataError };
|
|
2060
|
+
const metadata = createMetadata({ game, numPlayers, setupData, unlisted });
|
|
2061
|
+
const initialState = InitializeGame({ game, numPlayers, setupData });
|
|
2062
|
+
return { metadata, initialState };
|
|
2063
|
+
};
|
|
2064
|
+
/**
|
|
2065
|
+
* Given players, returns the count of players.
|
|
2066
|
+
*/
|
|
2067
|
+
const getNumPlayers = (players) => Object.keys(players).length;
|
|
2068
|
+
/**
|
|
2069
|
+
* Given players, tries to find the ID of the first player that can be joined.
|
|
2070
|
+
* Returns `undefined` if there’s no available ID.
|
|
2071
|
+
*/
|
|
2072
|
+
const getFirstAvailablePlayerID = (players) => {
|
|
2073
|
+
const numPlayers = getNumPlayers(players);
|
|
2074
|
+
// Try to get the first index available
|
|
2075
|
+
for (let i = 0; i < numPlayers; i++) {
|
|
2076
|
+
if (typeof players[i].name === 'undefined' || players[i].name === null) {
|
|
2077
|
+
return String(i);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
/*
|
|
2083
|
+
* Copyright 2018 The boardgame.io Authors
|
|
2084
|
+
*
|
|
2085
|
+
* Use of this source code is governed by a MIT-style
|
|
2086
|
+
* license that can be found in the LICENSE file or at
|
|
2087
|
+
* https://opensource.org/licenses/MIT.
|
|
2088
|
+
*/
|
|
2089
|
+
/**
|
|
2090
|
+
* Creates a new match.
|
|
2091
|
+
*
|
|
2092
|
+
* @param {object} db - The storage API.
|
|
2093
|
+
* @param {object} game - The game config object.
|
|
2094
|
+
* @param {number} numPlayers - The number of players.
|
|
2095
|
+
* @param {object} setupData - User-defined object that's available
|
|
2096
|
+
* during game setup.
|
|
2097
|
+
* @param {object } lobbyConfig - Configuration options for the lobby.
|
|
2098
|
+
* @param {boolean} unlisted - Whether the match should be excluded from public listing.
|
|
2099
|
+
*/
|
|
2100
|
+
const CreateMatch = async ({ ctx, db, uuid, ...opts }) => {
|
|
2101
|
+
const matchID = uuid();
|
|
2102
|
+
const match = createMatch(opts);
|
|
2103
|
+
if ('setupDataError' in match) {
|
|
2104
|
+
ctx.throw(400, match.setupDataError);
|
|
2105
|
+
}
|
|
2106
|
+
else {
|
|
2107
|
+
await db.createMatch(matchID, match);
|
|
2108
|
+
return matchID;
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
/**
|
|
2112
|
+
* Create a metadata object without secret credentials to return to the client.
|
|
2113
|
+
*
|
|
2114
|
+
* @param {string} matchID - The identifier of the match the metadata belongs to.
|
|
2115
|
+
* @param {object} metadata - The match metadata object to strip credentials from.
|
|
2116
|
+
* @return - A metadata object without player credentials.
|
|
2117
|
+
*/
|
|
2118
|
+
const createClientMatchData = (matchID, metadata) => {
|
|
2119
|
+
return {
|
|
2120
|
+
...metadata,
|
|
2121
|
+
matchID,
|
|
2122
|
+
players: Object.values(metadata.players).map((player) => {
|
|
2123
|
+
// strip away credentials
|
|
2124
|
+
const { credentials, ...strippedInfo } = player;
|
|
2125
|
+
return strippedInfo;
|
|
2126
|
+
}),
|
|
2127
|
+
};
|
|
2128
|
+
};
|
|
2129
|
+
/** Utility extracting `string` from a query if it is `string[]`. */
|
|
2130
|
+
const unwrapQuery = (query) => (Array.isArray(query) ? query[0] : query);
|
|
2131
|
+
const configureRouter = ({ router, db, auth, games, uuid = () => nanoid.nanoid(11), }) => {
|
|
2132
|
+
/**
|
|
2133
|
+
* List available games.
|
|
2134
|
+
*
|
|
2135
|
+
* @return - Array of game names as string.
|
|
2136
|
+
*/
|
|
2137
|
+
router.get('/games', async (ctx) => {
|
|
2138
|
+
const body = games.map((game) => game.name);
|
|
2139
|
+
ctx.body = body;
|
|
2140
|
+
});
|
|
2141
|
+
/**
|
|
2142
|
+
* Create a new match of a given game.
|
|
2143
|
+
*
|
|
2144
|
+
* @param {string} name - The name of the game of the new match.
|
|
2145
|
+
* @param {number} numPlayers - The number of players.
|
|
2146
|
+
* @param {object} setupData - User-defined object that's available
|
|
2147
|
+
* during game setup.
|
|
2148
|
+
* @param {boolean} unlisted - Whether the match should be excluded from public listing.
|
|
2149
|
+
* @return - The ID of the created match.
|
|
2150
|
+
*/
|
|
2151
|
+
router.post('/games/:name/create', koaBody__default["default"](), async (ctx) => {
|
|
2152
|
+
// The name of the game (for example: tic-tac-toe).
|
|
2153
|
+
const gameName = ctx.params.name;
|
|
2154
|
+
// User-data to pass to the game setup function.
|
|
2155
|
+
const setupData = ctx.request.body.setupData;
|
|
2156
|
+
// Whether the game should be excluded from public listing.
|
|
2157
|
+
const unlisted = ctx.request.body.unlisted;
|
|
2158
|
+
// The number of players for this game instance.
|
|
2159
|
+
const numPlayers = Number.parseInt(ctx.request.body.numPlayers);
|
|
2160
|
+
const game = games.find((g) => g.name === gameName);
|
|
2161
|
+
if (!game)
|
|
2162
|
+
ctx.throw(404, 'Game ' + gameName + ' not found');
|
|
2163
|
+
if (ctx.request.body.numPlayers !== undefined &&
|
|
2164
|
+
(Number.isNaN(numPlayers) ||
|
|
2165
|
+
(game.minPlayers && numPlayers < game.minPlayers) ||
|
|
2166
|
+
(game.maxPlayers && numPlayers > game.maxPlayers))) {
|
|
2167
|
+
ctx.throw(400, 'Invalid numPlayers');
|
|
2168
|
+
}
|
|
2169
|
+
const matchID = await CreateMatch({
|
|
2170
|
+
ctx,
|
|
2171
|
+
db,
|
|
2172
|
+
game,
|
|
2173
|
+
numPlayers,
|
|
2174
|
+
setupData,
|
|
2175
|
+
uuid,
|
|
2176
|
+
unlisted,
|
|
2177
|
+
});
|
|
2178
|
+
const body = { matchID };
|
|
2179
|
+
ctx.body = body;
|
|
2180
|
+
});
|
|
2181
|
+
/**
|
|
2182
|
+
* List matches for a given game.
|
|
2183
|
+
*
|
|
2184
|
+
* This does not return matches that are marked as unlisted.
|
|
2185
|
+
*
|
|
2186
|
+
* @param {string} name - The name of the game.
|
|
2187
|
+
* @return - Array of match objects.
|
|
2188
|
+
*/
|
|
2189
|
+
router.get('/games/:name', async (ctx) => {
|
|
2190
|
+
const gameName = ctx.params.name;
|
|
2191
|
+
const isGameoverString = unwrapQuery(ctx.query.isGameover);
|
|
2192
|
+
const updatedBeforeString = unwrapQuery(ctx.query.updatedBefore);
|
|
2193
|
+
const updatedAfterString = unwrapQuery(ctx.query.updatedAfter);
|
|
2194
|
+
let isGameover;
|
|
2195
|
+
if (isGameoverString) {
|
|
2196
|
+
if (isGameoverString.toLowerCase() === 'true') {
|
|
2197
|
+
isGameover = true;
|
|
2198
|
+
}
|
|
2199
|
+
else if (isGameoverString.toLowerCase() === 'false') {
|
|
2200
|
+
isGameover = false;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
let updatedBefore;
|
|
2204
|
+
if (updatedBeforeString) {
|
|
2205
|
+
const parsedNumber = Number.parseInt(updatedBeforeString, 10);
|
|
2206
|
+
if (parsedNumber > 0) {
|
|
2207
|
+
updatedBefore = parsedNumber;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
let updatedAfter;
|
|
2211
|
+
if (updatedAfterString) {
|
|
2212
|
+
const parsedNumber = Number.parseInt(updatedAfterString, 10);
|
|
2213
|
+
if (parsedNumber > 0) {
|
|
2214
|
+
updatedAfter = parsedNumber;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
const matchList = await db.listMatches({
|
|
2218
|
+
gameName,
|
|
2219
|
+
where: {
|
|
2220
|
+
isGameover,
|
|
2221
|
+
updatedAfter,
|
|
2222
|
+
updatedBefore,
|
|
2223
|
+
},
|
|
2224
|
+
});
|
|
2225
|
+
const matches = [];
|
|
2226
|
+
for (const matchID of matchList) {
|
|
2227
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2228
|
+
metadata: true,
|
|
2229
|
+
});
|
|
2230
|
+
if (!metadata.unlisted) {
|
|
2231
|
+
matches.push(createClientMatchData(matchID, metadata));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
const body = { matches };
|
|
2235
|
+
ctx.body = body;
|
|
2236
|
+
});
|
|
2237
|
+
/**
|
|
2238
|
+
* Get data about a specific match.
|
|
2239
|
+
*
|
|
2240
|
+
* @param {string} name - The name of the game.
|
|
2241
|
+
* @param {string} id - The ID of the match.
|
|
2242
|
+
* @return - A match object.
|
|
2243
|
+
*/
|
|
2244
|
+
router.get('/games/:name/:id', async (ctx) => {
|
|
2245
|
+
const matchID = ctx.params.id;
|
|
2246
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2247
|
+
metadata: true,
|
|
2248
|
+
});
|
|
2249
|
+
if (!metadata) {
|
|
2250
|
+
ctx.throw(404, 'Match ' + matchID + ' not found');
|
|
2251
|
+
}
|
|
2252
|
+
const body = createClientMatchData(matchID, metadata);
|
|
2253
|
+
ctx.body = body;
|
|
2254
|
+
});
|
|
2255
|
+
/**
|
|
2256
|
+
* Join a given match.
|
|
2257
|
+
*
|
|
2258
|
+
* @param {string} name - The name of the game.
|
|
2259
|
+
* @param {string} id - The ID of the match.
|
|
2260
|
+
* @param {string} playerID - The ID of the player who joins. If not sent, will be assigned to the first index available.
|
|
2261
|
+
* @param {string} playerName - The name of the player who joins.
|
|
2262
|
+
* @param {object} data - The default data of the player in the match.
|
|
2263
|
+
* @return - Player ID and credentials to use when interacting in the joined match.
|
|
2264
|
+
*/
|
|
2265
|
+
router.post('/games/:name/:id/join', koaBody__default["default"](), async (ctx) => {
|
|
2266
|
+
let playerID = ctx.request.body.playerID;
|
|
2267
|
+
const playerName = ctx.request.body.playerName;
|
|
2268
|
+
const data = ctx.request.body.data;
|
|
2269
|
+
const matchID = ctx.params.id;
|
|
2270
|
+
if (!playerName) {
|
|
2271
|
+
ctx.throw(403, 'playerName is required');
|
|
2272
|
+
}
|
|
2273
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2274
|
+
metadata: true,
|
|
2275
|
+
});
|
|
2276
|
+
if (!metadata) {
|
|
2277
|
+
ctx.throw(404, 'Match ' + matchID + ' not found');
|
|
2278
|
+
}
|
|
2279
|
+
if (typeof playerID === 'undefined' || playerID === null) {
|
|
2280
|
+
playerID = getFirstAvailablePlayerID(metadata.players);
|
|
2281
|
+
if (playerID === undefined) {
|
|
2282
|
+
const numPlayers = getNumPlayers(metadata.players);
|
|
2283
|
+
ctx.throw(409, `Match ${matchID} reached maximum number of players (${numPlayers})`);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
if (!metadata.players[playerID]) {
|
|
2287
|
+
ctx.throw(404, 'Player ' + playerID + ' not found');
|
|
2288
|
+
}
|
|
2289
|
+
if (metadata.players[playerID].name) {
|
|
2290
|
+
ctx.throw(409, 'Player ' + playerID + ' not available');
|
|
2291
|
+
}
|
|
2292
|
+
if (data) {
|
|
2293
|
+
metadata.players[playerID].data = data;
|
|
2294
|
+
}
|
|
2295
|
+
metadata.players[playerID].name = playerName;
|
|
2296
|
+
const playerCredentials = await auth.generateCredentials(ctx);
|
|
2297
|
+
metadata.players[playerID].credentials = playerCredentials;
|
|
2298
|
+
await db.setMetadata(matchID, metadata);
|
|
2299
|
+
const body = { playerID, playerCredentials };
|
|
2300
|
+
ctx.body = body;
|
|
2301
|
+
});
|
|
2302
|
+
/**
|
|
2303
|
+
* Leave a given match.
|
|
2304
|
+
*
|
|
2305
|
+
* @param {string} name - The name of the game.
|
|
2306
|
+
* @param {string} id - The ID of the match.
|
|
2307
|
+
* @param {string} playerID - The ID of the player who leaves.
|
|
2308
|
+
* @param {string} credentials - The credentials of the player who leaves.
|
|
2309
|
+
* @return - Nothing.
|
|
2310
|
+
*/
|
|
2311
|
+
router.post('/games/:name/:id/leave', koaBody__default["default"](), async (ctx) => {
|
|
2312
|
+
const matchID = ctx.params.id;
|
|
2313
|
+
const playerID = ctx.request.body.playerID;
|
|
2314
|
+
const credentials = ctx.request.body.credentials;
|
|
2315
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2316
|
+
metadata: true,
|
|
2317
|
+
});
|
|
2318
|
+
if (typeof playerID === 'undefined' || playerID === null) {
|
|
2319
|
+
ctx.throw(403, 'playerID is required');
|
|
2320
|
+
}
|
|
2321
|
+
if (!metadata) {
|
|
2322
|
+
ctx.throw(404, 'Match ' + matchID + ' not found');
|
|
2323
|
+
}
|
|
2324
|
+
if (!metadata.players[playerID]) {
|
|
2325
|
+
ctx.throw(404, 'Player ' + playerID + ' not found');
|
|
2326
|
+
}
|
|
2327
|
+
const isAuthorized = await auth.authenticateCredentials({
|
|
2328
|
+
playerID,
|
|
2329
|
+
credentials,
|
|
2330
|
+
metadata,
|
|
2331
|
+
});
|
|
2332
|
+
if (!isAuthorized) {
|
|
2333
|
+
ctx.throw(403, 'Invalid credentials ' + credentials);
|
|
2334
|
+
}
|
|
2335
|
+
delete metadata.players[playerID].name;
|
|
2336
|
+
delete metadata.players[playerID].credentials;
|
|
2337
|
+
const hasPlayers = Object.values(metadata.players).some(({ name }) => name);
|
|
2338
|
+
await (hasPlayers
|
|
2339
|
+
? db.setMetadata(matchID, metadata) // Update metadata.
|
|
2340
|
+
: db.wipe(matchID)); // Delete match.
|
|
2341
|
+
ctx.body = {};
|
|
2342
|
+
});
|
|
2343
|
+
/**
|
|
2344
|
+
* Start a new match based on another existing match.
|
|
2345
|
+
*
|
|
2346
|
+
* @param {string} name - The name of the game.
|
|
2347
|
+
* @param {string} id - The ID of the match.
|
|
2348
|
+
* @param {string} playerID - The ID of the player creating the match.
|
|
2349
|
+
* @param {string} credentials - The credentials of the player creating the match.
|
|
2350
|
+
* @param {boolean} unlisted - Whether the match should be excluded from public listing.
|
|
2351
|
+
* @return - The ID of the new match.
|
|
2352
|
+
*/
|
|
2353
|
+
router.post('/games/:name/:id/playAgain', koaBody__default["default"](), async (ctx) => {
|
|
2354
|
+
const gameName = ctx.params.name;
|
|
2355
|
+
const matchID = ctx.params.id;
|
|
2356
|
+
const playerID = ctx.request.body.playerID;
|
|
2357
|
+
const credentials = ctx.request.body.credentials;
|
|
2358
|
+
const unlisted = ctx.request.body.unlisted;
|
|
2359
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2360
|
+
metadata: true,
|
|
2361
|
+
});
|
|
2362
|
+
if (typeof playerID === 'undefined' || playerID === null) {
|
|
2363
|
+
ctx.throw(403, 'playerID is required');
|
|
2364
|
+
}
|
|
2365
|
+
if (!metadata) {
|
|
2366
|
+
ctx.throw(404, 'Match ' + matchID + ' not found');
|
|
2367
|
+
}
|
|
2368
|
+
if (!metadata.players[playerID]) {
|
|
2369
|
+
ctx.throw(404, 'Player ' + playerID + ' not found');
|
|
2370
|
+
}
|
|
2371
|
+
const isAuthorized = await auth.authenticateCredentials({
|
|
2372
|
+
playerID,
|
|
2373
|
+
credentials,
|
|
2374
|
+
metadata,
|
|
2375
|
+
});
|
|
2376
|
+
if (!isAuthorized) {
|
|
2377
|
+
ctx.throw(403, 'Invalid credentials ' + credentials);
|
|
2378
|
+
}
|
|
2379
|
+
// Check if nextMatch is already set, if so, return that id.
|
|
2380
|
+
if (metadata.nextMatchID) {
|
|
2381
|
+
ctx.body = { nextMatchID: metadata.nextMatchID };
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
// User-data to pass to the game setup function.
|
|
2385
|
+
const setupData = ctx.request.body.setupData || metadata.setupData;
|
|
2386
|
+
// The number of players for this game instance.
|
|
2387
|
+
const numPlayers = Number.parseInt(ctx.request.body.numPlayers) ||
|
|
2388
|
+
// eslint-disable-next-line unicorn/explicit-length-check
|
|
2389
|
+
Object.keys(metadata.players).length;
|
|
2390
|
+
const game = games.find((g) => g.name === gameName);
|
|
2391
|
+
const nextMatchID = await CreateMatch({
|
|
2392
|
+
ctx,
|
|
2393
|
+
db,
|
|
2394
|
+
game,
|
|
2395
|
+
numPlayers,
|
|
2396
|
+
setupData,
|
|
2397
|
+
uuid,
|
|
2398
|
+
unlisted,
|
|
2399
|
+
});
|
|
2400
|
+
metadata.nextMatchID = nextMatchID;
|
|
2401
|
+
await db.setMetadata(matchID, metadata);
|
|
2402
|
+
const body = { nextMatchID };
|
|
2403
|
+
ctx.body = body;
|
|
2404
|
+
});
|
|
2405
|
+
const updatePlayerMetadata = async (ctx) => {
|
|
2406
|
+
const matchID = ctx.params.id;
|
|
2407
|
+
const playerID = ctx.request.body.playerID;
|
|
2408
|
+
const credentials = ctx.request.body.credentials;
|
|
2409
|
+
const newName = ctx.request.body.newName;
|
|
2410
|
+
const data = ctx.request.body.data;
|
|
2411
|
+
const { metadata } = await db.fetch(matchID, {
|
|
2412
|
+
metadata: true,
|
|
2413
|
+
});
|
|
2414
|
+
if (typeof playerID === 'undefined') {
|
|
2415
|
+
ctx.throw(403, 'playerID is required');
|
|
2416
|
+
}
|
|
2417
|
+
if (data === undefined && !newName) {
|
|
2418
|
+
ctx.throw(403, 'newName or data is required');
|
|
2419
|
+
}
|
|
2420
|
+
if (newName && typeof newName !== 'string') {
|
|
2421
|
+
ctx.throw(403, `newName must be a string, got ${typeof newName}`);
|
|
2422
|
+
}
|
|
2423
|
+
if (!metadata) {
|
|
2424
|
+
ctx.throw(404, 'Match ' + matchID + ' not found');
|
|
2425
|
+
}
|
|
2426
|
+
if (!metadata.players[playerID]) {
|
|
2427
|
+
ctx.throw(404, 'Player ' + playerID + ' not found');
|
|
2428
|
+
}
|
|
2429
|
+
const isAuthorized = await auth.authenticateCredentials({
|
|
2430
|
+
playerID,
|
|
2431
|
+
credentials,
|
|
2432
|
+
metadata,
|
|
2433
|
+
});
|
|
2434
|
+
if (!isAuthorized) {
|
|
2435
|
+
ctx.throw(403, 'Invalid credentials ' + credentials);
|
|
2436
|
+
}
|
|
2437
|
+
if (newName) {
|
|
2438
|
+
metadata.players[playerID].name = newName;
|
|
2439
|
+
}
|
|
2440
|
+
if (data) {
|
|
2441
|
+
metadata.players[playerID].data = data;
|
|
2442
|
+
}
|
|
2443
|
+
await db.setMetadata(matchID, metadata);
|
|
2444
|
+
ctx.body = {};
|
|
2445
|
+
};
|
|
2446
|
+
/**
|
|
2447
|
+
* Change the name of a player in a given match.
|
|
2448
|
+
*
|
|
2449
|
+
* @param {string} name - The name of the game.
|
|
2450
|
+
* @param {string} id - The ID of the match.
|
|
2451
|
+
* @param {string} playerID - The ID of the player.
|
|
2452
|
+
* @param {string} credentials - The credentials of the player.
|
|
2453
|
+
* @param {object} newName - The new name of the player in the match.
|
|
2454
|
+
* @return - Nothing.
|
|
2455
|
+
*/
|
|
2456
|
+
router.post('/games/:name/:id/rename', koaBody__default["default"](), async (ctx) => {
|
|
2457
|
+
console.warn('This endpoint /rename is deprecated. Please use /update instead.');
|
|
2458
|
+
await updatePlayerMetadata(ctx);
|
|
2459
|
+
});
|
|
2460
|
+
/**
|
|
2461
|
+
* Update the player's data for a given match.
|
|
2462
|
+
*
|
|
2463
|
+
* @param {string} name - The name of the game.
|
|
2464
|
+
* @param {string} id - The ID of the match.
|
|
2465
|
+
* @param {string} playerID - The ID of the player.
|
|
2466
|
+
* @param {string} credentials - The credentials of the player.
|
|
2467
|
+
* @param {object} newName - The new name of the player in the match.
|
|
2468
|
+
* @param {object} data - The new data of the player in the match.
|
|
2469
|
+
* @return - Nothing.
|
|
2470
|
+
*/
|
|
2471
|
+
router.post('/games/:name/:id/update', koaBody__default["default"](), updatePlayerMetadata);
|
|
2472
|
+
return router;
|
|
2473
|
+
};
|
|
2474
|
+
const configureApp = (app, router, origins) => {
|
|
2475
|
+
app.use(cors__default["default"]({
|
|
2476
|
+
// Set Access-Control-Allow-Origin header for allowed origins.
|
|
2477
|
+
origin: (ctx) => {
|
|
2478
|
+
const origin = ctx.get('Origin');
|
|
2479
|
+
return isOriginAllowed(origin, origins) ? origin : '';
|
|
2480
|
+
},
|
|
2481
|
+
}));
|
|
2482
|
+
// If API_SECRET is set, then require that requests set an
|
|
2483
|
+
// api-secret header that is set to the same value.
|
|
2484
|
+
app.use(async (ctx, next) => {
|
|
2485
|
+
if (!!process.env.API_SECRET &&
|
|
2486
|
+
ctx.request.headers['api-secret'] !== process.env.API_SECRET) {
|
|
2487
|
+
ctx.throw(403, 'Invalid API secret');
|
|
2488
|
+
}
|
|
2489
|
+
await next();
|
|
2490
|
+
});
|
|
2491
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
2492
|
+
};
|
|
2493
|
+
/**
|
|
2494
|
+
* Check if a request’s origin header is allowed for CORS.
|
|
2495
|
+
* Adapted from `cors` package: https://github.com/expressjs/cors
|
|
2496
|
+
* @param origin Request origin to test.
|
|
2497
|
+
* @param allowedOrigin Origin(s) that are allowed to connect via CORS.
|
|
2498
|
+
* @returns `true` if the origin matched at least one of the allowed origins.
|
|
2499
|
+
*/
|
|
2500
|
+
function isOriginAllowed(origin, allowedOrigin) {
|
|
2501
|
+
if (Array.isArray(allowedOrigin)) {
|
|
2502
|
+
for (const entry of allowedOrigin) {
|
|
2503
|
+
if (isOriginAllowed(origin, entry)) {
|
|
2504
|
+
return true;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
return false;
|
|
2508
|
+
}
|
|
2509
|
+
else if (typeof allowedOrigin === 'string') {
|
|
2510
|
+
return origin === allowedOrigin;
|
|
2511
|
+
}
|
|
2512
|
+
else if (allowedOrigin instanceof RegExp) {
|
|
2513
|
+
return allowedOrigin.test(origin);
|
|
2514
|
+
}
|
|
2515
|
+
else {
|
|
2516
|
+
return !!allowedOrigin;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
var Type;
|
|
2521
|
+
(function (Type) {
|
|
2522
|
+
Type[Type["SYNC"] = 0] = "SYNC";
|
|
2523
|
+
Type[Type["ASYNC"] = 1] = "ASYNC";
|
|
2524
|
+
})(Type || (Type = {}));
|
|
2525
|
+
/**
|
|
2526
|
+
* Type guard that checks if a storage implementation is synchronous.
|
|
2527
|
+
*/
|
|
2528
|
+
function isSynchronous(storageAPI) {
|
|
2529
|
+
return storageAPI.type() === Type.SYNC;
|
|
2530
|
+
}
|
|
2531
|
+
class Async {
|
|
2532
|
+
/* istanbul ignore next */
|
|
2533
|
+
type() {
|
|
2534
|
+
/* istanbul ignore next */
|
|
2535
|
+
return Type.ASYNC;
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Create a new match.
|
|
2539
|
+
*
|
|
2540
|
+
* This might just need to call setState and setMetadata in
|
|
2541
|
+
* most implementations.
|
|
2542
|
+
*
|
|
2543
|
+
* However, it exists as a separate call so that the
|
|
2544
|
+
* implementation can provision things differently when
|
|
2545
|
+
* a match is created. For example, it might stow away the
|
|
2546
|
+
* initial match state in a separate field for easier retrieval.
|
|
2547
|
+
*/
|
|
2548
|
+
/* istanbul ignore next */
|
|
2549
|
+
async createMatch(matchID, opts) {
|
|
2550
|
+
if (this.createGame) {
|
|
2551
|
+
console.warn('The database connector does not implement a createMatch method.', '\nUsing the deprecated createGame method instead.');
|
|
2552
|
+
return this.createGame(matchID, opts);
|
|
2553
|
+
}
|
|
2554
|
+
else {
|
|
2555
|
+
console.error('The database connector does not implement a createMatch method.');
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Return all matches.
|
|
2560
|
+
*/
|
|
2561
|
+
/* istanbul ignore next */
|
|
2562
|
+
async listMatches(opts) {
|
|
2563
|
+
if (this.listGames) {
|
|
2564
|
+
console.warn('The database connector does not implement a listMatches method.', '\nUsing the deprecated listGames method instead.');
|
|
2565
|
+
return this.listGames(opts);
|
|
2566
|
+
}
|
|
2567
|
+
else {
|
|
2568
|
+
console.error('The database connector does not implement a listMatches method.');
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
class Sync {
|
|
2573
|
+
type() {
|
|
2574
|
+
return Type.SYNC;
|
|
2575
|
+
}
|
|
2576
|
+
/**
|
|
2577
|
+
* Connect.
|
|
2578
|
+
*/
|
|
2579
|
+
connect() {
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Create a new match.
|
|
2584
|
+
*
|
|
2585
|
+
* This might just need to call setState and setMetadata in
|
|
2586
|
+
* most implementations.
|
|
2587
|
+
*
|
|
2588
|
+
* However, it exists as a separate call so that the
|
|
2589
|
+
* implementation can provision things differently when
|
|
2590
|
+
* a match is created. For example, it might stow away the
|
|
2591
|
+
* initial match state in a separate field for easier retrieval.
|
|
2592
|
+
*/
|
|
2593
|
+
/* istanbul ignore next */
|
|
2594
|
+
createMatch(matchID, opts) {
|
|
2595
|
+
if (this.createGame) {
|
|
2596
|
+
console.warn('The database connector does not implement a createMatch method.', '\nUsing the deprecated createGame method instead.');
|
|
2597
|
+
return this.createGame(matchID, opts);
|
|
2598
|
+
}
|
|
2599
|
+
else {
|
|
2600
|
+
console.error('The database connector does not implement a createMatch method.');
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Return all matches.
|
|
2605
|
+
*/
|
|
2606
|
+
/* istanbul ignore next */
|
|
2607
|
+
listMatches(opts) {
|
|
2608
|
+
if (this.listGames) {
|
|
2609
|
+
console.warn('The database connector does not implement a listMatches method.', '\nUsing the deprecated listGames method instead.');
|
|
2610
|
+
return this.listGames(opts);
|
|
2611
|
+
}
|
|
2612
|
+
else {
|
|
2613
|
+
console.error('The database connector does not implement a listMatches method.');
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/*
|
|
2619
|
+
* Copyright 2017 The boardgame.io Authors
|
|
2620
|
+
*
|
|
2621
|
+
* Use of this source code is governed by a MIT-style
|
|
2622
|
+
* license that can be found in the LICENSE file or at
|
|
2623
|
+
* https://opensource.org/licenses/MIT.
|
|
2624
|
+
*/
|
|
2625
|
+
/**
|
|
2626
|
+
* InMemory data storage.
|
|
2627
|
+
*/
|
|
2628
|
+
class InMemory extends Sync {
|
|
2629
|
+
/**
|
|
2630
|
+
* Creates a new InMemory storage.
|
|
2631
|
+
*/
|
|
2632
|
+
constructor() {
|
|
2633
|
+
super();
|
|
2634
|
+
this.state = new Map();
|
|
2635
|
+
this.initial = new Map();
|
|
2636
|
+
this.metadata = new Map();
|
|
2637
|
+
this.log = new Map();
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Create a new match.
|
|
2641
|
+
*
|
|
2642
|
+
* @override
|
|
2643
|
+
*/
|
|
2644
|
+
createMatch(matchID, opts) {
|
|
2645
|
+
this.initial.set(matchID, opts.initialState);
|
|
2646
|
+
this.setState(matchID, opts.initialState);
|
|
2647
|
+
this.setMetadata(matchID, opts.metadata);
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Write the match metadata to the in-memory object.
|
|
2651
|
+
*/
|
|
2652
|
+
setMetadata(matchID, metadata) {
|
|
2653
|
+
this.metadata.set(matchID, metadata);
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* Write the match state to the in-memory object.
|
|
2657
|
+
*/
|
|
2658
|
+
setState(matchID, state, deltalog) {
|
|
2659
|
+
if (deltalog && deltalog.length > 0) {
|
|
2660
|
+
const log = this.log.get(matchID) || [];
|
|
2661
|
+
this.log.set(matchID, [...log, ...deltalog]);
|
|
2662
|
+
}
|
|
2663
|
+
this.state.set(matchID, state);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Fetches state for a particular matchID.
|
|
2667
|
+
*/
|
|
2668
|
+
fetch(matchID, opts) {
|
|
2669
|
+
const result = {};
|
|
2670
|
+
if (opts.state) {
|
|
2671
|
+
result.state = this.state.get(matchID);
|
|
2672
|
+
}
|
|
2673
|
+
if (opts.metadata) {
|
|
2674
|
+
result.metadata = this.metadata.get(matchID);
|
|
2675
|
+
}
|
|
2676
|
+
if (opts.log) {
|
|
2677
|
+
result.log = this.log.get(matchID) || [];
|
|
2678
|
+
}
|
|
2679
|
+
if (opts.initialState) {
|
|
2680
|
+
result.initialState = this.initial.get(matchID);
|
|
2681
|
+
}
|
|
2682
|
+
return result;
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* Remove the match state from the in-memory object.
|
|
2686
|
+
*/
|
|
2687
|
+
wipe(matchID) {
|
|
2688
|
+
this.state.delete(matchID);
|
|
2689
|
+
this.metadata.delete(matchID);
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Return all keys.
|
|
2693
|
+
*
|
|
2694
|
+
* @override
|
|
2695
|
+
*/
|
|
2696
|
+
listMatches(opts) {
|
|
2697
|
+
return [...this.metadata.entries()]
|
|
2698
|
+
.filter(([, metadata]) => {
|
|
2699
|
+
if (!opts) {
|
|
2700
|
+
return true;
|
|
2701
|
+
}
|
|
2702
|
+
if (opts.gameName !== undefined &&
|
|
2703
|
+
metadata.gameName !== opts.gameName) {
|
|
2704
|
+
return false;
|
|
2705
|
+
}
|
|
2706
|
+
if (opts.where !== undefined) {
|
|
2707
|
+
if (opts.where.isGameover !== undefined) {
|
|
2708
|
+
const isGameover = metadata.gameover !== undefined;
|
|
2709
|
+
if (isGameover !== opts.where.isGameover) {
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
if (opts.where.updatedBefore !== undefined &&
|
|
2714
|
+
metadata.updatedAt >= opts.where.updatedBefore) {
|
|
2715
|
+
return false;
|
|
2716
|
+
}
|
|
2717
|
+
if (opts.where.updatedAfter !== undefined &&
|
|
2718
|
+
metadata.updatedAt <= opts.where.updatedAfter) {
|
|
2719
|
+
return false;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return true;
|
|
2723
|
+
})
|
|
2724
|
+
.map(([key]) => key);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/**
|
|
2729
|
+
* FlatFile data storage.
|
|
2730
|
+
*/
|
|
2731
|
+
class FlatFile extends Async {
|
|
2732
|
+
constructor({ dir, logging, ttl }) {
|
|
2733
|
+
super();
|
|
2734
|
+
this.games = require('node-persist');
|
|
2735
|
+
this.dir = dir;
|
|
2736
|
+
this.logging = logging || false;
|
|
2737
|
+
this.ttl = ttl || false;
|
|
2738
|
+
this.fileQueues = {};
|
|
2739
|
+
}
|
|
2740
|
+
async chainRequest(key, request) {
|
|
2741
|
+
if (!(key in this.fileQueues))
|
|
2742
|
+
this.fileQueues[key] = Promise.resolve();
|
|
2743
|
+
this.fileQueues[key] = this.fileQueues[key].then(request, request);
|
|
2744
|
+
return this.fileQueues[key];
|
|
2745
|
+
}
|
|
2746
|
+
async getItem(key) {
|
|
2747
|
+
return this.chainRequest(key, () => this.games.getItem(key));
|
|
2748
|
+
}
|
|
2749
|
+
async setItem(key, value) {
|
|
2750
|
+
return this.chainRequest(key, () => this.games.setItem(key, value));
|
|
2751
|
+
}
|
|
2752
|
+
async removeItem(key) {
|
|
2753
|
+
return this.chainRequest(key, () => this.games.removeItem(key));
|
|
2754
|
+
}
|
|
2755
|
+
async connect() {
|
|
2756
|
+
await this.games.init({
|
|
2757
|
+
dir: this.dir,
|
|
2758
|
+
logging: this.logging,
|
|
2759
|
+
ttl: this.ttl,
|
|
2760
|
+
});
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Create new match.
|
|
2765
|
+
*
|
|
2766
|
+
* @param matchID
|
|
2767
|
+
* @param opts
|
|
2768
|
+
* @override
|
|
2769
|
+
*/
|
|
2770
|
+
async createMatch(matchID, opts) {
|
|
2771
|
+
// Store initial state separately for easy retrieval later.
|
|
2772
|
+
const key = InitialStateKey(matchID);
|
|
2773
|
+
await this.setItem(key, opts.initialState);
|
|
2774
|
+
await this.setState(matchID, opts.initialState);
|
|
2775
|
+
await this.setMetadata(matchID, opts.metadata);
|
|
2776
|
+
}
|
|
2777
|
+
async fetch(matchID, opts) {
|
|
2778
|
+
const result = {};
|
|
2779
|
+
if (opts.state) {
|
|
2780
|
+
result.state = (await this.getItem(matchID));
|
|
2781
|
+
}
|
|
2782
|
+
if (opts.metadata) {
|
|
2783
|
+
const key = MetadataKey(matchID);
|
|
2784
|
+
result.metadata = (await this.getItem(key));
|
|
2785
|
+
}
|
|
2786
|
+
if (opts.log) {
|
|
2787
|
+
const key = LogKey(matchID);
|
|
2788
|
+
result.log = (await this.getItem(key));
|
|
2789
|
+
}
|
|
2790
|
+
if (opts.initialState) {
|
|
2791
|
+
const key = InitialStateKey(matchID);
|
|
2792
|
+
result.initialState = (await this.getItem(key));
|
|
2793
|
+
}
|
|
2794
|
+
return result;
|
|
2795
|
+
}
|
|
2796
|
+
async clear() {
|
|
2797
|
+
return this.games.clear();
|
|
2798
|
+
}
|
|
2799
|
+
async setState(id, state, deltalog) {
|
|
2800
|
+
if (deltalog && deltalog.length > 0) {
|
|
2801
|
+
const key = LogKey(id);
|
|
2802
|
+
const log = (await this.getItem(key)) || [];
|
|
2803
|
+
await this.setItem(key, [...log, ...deltalog]);
|
|
2804
|
+
}
|
|
2805
|
+
return await this.setItem(id, state);
|
|
2806
|
+
}
|
|
2807
|
+
async setMetadata(id, metadata) {
|
|
2808
|
+
const key = MetadataKey(id);
|
|
2809
|
+
return await this.setItem(key, metadata);
|
|
2810
|
+
}
|
|
2811
|
+
async wipe(id) {
|
|
2812
|
+
const keys = await this.games.keys();
|
|
2813
|
+
if (!keys.includes(id))
|
|
2814
|
+
return;
|
|
2815
|
+
await this.removeItem(id);
|
|
2816
|
+
await this.removeItem(InitialStateKey(id));
|
|
2817
|
+
await this.removeItem(LogKey(id));
|
|
2818
|
+
await this.removeItem(MetadataKey(id));
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* List matches IDs.
|
|
2822
|
+
*
|
|
2823
|
+
* @param opts
|
|
2824
|
+
* @override
|
|
2825
|
+
*/
|
|
2826
|
+
async listMatches(opts) {
|
|
2827
|
+
const keys = await this.games.keys();
|
|
2828
|
+
const suffix = ':metadata';
|
|
2829
|
+
const arr = await Promise.all(keys.map(async (k) => {
|
|
2830
|
+
if (!k.endsWith(suffix)) {
|
|
2831
|
+
return false;
|
|
2832
|
+
}
|
|
2833
|
+
const matchID = k.slice(0, k.length - suffix.length);
|
|
2834
|
+
if (!opts) {
|
|
2835
|
+
return matchID;
|
|
2836
|
+
}
|
|
2837
|
+
const game = await this.fetch(matchID, {
|
|
2838
|
+
state: true,
|
|
2839
|
+
metadata: true,
|
|
2840
|
+
});
|
|
2841
|
+
if (opts.gameName && opts.gameName !== game.metadata.gameName) {
|
|
2842
|
+
return false;
|
|
2843
|
+
}
|
|
2844
|
+
if (opts.where !== undefined) {
|
|
2845
|
+
if (typeof opts.where.isGameover !== 'undefined') {
|
|
2846
|
+
const isGameover = typeof game.metadata.gameover !== 'undefined';
|
|
2847
|
+
if (isGameover !== opts.where.isGameover) {
|
|
2848
|
+
return false;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
if (typeof opts.where.updatedBefore !== 'undefined' &&
|
|
2852
|
+
game.metadata.updatedAt >= opts.where.updatedBefore) {
|
|
2853
|
+
return false;
|
|
2854
|
+
}
|
|
2855
|
+
if (typeof opts.where.updatedAfter !== 'undefined' &&
|
|
2856
|
+
game.metadata.updatedAt <= opts.where.updatedAfter) {
|
|
2857
|
+
return false;
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
return matchID;
|
|
2861
|
+
}));
|
|
2862
|
+
return arr.filter((r) => typeof r === 'string');
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
function InitialStateKey(matchID) {
|
|
2866
|
+
return `${matchID}:initial`;
|
|
2867
|
+
}
|
|
2868
|
+
function MetadataKey(matchID) {
|
|
2869
|
+
return `${matchID}:metadata`;
|
|
2870
|
+
}
|
|
2871
|
+
function LogKey(matchID) {
|
|
2872
|
+
return `${matchID}:log`;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
const DBFromEnv = () => {
|
|
2876
|
+
return process.env.FLATFILE_DIR
|
|
2877
|
+
? new FlatFile({
|
|
2878
|
+
dir: process.env.FLATFILE_DIR,
|
|
2879
|
+
})
|
|
2880
|
+
: new InMemory();
|
|
2881
|
+
};
|
|
2882
|
+
|
|
2883
|
+
/**
|
|
2884
|
+
* Verifies that a match has metadata and is using credentials.
|
|
2885
|
+
*/
|
|
2886
|
+
const doesMatchRequireAuthentication = (matchData) => {
|
|
2887
|
+
if (!matchData)
|
|
2888
|
+
return false;
|
|
2889
|
+
const { players } = matchData;
|
|
2890
|
+
const hasCredentials = Object.values(players).some((player) => !!(player && player.credentials));
|
|
2891
|
+
return hasCredentials;
|
|
2892
|
+
};
|
|
2893
|
+
/**
|
|
2894
|
+
* The default `authenticateCredentials` method.
|
|
2895
|
+
* Verifies that the provided credentials match the player’s metadata.
|
|
2896
|
+
*/
|
|
2897
|
+
const areCredentialsAuthentic = (actionCredentials, playerMetadata) => {
|
|
2898
|
+
if (!actionCredentials)
|
|
2899
|
+
return false;
|
|
2900
|
+
if (!playerMetadata)
|
|
2901
|
+
return false;
|
|
2902
|
+
return actionCredentials === playerMetadata.credentials;
|
|
2903
|
+
};
|
|
2904
|
+
/**
|
|
2905
|
+
* Extracts a player’s metadata from the match data object.
|
|
2906
|
+
*/
|
|
2907
|
+
const extractPlayerMetadata = (matchData, playerID) => {
|
|
2908
|
+
if (matchData && matchData.players) {
|
|
2909
|
+
return matchData.players[playerID];
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
/**
|
|
2913
|
+
* Class that provides authentication methods to the lobby server & transport.
|
|
2914
|
+
*/
|
|
2915
|
+
class Auth {
|
|
2916
|
+
constructor(opts = {}) {
|
|
2917
|
+
this.shouldAuthenticate = doesMatchRequireAuthentication;
|
|
2918
|
+
this.authenticate = areCredentialsAuthentic;
|
|
2919
|
+
/**
|
|
2920
|
+
* Generate credentials string from the Koa context.
|
|
2921
|
+
*/
|
|
2922
|
+
this.generateCredentials = () => nanoid.nanoid();
|
|
2923
|
+
if (typeof opts.authenticateCredentials === 'function') {
|
|
2924
|
+
this.authenticate = opts.authenticateCredentials;
|
|
2925
|
+
this.shouldAuthenticate = () => true;
|
|
2926
|
+
}
|
|
2927
|
+
if (typeof opts.generateCredentials === 'function') {
|
|
2928
|
+
this.generateCredentials = opts.generateCredentials;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* Resolves to true if the provided credentials are valid for the given
|
|
2933
|
+
* metadata and player IDs, or if the match does not require authentication.
|
|
2934
|
+
*/
|
|
2935
|
+
authenticateCredentials({ playerID, credentials, metadata, }) {
|
|
2936
|
+
const playerMetadata = extractPlayerMetadata(metadata, playerID);
|
|
2937
|
+
return this.shouldAuthenticate(metadata)
|
|
2938
|
+
? this.authenticate(credentials, playerMetadata)
|
|
2939
|
+
: true;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/*
|
|
2944
|
+
* Copyright 2017 The boardgame.io Authors
|
|
2945
|
+
*
|
|
2946
|
+
* Use of this source code is governed by a MIT-style
|
|
2947
|
+
* license that can be found in the LICENSE file or at
|
|
2948
|
+
* https://opensource.org/licenses/MIT.
|
|
2949
|
+
*/
|
|
2950
|
+
var UpdateErrorType;
|
|
2951
|
+
(function (UpdateErrorType) {
|
|
2952
|
+
// The action’s credentials were missing or invalid
|
|
2953
|
+
UpdateErrorType["UnauthorizedAction"] = "update/unauthorized_action";
|
|
2954
|
+
// The action’s matchID was not found
|
|
2955
|
+
UpdateErrorType["MatchNotFound"] = "update/match_not_found";
|
|
2956
|
+
// Could not apply Patch operation (rfc6902).
|
|
2957
|
+
UpdateErrorType["PatchFailed"] = "update/patch_failed";
|
|
2958
|
+
})(UpdateErrorType || (UpdateErrorType = {}));
|
|
2959
|
+
var ActionErrorType;
|
|
2960
|
+
(function (ActionErrorType) {
|
|
2961
|
+
// The action contained a stale state ID
|
|
2962
|
+
ActionErrorType["StaleStateId"] = "action/stale_state_id";
|
|
2963
|
+
// The requested move is unknown or not currently available
|
|
2964
|
+
ActionErrorType["UnavailableMove"] = "action/unavailable_move";
|
|
2965
|
+
// The move declared it was invalid (INVALID_MOVE constant)
|
|
2966
|
+
ActionErrorType["InvalidMove"] = "action/invalid_move";
|
|
2967
|
+
// The player making the action is not currently active
|
|
2968
|
+
ActionErrorType["InactivePlayer"] = "action/inactive_player";
|
|
2969
|
+
// The game has finished
|
|
2970
|
+
ActionErrorType["GameOver"] = "action/gameover";
|
|
2971
|
+
// The requested action is disabled (e.g. undo/redo, events)
|
|
2972
|
+
ActionErrorType["ActionDisabled"] = "action/action_disabled";
|
|
2973
|
+
// The requested action is not currently possible
|
|
2974
|
+
ActionErrorType["ActionInvalid"] = "action/action_invalid";
|
|
2975
|
+
// The requested action was declared invalid by a plugin
|
|
2976
|
+
ActionErrorType["PluginActionInvalid"] = "action/plugin_invalid";
|
|
2977
|
+
})(ActionErrorType || (ActionErrorType = {}));
|
|
2978
|
+
|
|
2979
|
+
/*
|
|
2980
|
+
* Copyright 2017 The boardgame.io Authors
|
|
2981
|
+
*
|
|
2982
|
+
* Use of this source code is governed by a MIT-style
|
|
2983
|
+
* license that can be found in the LICENSE file or at
|
|
2984
|
+
* https://opensource.org/licenses/MIT.
|
|
2985
|
+
*/
|
|
2986
|
+
/**
|
|
2987
|
+
* Check if the payload for the passed action contains a playerID.
|
|
2988
|
+
*/
|
|
2989
|
+
const actionHasPlayerID = (action) => action.payload.playerID !== null && action.payload.playerID !== undefined;
|
|
2990
|
+
/**
|
|
2991
|
+
* Returns true if a move can be undone.
|
|
2992
|
+
*/
|
|
2993
|
+
const CanUndoMove = (G, ctx, move) => {
|
|
2994
|
+
function HasUndoable(move) {
|
|
2995
|
+
return move.undoable !== undefined;
|
|
2996
|
+
}
|
|
2997
|
+
function IsFunction(undoable) {
|
|
2998
|
+
return undoable instanceof Function;
|
|
2999
|
+
}
|
|
3000
|
+
if (!HasUndoable(move)) {
|
|
3001
|
+
return true;
|
|
3002
|
+
}
|
|
3003
|
+
if (IsFunction(move.undoable)) {
|
|
3004
|
+
return move.undoable({ G, ctx });
|
|
3005
|
+
}
|
|
3006
|
+
return move.undoable;
|
|
3007
|
+
};
|
|
3008
|
+
/**
|
|
3009
|
+
* Update the undo and redo stacks for a move or event.
|
|
3010
|
+
*/
|
|
3011
|
+
function updateUndoRedoState(state, opts) {
|
|
3012
|
+
if (opts.game.disableUndo)
|
|
3013
|
+
return state;
|
|
3014
|
+
const undoEntry = {
|
|
3015
|
+
G: state.G,
|
|
3016
|
+
ctx: state.ctx,
|
|
3017
|
+
plugins: state.plugins,
|
|
3018
|
+
playerID: opts.action.payload.playerID || state.ctx.currentPlayer,
|
|
3019
|
+
};
|
|
3020
|
+
if (opts.action.type === 'MAKE_MOVE') {
|
|
3021
|
+
undoEntry.moveType = opts.action.payload.type;
|
|
3022
|
+
}
|
|
3023
|
+
return {
|
|
3024
|
+
...state,
|
|
3025
|
+
_undo: [...state._undo, undoEntry],
|
|
3026
|
+
// Always reset redo stack when making a move or event
|
|
3027
|
+
_redo: [],
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* Process state, adding the initial deltalog for this action.
|
|
3032
|
+
*/
|
|
3033
|
+
function initializeDeltalog(state, action, move) {
|
|
3034
|
+
// Create a log entry for this action.
|
|
3035
|
+
const logEntry = {
|
|
3036
|
+
action,
|
|
3037
|
+
_stateID: state._stateID,
|
|
3038
|
+
turn: state.ctx.turn,
|
|
3039
|
+
phase: state.ctx.phase,
|
|
3040
|
+
};
|
|
3041
|
+
const pluginLogMetadata = state.plugins.log.data.metadata;
|
|
3042
|
+
if (pluginLogMetadata !== undefined) {
|
|
3043
|
+
logEntry.metadata = pluginLogMetadata;
|
|
3044
|
+
}
|
|
3045
|
+
if (typeof move === 'object' && move.redact === true) {
|
|
3046
|
+
logEntry.redact = true;
|
|
3047
|
+
}
|
|
3048
|
+
else if (typeof move === 'object' && move.redact instanceof Function) {
|
|
3049
|
+
logEntry.redact = move.redact({ G: state.G, ctx: state.ctx });
|
|
3050
|
+
}
|
|
3051
|
+
return {
|
|
3052
|
+
...state,
|
|
3053
|
+
deltalog: [logEntry],
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Update plugin state after move/event & check if plugins consider the action to be valid.
|
|
3058
|
+
* @param state Current version of state in the reducer.
|
|
3059
|
+
* @param oldState State to revert to in case of error.
|
|
3060
|
+
* @param pluginOpts Plugin configuration options.
|
|
3061
|
+
* @returns Tuple of the new state updated after flushing plugins and the old
|
|
3062
|
+
* state augmented with an error if a plugin declared the action invalid.
|
|
3063
|
+
*/
|
|
3064
|
+
function flushAndValidatePlugins(state, oldState, pluginOpts) {
|
|
3065
|
+
const [newState, isInvalid] = FlushAndValidate(state, pluginOpts);
|
|
3066
|
+
if (!isInvalid)
|
|
3067
|
+
return [newState];
|
|
3068
|
+
return [
|
|
3069
|
+
newState,
|
|
3070
|
+
WithError(oldState, ActionErrorType.PluginActionInvalid, isInvalid),
|
|
3071
|
+
];
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* ExtractTransientsFromState
|
|
3075
|
+
*
|
|
3076
|
+
* Split out transients from the a TransientState
|
|
3077
|
+
*/
|
|
3078
|
+
function ExtractTransients(transientState) {
|
|
3079
|
+
if (!transientState) {
|
|
3080
|
+
// We preserve null for the state for legacy callers, but the transient
|
|
3081
|
+
// field should be undefined if not present to be consistent with the
|
|
3082
|
+
// code path below.
|
|
3083
|
+
return [null, undefined];
|
|
3084
|
+
}
|
|
3085
|
+
const { transients, ...state } = transientState;
|
|
3086
|
+
return [state, transients];
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* WithError
|
|
3090
|
+
*
|
|
3091
|
+
* Augment a State instance with transient error information.
|
|
3092
|
+
*/
|
|
3093
|
+
function WithError(state, errorType, payload) {
|
|
3094
|
+
const error = {
|
|
3095
|
+
type: errorType,
|
|
3096
|
+
payload,
|
|
3097
|
+
};
|
|
3098
|
+
return {
|
|
3099
|
+
...state,
|
|
3100
|
+
transients: {
|
|
3101
|
+
error,
|
|
3102
|
+
},
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Middleware for processing TransientState associated with the reducer
|
|
3107
|
+
* returned by CreateGameReducer.
|
|
3108
|
+
* This should pretty much be used everywhere you want realistic state
|
|
3109
|
+
* transitions and error handling.
|
|
3110
|
+
*/
|
|
3111
|
+
const TransientHandlingMiddleware = (store) => (next) => (action) => {
|
|
3112
|
+
const result = next(action);
|
|
3113
|
+
switch (action.type) {
|
|
3114
|
+
case STRIP_TRANSIENTS: {
|
|
3115
|
+
return result;
|
|
3116
|
+
}
|
|
3117
|
+
default: {
|
|
3118
|
+
const [, transients] = ExtractTransients(store.getState());
|
|
3119
|
+
if (typeof transients !== 'undefined') {
|
|
3120
|
+
store.dispatch(stripTransients());
|
|
3121
|
+
// Dev Note: If parent middleware needs to correlate the spawned
|
|
3122
|
+
// StripTransients action to the triggering action, instrument here.
|
|
3123
|
+
//
|
|
3124
|
+
// This is a bit tricky; for more details, see:
|
|
3125
|
+
// https://github.com/boardgameio/boardgame.io/pull/940#discussion_r636200648
|
|
3126
|
+
return {
|
|
3127
|
+
...result,
|
|
3128
|
+
transients,
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
return result;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
};
|
|
3135
|
+
/**
|
|
3136
|
+
* CreateGameReducer
|
|
3137
|
+
*
|
|
3138
|
+
* Creates the main game state reducer.
|
|
3139
|
+
*/
|
|
3140
|
+
function CreateGameReducer({ game, isClient, }) {
|
|
3141
|
+
game = ProcessGameConfig(game);
|
|
3142
|
+
/**
|
|
3143
|
+
* GameReducer
|
|
3144
|
+
*
|
|
3145
|
+
* Redux reducer that maintains the overall game state.
|
|
3146
|
+
* @param {object} state - The state before the action.
|
|
3147
|
+
* @param {object} action - A Redux action.
|
|
3148
|
+
*/
|
|
3149
|
+
return (stateWithTransients = null, action) => {
|
|
3150
|
+
let [state /*, transients */] = ExtractTransients(stateWithTransients);
|
|
3151
|
+
switch (action.type) {
|
|
3152
|
+
case STRIP_TRANSIENTS: {
|
|
3153
|
+
// This action indicates that transient metadata in the state has been
|
|
3154
|
+
// consumed and should now be stripped from the state..
|
|
3155
|
+
return state;
|
|
3156
|
+
}
|
|
3157
|
+
case GAME_EVENT: {
|
|
3158
|
+
state = { ...state, deltalog: [] };
|
|
3159
|
+
// Process game events only on the server.
|
|
3160
|
+
// These events like `endTurn` typically
|
|
3161
|
+
// contain code that may rely on secret state
|
|
3162
|
+
// and cannot be computed on the client.
|
|
3163
|
+
if (isClient) {
|
|
3164
|
+
return state;
|
|
3165
|
+
}
|
|
3166
|
+
// Disallow events once the game is over.
|
|
3167
|
+
if (state.ctx.gameover !== undefined) {
|
|
3168
|
+
error(`cannot call event after game end`);
|
|
3169
|
+
return WithError(state, ActionErrorType.GameOver);
|
|
3170
|
+
}
|
|
3171
|
+
// Ignore the event if the player isn't active.
|
|
3172
|
+
if (actionHasPlayerID(action) &&
|
|
3173
|
+
!game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)) {
|
|
3174
|
+
error(`disallowed event: ${action.payload.type}`);
|
|
3175
|
+
return WithError(state, ActionErrorType.InactivePlayer);
|
|
3176
|
+
}
|
|
3177
|
+
// Execute plugins.
|
|
3178
|
+
state = Enhance(state, {
|
|
3179
|
+
game,
|
|
3180
|
+
isClient: false,
|
|
3181
|
+
playerID: action.payload.playerID,
|
|
3182
|
+
});
|
|
3183
|
+
// Process event.
|
|
3184
|
+
let newState = game.flow.processEvent(state, action);
|
|
3185
|
+
// Execute plugins.
|
|
3186
|
+
let stateWithError;
|
|
3187
|
+
[newState, stateWithError] = flushAndValidatePlugins(newState, state, {
|
|
3188
|
+
game,
|
|
3189
|
+
isClient: false,
|
|
3190
|
+
});
|
|
3191
|
+
if (stateWithError)
|
|
3192
|
+
return stateWithError;
|
|
3193
|
+
// Update undo / redo state.
|
|
3194
|
+
newState = updateUndoRedoState(newState, { game, action });
|
|
3195
|
+
return { ...newState, _stateID: state._stateID + 1 };
|
|
3196
|
+
}
|
|
3197
|
+
case MAKE_MOVE: {
|
|
3198
|
+
const oldState = (state = { ...state, deltalog: [] });
|
|
3199
|
+
// Check whether the move is allowed at this time.
|
|
3200
|
+
const move = game.flow.getMove(state.ctx, action.payload.type, action.payload.playerID || state.ctx.currentPlayer);
|
|
3201
|
+
if (move === null) {
|
|
3202
|
+
error(`disallowed move: ${action.payload.type}`);
|
|
3203
|
+
return WithError(state, ActionErrorType.UnavailableMove);
|
|
3204
|
+
}
|
|
3205
|
+
// Don't run move on client if move says so.
|
|
3206
|
+
if (isClient && move.client === false) {
|
|
3207
|
+
return state;
|
|
3208
|
+
}
|
|
3209
|
+
// Disallow moves once the game is over.
|
|
3210
|
+
if (state.ctx.gameover !== undefined) {
|
|
3211
|
+
error(`cannot make move after game end`);
|
|
3212
|
+
return WithError(state, ActionErrorType.GameOver);
|
|
3213
|
+
}
|
|
3214
|
+
// Ignore the move if the player isn't active.
|
|
3215
|
+
if (actionHasPlayerID(action) &&
|
|
3216
|
+
!game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)) {
|
|
3217
|
+
error(`disallowed move: ${action.payload.type}`);
|
|
3218
|
+
return WithError(state, ActionErrorType.InactivePlayer);
|
|
3219
|
+
}
|
|
3220
|
+
// Execute plugins.
|
|
3221
|
+
state = Enhance(state, {
|
|
3222
|
+
game,
|
|
3223
|
+
isClient,
|
|
3224
|
+
playerID: action.payload.playerID,
|
|
3225
|
+
});
|
|
3226
|
+
// Process the move.
|
|
3227
|
+
const G = game.processMove(state, action.payload);
|
|
3228
|
+
// The game declared the move as invalid.
|
|
3229
|
+
if (G === INVALID_MOVE) {
|
|
3230
|
+
error(`invalid move: ${action.payload.type} args: ${action.payload.args}`);
|
|
3231
|
+
// TODO(#723): Marshal a nice error payload with the processed move.
|
|
3232
|
+
return WithError(state, ActionErrorType.InvalidMove);
|
|
3233
|
+
}
|
|
3234
|
+
const newState = { ...state, G };
|
|
3235
|
+
// Some plugin indicated that it is not suitable to be
|
|
3236
|
+
// materialized on the client (and must wait for the server
|
|
3237
|
+
// response instead).
|
|
3238
|
+
if (isClient && NoClient(newState, { game })) {
|
|
3239
|
+
return state;
|
|
3240
|
+
}
|
|
3241
|
+
state = newState;
|
|
3242
|
+
// If we're on the client, just process the move
|
|
3243
|
+
// and no triggers in multiplayer mode.
|
|
3244
|
+
// These will be processed on the server, which
|
|
3245
|
+
// will send back a state update.
|
|
3246
|
+
if (isClient) {
|
|
3247
|
+
let stateWithError;
|
|
3248
|
+
[state, stateWithError] = flushAndValidatePlugins(state, oldState, {
|
|
3249
|
+
game,
|
|
3250
|
+
isClient: true,
|
|
3251
|
+
});
|
|
3252
|
+
if (stateWithError)
|
|
3253
|
+
return stateWithError;
|
|
3254
|
+
return {
|
|
3255
|
+
...state,
|
|
3256
|
+
_stateID: state._stateID + 1,
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
// On the server, construct the deltalog.
|
|
3260
|
+
state = initializeDeltalog(state, action, move);
|
|
3261
|
+
// Allow the flow reducer to process any triggers that happen after moves.
|
|
3262
|
+
state = game.flow.processMove(state, action.payload);
|
|
3263
|
+
let stateWithError;
|
|
3264
|
+
[state, stateWithError] = flushAndValidatePlugins(state, oldState, {
|
|
3265
|
+
game,
|
|
3266
|
+
});
|
|
3267
|
+
if (stateWithError)
|
|
3268
|
+
return stateWithError;
|
|
3269
|
+
// Update undo / redo state.
|
|
3270
|
+
state = updateUndoRedoState(state, { game, action });
|
|
3271
|
+
return {
|
|
3272
|
+
...state,
|
|
3273
|
+
_stateID: state._stateID + 1,
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
case RESET:
|
|
3277
|
+
case UPDATE:
|
|
3278
|
+
case SYNC: {
|
|
3279
|
+
return action.state;
|
|
3280
|
+
}
|
|
3281
|
+
case UNDO: {
|
|
3282
|
+
state = { ...state, deltalog: [] };
|
|
3283
|
+
if (game.disableUndo) {
|
|
3284
|
+
error('Undo is not enabled');
|
|
3285
|
+
return WithError(state, ActionErrorType.ActionDisabled);
|
|
3286
|
+
}
|
|
3287
|
+
const { G, ctx, _undo, _redo, _stateID } = state;
|
|
3288
|
+
if (_undo.length < 2) {
|
|
3289
|
+
error(`No moves to undo`);
|
|
3290
|
+
return WithError(state, ActionErrorType.ActionInvalid);
|
|
3291
|
+
}
|
|
3292
|
+
const last = _undo[_undo.length - 1];
|
|
3293
|
+
const restore = _undo[_undo.length - 2];
|
|
3294
|
+
// Only allow players to undo their own moves.
|
|
3295
|
+
if (actionHasPlayerID(action) &&
|
|
3296
|
+
action.payload.playerID !== last.playerID) {
|
|
3297
|
+
error(`Cannot undo other players' moves`);
|
|
3298
|
+
return WithError(state, ActionErrorType.ActionInvalid);
|
|
3299
|
+
}
|
|
3300
|
+
// If undoing a move, check it is undoable.
|
|
3301
|
+
if (last.moveType) {
|
|
3302
|
+
const lastMove = game.flow.getMove(restore.ctx, last.moveType, last.playerID);
|
|
3303
|
+
if (!CanUndoMove(G, ctx, lastMove)) {
|
|
3304
|
+
error(`Move cannot be undone`);
|
|
3305
|
+
return WithError(state, ActionErrorType.ActionInvalid);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
state = initializeDeltalog(state, action);
|
|
3309
|
+
return {
|
|
3310
|
+
...state,
|
|
3311
|
+
G: restore.G,
|
|
3312
|
+
ctx: restore.ctx,
|
|
3313
|
+
plugins: restore.plugins,
|
|
3314
|
+
_stateID: _stateID + 1,
|
|
3315
|
+
_undo: _undo.slice(0, -1),
|
|
3316
|
+
_redo: [last, ..._redo],
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
case REDO: {
|
|
3320
|
+
state = { ...state, deltalog: [] };
|
|
3321
|
+
if (game.disableUndo) {
|
|
3322
|
+
error('Redo is not enabled');
|
|
3323
|
+
return WithError(state, ActionErrorType.ActionDisabled);
|
|
3324
|
+
}
|
|
3325
|
+
const { _undo, _redo, _stateID } = state;
|
|
3326
|
+
if (_redo.length === 0) {
|
|
3327
|
+
error(`No moves to redo`);
|
|
3328
|
+
return WithError(state, ActionErrorType.ActionInvalid);
|
|
3329
|
+
}
|
|
3330
|
+
const first = _redo[0];
|
|
3331
|
+
// Only allow players to redo their own undos.
|
|
3332
|
+
if (actionHasPlayerID(action) &&
|
|
3333
|
+
action.payload.playerID !== first.playerID) {
|
|
3334
|
+
error(`Cannot redo other players' moves`);
|
|
3335
|
+
return WithError(state, ActionErrorType.ActionInvalid);
|
|
3336
|
+
}
|
|
3337
|
+
state = initializeDeltalog(state, action);
|
|
3338
|
+
return {
|
|
3339
|
+
...state,
|
|
3340
|
+
G: first.G,
|
|
3341
|
+
ctx: first.ctx,
|
|
3342
|
+
plugins: first.plugins,
|
|
3343
|
+
_stateID: _stateID + 1,
|
|
3344
|
+
_undo: [..._undo, first],
|
|
3345
|
+
_redo: _redo.slice(1),
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3348
|
+
case PLUGIN: {
|
|
3349
|
+
// TODO(#723): Expose error semantics to plugin processing.
|
|
3350
|
+
return ProcessAction(state, action, { game });
|
|
3351
|
+
}
|
|
3352
|
+
case PATCH: {
|
|
3353
|
+
const oldState = state;
|
|
3354
|
+
const newState = JSON.parse(JSON.stringify(oldState));
|
|
3355
|
+
const patchError = rfc6902.applyPatch(newState, action.patch);
|
|
3356
|
+
const hasError = patchError.some((entry) => entry !== null);
|
|
3357
|
+
if (hasError) {
|
|
3358
|
+
error(`Patch ${JSON.stringify(action.patch)} apply failed`);
|
|
3359
|
+
return WithError(oldState, UpdateErrorType.PatchFailed, patchError);
|
|
3360
|
+
}
|
|
3361
|
+
else {
|
|
3362
|
+
return newState;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
default: {
|
|
3366
|
+
return state;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
/*
|
|
3373
|
+
* Copyright 2018 The boardgame.io Authors
|
|
3374
|
+
*
|
|
3375
|
+
* Use of this source code is governed by a MIT-style
|
|
3376
|
+
* license that can be found in the LICENSE file or at
|
|
3377
|
+
* https://opensource.org/licenses/MIT.
|
|
3378
|
+
*/
|
|
3379
|
+
/**
|
|
3380
|
+
* Filter match data to get a player metadata object with credentials stripped.
|
|
3381
|
+
*/
|
|
3382
|
+
const filterMatchData = (matchData) => Object.values(matchData.players).map((player) => {
|
|
3383
|
+
const { credentials, ...filteredData } = player;
|
|
3384
|
+
return filteredData;
|
|
3385
|
+
});
|
|
3386
|
+
/**
|
|
3387
|
+
* Remove player credentials from action payload
|
|
3388
|
+
*/
|
|
3389
|
+
const stripCredentialsFromAction = (action) => {
|
|
3390
|
+
const { credentials, ...payload } = action.payload;
|
|
3391
|
+
return { ...action, payload };
|
|
3392
|
+
};
|
|
3393
|
+
/**
|
|
3394
|
+
* Master
|
|
3395
|
+
*
|
|
3396
|
+
* Class that runs the game and maintains the authoritative state.
|
|
3397
|
+
* It uses the transportAPI to communicate with clients and the
|
|
3398
|
+
* storageAPI to communicate with the database.
|
|
3399
|
+
*/
|
|
3400
|
+
class Master {
|
|
3401
|
+
constructor(game, storageAPI, transportAPI, auth) {
|
|
3402
|
+
this.game = ProcessGameConfig(game);
|
|
3403
|
+
this.storageAPI = storageAPI;
|
|
3404
|
+
this.transportAPI = transportAPI;
|
|
3405
|
+
this.subscribeCallback = () => { };
|
|
3406
|
+
this.auth = auth;
|
|
3407
|
+
}
|
|
3408
|
+
subscribe(fn) {
|
|
3409
|
+
this.subscribeCallback = fn;
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Called on each move / event made by the client.
|
|
3413
|
+
* Computes the new value of the game state and returns it
|
|
3414
|
+
* along with a deltalog.
|
|
3415
|
+
*/
|
|
3416
|
+
async onUpdate(credAction, stateID, matchID, playerID) {
|
|
3417
|
+
if (!credAction || !credAction.payload) {
|
|
3418
|
+
return { error: 'missing action or action payload' };
|
|
3419
|
+
}
|
|
3420
|
+
let metadata;
|
|
3421
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3422
|
+
({ metadata } = this.storageAPI.fetch(matchID, { metadata: true }));
|
|
3423
|
+
}
|
|
3424
|
+
else {
|
|
3425
|
+
({ metadata } = await this.storageAPI.fetch(matchID, { metadata: true }));
|
|
3426
|
+
}
|
|
3427
|
+
if (this.auth) {
|
|
3428
|
+
const isAuthentic = await this.auth.authenticateCredentials({
|
|
3429
|
+
playerID,
|
|
3430
|
+
credentials: credAction.payload.credentials,
|
|
3431
|
+
metadata,
|
|
3432
|
+
});
|
|
3433
|
+
if (!isAuthentic) {
|
|
3434
|
+
return { error: 'unauthorized action' };
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
const action = stripCredentialsFromAction(credAction);
|
|
3438
|
+
const key = matchID;
|
|
3439
|
+
let state;
|
|
3440
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3441
|
+
({ state } = this.storageAPI.fetch(key, { state: true }));
|
|
3442
|
+
}
|
|
3443
|
+
else {
|
|
3444
|
+
({ state } = await this.storageAPI.fetch(key, { state: true }));
|
|
3445
|
+
}
|
|
3446
|
+
if (state === undefined) {
|
|
3447
|
+
error(`game not found, matchID=[${key}]`);
|
|
3448
|
+
return { error: 'game not found' };
|
|
3449
|
+
}
|
|
3450
|
+
if (state.ctx.gameover !== undefined) {
|
|
3451
|
+
error(`game over - matchID=[${key}] - playerID=[${playerID}]` +
|
|
3452
|
+
` - action[${action.payload.type}]`);
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const reducer = CreateGameReducer({
|
|
3456
|
+
game: this.game,
|
|
3457
|
+
});
|
|
3458
|
+
const middleware = redux.applyMiddleware(TransientHandlingMiddleware);
|
|
3459
|
+
const store = redux.createStore(reducer, state, middleware);
|
|
3460
|
+
// Only allow UNDO / REDO if there is exactly one player
|
|
3461
|
+
// that can make moves right now and the person doing the
|
|
3462
|
+
// action is that player.
|
|
3463
|
+
if (action.type == UNDO || action.type == REDO) {
|
|
3464
|
+
const hasActivePlayers = state.ctx.activePlayers !== null;
|
|
3465
|
+
const isCurrentPlayer = state.ctx.currentPlayer === playerID;
|
|
3466
|
+
if (
|
|
3467
|
+
// If activePlayers is empty, non-current players can’t undo.
|
|
3468
|
+
(!hasActivePlayers && !isCurrentPlayer) ||
|
|
3469
|
+
// If player is not active or multiple players are active, can’t undo.
|
|
3470
|
+
(hasActivePlayers &&
|
|
3471
|
+
(state.ctx.activePlayers[playerID] === undefined ||
|
|
3472
|
+
Object.keys(state.ctx.activePlayers).length > 1))) {
|
|
3473
|
+
error(`playerID=[${playerID}] cannot undo / redo right now`);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
// Check whether the player is active.
|
|
3478
|
+
if (!this.game.flow.isPlayerActive(state.G, state.ctx, playerID)) {
|
|
3479
|
+
error(`player not active - playerID=[${playerID}]` +
|
|
3480
|
+
` - action[${action.payload.type}]`);
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
// Get move for further checks
|
|
3484
|
+
const move = action.type == MAKE_MOVE
|
|
3485
|
+
? this.game.flow.getMove(state.ctx, action.payload.type, playerID)
|
|
3486
|
+
: null;
|
|
3487
|
+
// Check whether the player is allowed to make the move.
|
|
3488
|
+
if (action.type == MAKE_MOVE && !move) {
|
|
3489
|
+
error(`move not processed - canPlayerMakeMove=false - playerID=[${playerID}]` +
|
|
3490
|
+
` - action[${action.payload.type}]`);
|
|
3491
|
+
return;
|
|
3492
|
+
}
|
|
3493
|
+
// Check if action's stateID is different than store's stateID
|
|
3494
|
+
// and if move does not have ignoreStaleStateID truthy.
|
|
3495
|
+
if (state._stateID !== stateID &&
|
|
3496
|
+
!(move && IsLongFormMove(move) && move.ignoreStaleStateID)) {
|
|
3497
|
+
error(`invalid stateID, was=[${stateID}], expected=[${state._stateID}]` +
|
|
3498
|
+
` - playerID=[${playerID}] - action[${action.payload.type}]`);
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
const prevState = store.getState();
|
|
3502
|
+
// Update server's version of the store.
|
|
3503
|
+
store.dispatch(action);
|
|
3504
|
+
state = store.getState();
|
|
3505
|
+
this.subscribeCallback({
|
|
3506
|
+
state,
|
|
3507
|
+
action,
|
|
3508
|
+
matchID,
|
|
3509
|
+
});
|
|
3510
|
+
if (this.game.deltaState) {
|
|
3511
|
+
this.transportAPI.sendAll({
|
|
3512
|
+
type: 'patch',
|
|
3513
|
+
args: [matchID, stateID, prevState, state],
|
|
3514
|
+
});
|
|
3515
|
+
}
|
|
3516
|
+
else {
|
|
3517
|
+
this.transportAPI.sendAll({
|
|
3518
|
+
type: 'update',
|
|
3519
|
+
args: [matchID, state],
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
const { deltalog, ...stateWithoutDeltalog } = state;
|
|
3523
|
+
let newMetadata;
|
|
3524
|
+
if (metadata &&
|
|
3525
|
+
(metadata.gameover === undefined || metadata.gameover === null)) {
|
|
3526
|
+
newMetadata = {
|
|
3527
|
+
...metadata,
|
|
3528
|
+
updatedAt: Date.now(),
|
|
3529
|
+
};
|
|
3530
|
+
if (state.ctx.gameover !== undefined) {
|
|
3531
|
+
newMetadata.gameover = state.ctx.gameover;
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3535
|
+
this.storageAPI.setState(key, stateWithoutDeltalog, deltalog);
|
|
3536
|
+
if (newMetadata)
|
|
3537
|
+
this.storageAPI.setMetadata(key, newMetadata);
|
|
3538
|
+
}
|
|
3539
|
+
else {
|
|
3540
|
+
const writes = [
|
|
3541
|
+
this.storageAPI.setState(key, stateWithoutDeltalog, deltalog),
|
|
3542
|
+
];
|
|
3543
|
+
if (newMetadata) {
|
|
3544
|
+
writes.push(this.storageAPI.setMetadata(key, newMetadata));
|
|
3545
|
+
}
|
|
3546
|
+
await Promise.all(writes);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* Called when the client connects / reconnects.
|
|
3551
|
+
* Returns the latest game state and the entire log.
|
|
3552
|
+
*/
|
|
3553
|
+
async onSync(matchID, playerID, credentials, numPlayers = 2) {
|
|
3554
|
+
const key = matchID;
|
|
3555
|
+
const fetchOpts = {
|
|
3556
|
+
state: true,
|
|
3557
|
+
metadata: true,
|
|
3558
|
+
log: true,
|
|
3559
|
+
initialState: true,
|
|
3560
|
+
};
|
|
3561
|
+
const fetchResult = isSynchronous(this.storageAPI)
|
|
3562
|
+
? this.storageAPI.fetch(key, fetchOpts)
|
|
3563
|
+
: await this.storageAPI.fetch(key, fetchOpts);
|
|
3564
|
+
let { state, initialState, log, metadata } = fetchResult;
|
|
3565
|
+
if (this.auth && playerID !== undefined && playerID !== null) {
|
|
3566
|
+
const isAuthentic = await this.auth.authenticateCredentials({
|
|
3567
|
+
playerID,
|
|
3568
|
+
credentials,
|
|
3569
|
+
metadata,
|
|
3570
|
+
});
|
|
3571
|
+
if (!isAuthentic) {
|
|
3572
|
+
return { error: 'unauthorized' };
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
// If the game doesn't exist, then create one on demand.
|
|
3576
|
+
// TODO: Move this out of the sync call.
|
|
3577
|
+
if (state === undefined) {
|
|
3578
|
+
const match = createMatch({
|
|
3579
|
+
game: this.game,
|
|
3580
|
+
unlisted: true,
|
|
3581
|
+
numPlayers,
|
|
3582
|
+
setupData: undefined,
|
|
3583
|
+
});
|
|
3584
|
+
if ('setupDataError' in match) {
|
|
3585
|
+
return { error: 'game requires setupData' };
|
|
3586
|
+
}
|
|
3587
|
+
initialState = state = match.initialState;
|
|
3588
|
+
metadata = match.metadata;
|
|
3589
|
+
this.subscribeCallback({ state, matchID });
|
|
3590
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3591
|
+
this.storageAPI.createMatch(key, { initialState, metadata });
|
|
3592
|
+
}
|
|
3593
|
+
else {
|
|
3594
|
+
await this.storageAPI.createMatch(key, { initialState, metadata });
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
const filteredMetadata = metadata ? filterMatchData(metadata) : undefined;
|
|
3598
|
+
const syncInfo = {
|
|
3599
|
+
state,
|
|
3600
|
+
log,
|
|
3601
|
+
filteredMetadata,
|
|
3602
|
+
initialState,
|
|
3603
|
+
};
|
|
3604
|
+
this.transportAPI.send({
|
|
3605
|
+
playerID,
|
|
3606
|
+
type: 'sync',
|
|
3607
|
+
args: [matchID, syncInfo],
|
|
3608
|
+
});
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
/**
|
|
3612
|
+
* Called when a client connects or disconnects.
|
|
3613
|
+
* Updates and sends out metadata to reflect the player’s connection status.
|
|
3614
|
+
*/
|
|
3615
|
+
async onConnectionChange(matchID, playerID, credentials, connected) {
|
|
3616
|
+
const key = matchID;
|
|
3617
|
+
// Ignore changes for clients without a playerID, e.g. spectators.
|
|
3618
|
+
if (playerID === undefined || playerID === null) {
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
let metadata;
|
|
3622
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3623
|
+
({ metadata } = this.storageAPI.fetch(key, { metadata: true }));
|
|
3624
|
+
}
|
|
3625
|
+
else {
|
|
3626
|
+
({ metadata } = await this.storageAPI.fetch(key, { metadata: true }));
|
|
3627
|
+
}
|
|
3628
|
+
if (metadata === undefined) {
|
|
3629
|
+
error(`metadata not found for matchID=[${key}]`);
|
|
3630
|
+
return { error: 'metadata not found' };
|
|
3631
|
+
}
|
|
3632
|
+
if (metadata.players[playerID] === undefined) {
|
|
3633
|
+
error(`Player not in the match, matchID=[${key}] playerID=[${playerID}]`);
|
|
3634
|
+
return { error: 'player not in the match' };
|
|
3635
|
+
}
|
|
3636
|
+
if (this.auth) {
|
|
3637
|
+
const isAuthentic = await this.auth.authenticateCredentials({
|
|
3638
|
+
playerID,
|
|
3639
|
+
credentials,
|
|
3640
|
+
metadata,
|
|
3641
|
+
});
|
|
3642
|
+
if (!isAuthentic) {
|
|
3643
|
+
return { error: 'unauthorized' };
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
metadata.players[playerID].isConnected = connected;
|
|
3647
|
+
const filteredMetadata = filterMatchData(metadata);
|
|
3648
|
+
this.transportAPI.sendAll({
|
|
3649
|
+
type: 'matchData',
|
|
3650
|
+
args: [matchID, filteredMetadata],
|
|
3651
|
+
});
|
|
3652
|
+
if (isSynchronous(this.storageAPI)) {
|
|
3653
|
+
this.storageAPI.setMetadata(key, metadata);
|
|
3654
|
+
}
|
|
3655
|
+
else {
|
|
3656
|
+
await this.storageAPI.setMetadata(key, metadata);
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
async onChatMessage(matchID, chatMessage, credentials) {
|
|
3660
|
+
const key = matchID;
|
|
3661
|
+
if (this.auth) {
|
|
3662
|
+
const { metadata } = await this.storageAPI.fetch(key, {
|
|
3663
|
+
metadata: true,
|
|
3664
|
+
});
|
|
3665
|
+
if (!(chatMessage && typeof chatMessage.sender === 'string')) {
|
|
3666
|
+
return { error: 'unauthorized' };
|
|
3667
|
+
}
|
|
3668
|
+
const isAuthentic = await this.auth.authenticateCredentials({
|
|
3669
|
+
playerID: chatMessage.sender,
|
|
3670
|
+
credentials,
|
|
3671
|
+
metadata,
|
|
3672
|
+
});
|
|
3673
|
+
if (!isAuthentic) {
|
|
3674
|
+
return { error: 'unauthorized' };
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
this.transportAPI.sendAll({
|
|
3678
|
+
type: 'chat',
|
|
3679
|
+
args: [matchID, chatMessage],
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
const applyPlayerView = (game, playerID, state) => ({
|
|
3685
|
+
...state,
|
|
3686
|
+
G: game.playerView({ G: state.G, ctx: state.ctx, playerID }),
|
|
3687
|
+
plugins: PlayerView(state, { playerID, game }),
|
|
3688
|
+
deltalog: undefined,
|
|
3689
|
+
_undo: [],
|
|
3690
|
+
_redo: [],
|
|
3691
|
+
});
|
|
3692
|
+
/** Gets a function that filters the TransportData for a given player and game. */
|
|
3693
|
+
const getFilterPlayerView = (game) => (playerID, payload) => {
|
|
3694
|
+
switch (payload.type) {
|
|
3695
|
+
case 'patch': {
|
|
3696
|
+
const [matchID, stateID, prevState, state] = payload.args;
|
|
3697
|
+
const log = redactLog(state.deltalog, playerID);
|
|
3698
|
+
const filteredState = applyPlayerView(game, playerID, state);
|
|
3699
|
+
const newStateID = state._stateID;
|
|
3700
|
+
const prevFilteredState = applyPlayerView(game, playerID, prevState);
|
|
3701
|
+
const patch = rfc6902.createPatch(prevFilteredState, filteredState);
|
|
3702
|
+
return {
|
|
3703
|
+
type: 'patch',
|
|
3704
|
+
args: [matchID, stateID, newStateID, patch, log],
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3707
|
+
case 'update': {
|
|
3708
|
+
const [matchID, state] = payload.args;
|
|
3709
|
+
const log = redactLog(state.deltalog, playerID);
|
|
3710
|
+
const filteredState = applyPlayerView(game, playerID, state);
|
|
3711
|
+
return {
|
|
3712
|
+
type: 'update',
|
|
3713
|
+
args: [matchID, filteredState, log],
|
|
3714
|
+
};
|
|
3715
|
+
}
|
|
3716
|
+
case 'sync': {
|
|
3717
|
+
const [matchID, syncInfo] = payload.args;
|
|
3718
|
+
const filteredState = applyPlayerView(game, playerID, syncInfo.state);
|
|
3719
|
+
const log = redactLog(syncInfo.log, playerID);
|
|
3720
|
+
const newSyncInfo = {
|
|
3721
|
+
...syncInfo,
|
|
3722
|
+
state: filteredState,
|
|
3723
|
+
log,
|
|
3724
|
+
};
|
|
3725
|
+
return {
|
|
3726
|
+
type: 'sync',
|
|
3727
|
+
args: [matchID, newSyncInfo],
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
default: {
|
|
3731
|
+
return payload;
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
};
|
|
3735
|
+
/**
|
|
3736
|
+
* Redact the log.
|
|
3737
|
+
*
|
|
3738
|
+
* @param {Array} log - The game log (or deltalog).
|
|
3739
|
+
* @param {String} playerID - The playerID that this log is
|
|
3740
|
+
* to be sent to.
|
|
3741
|
+
*/
|
|
3742
|
+
function redactLog(log, playerID) {
|
|
3743
|
+
if (log === undefined) {
|
|
3744
|
+
return log;
|
|
3745
|
+
}
|
|
3746
|
+
return log.map((logEvent) => {
|
|
3747
|
+
// filter for all other players and spectators.
|
|
3748
|
+
if (playerID !== null && +playerID === +logEvent.action.payload.playerID) {
|
|
3749
|
+
return logEvent;
|
|
3750
|
+
}
|
|
3751
|
+
if (logEvent.redact !== true) {
|
|
3752
|
+
return logEvent;
|
|
3753
|
+
}
|
|
3754
|
+
const payload = {
|
|
3755
|
+
...logEvent.action.payload,
|
|
3756
|
+
args: null,
|
|
3757
|
+
};
|
|
3758
|
+
const filteredEvent = {
|
|
3759
|
+
...logEvent,
|
|
3760
|
+
action: { ...logEvent.action, payload },
|
|
3761
|
+
};
|
|
3762
|
+
const { redact, ...remaining } = filteredEvent;
|
|
3763
|
+
return remaining;
|
|
3764
|
+
});
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
class InMemoryPubSub {
|
|
3768
|
+
constructor() {
|
|
3769
|
+
this.callbacks = new Map();
|
|
3770
|
+
}
|
|
3771
|
+
publish(channelId, payload) {
|
|
3772
|
+
if (!this.callbacks.has(channelId)) {
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
const allCallbacks = this.callbacks.get(channelId);
|
|
3776
|
+
for (const callback of allCallbacks) {
|
|
3777
|
+
callback(payload);
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
subscribe(channelId, callback) {
|
|
3781
|
+
if (!this.callbacks.has(channelId)) {
|
|
3782
|
+
this.callbacks.set(channelId, []);
|
|
3783
|
+
}
|
|
3784
|
+
this.callbacks.get(channelId).push(callback);
|
|
3785
|
+
}
|
|
3786
|
+
unsubscribeAll(channelId) {
|
|
3787
|
+
if (this.callbacks.has(channelId)) {
|
|
3788
|
+
this.callbacks.delete(channelId);
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
/*
|
|
3794
|
+
* Copyright 2018 The boardgame.io Authors
|
|
3795
|
+
*
|
|
3796
|
+
* Use of this source code is governed by a MIT-style
|
|
3797
|
+
* license that can be found in the LICENSE file or at
|
|
3798
|
+
* https://opensource.org/licenses/MIT.
|
|
3799
|
+
*/
|
|
3800
|
+
const PING_TIMEOUT = 20 * 1e3;
|
|
3801
|
+
const PING_INTERVAL = 10 * 1e3;
|
|
3802
|
+
const emit = (socket, { type, args }) => {
|
|
3803
|
+
socket.emit(type, ...args);
|
|
3804
|
+
};
|
|
3805
|
+
function getPubSubChannelId(matchID) {
|
|
3806
|
+
return `MATCH-${matchID}`;
|
|
3807
|
+
}
|
|
3808
|
+
/**
|
|
3809
|
+
* API that's exposed by SocketIO for the Master to send
|
|
3810
|
+
* information to the clients.
|
|
3811
|
+
*/
|
|
3812
|
+
const TransportAPI = (matchID, socket, filterPlayerView, pubSub) => {
|
|
3813
|
+
const send = ({ playerID, ...data }) => {
|
|
3814
|
+
emit(socket, filterPlayerView(playerID, data));
|
|
3815
|
+
};
|
|
3816
|
+
/**
|
|
3817
|
+
* Send a message to all clients.
|
|
3818
|
+
*/
|
|
3819
|
+
const sendAll = (payload) => {
|
|
3820
|
+
pubSub.publish(getPubSubChannelId(matchID), payload);
|
|
3821
|
+
};
|
|
3822
|
+
return { send, sendAll };
|
|
3823
|
+
};
|
|
3824
|
+
/**
|
|
3825
|
+
* Transport interface that uses socket.io
|
|
3826
|
+
*/
|
|
3827
|
+
class SocketIO {
|
|
3828
|
+
constructor({ https, socketAdapter, socketOpts, pubSub } = {}) {
|
|
3829
|
+
this.clientInfo = new Map();
|
|
3830
|
+
this.roomInfo = new Map();
|
|
3831
|
+
this.perMatchQueue = new Map();
|
|
3832
|
+
this.https = https;
|
|
3833
|
+
this.socketAdapter = socketAdapter;
|
|
3834
|
+
this.socketOpts = socketOpts;
|
|
3835
|
+
this.pubSub = pubSub || new InMemoryPubSub();
|
|
3836
|
+
}
|
|
3837
|
+
/**
|
|
3838
|
+
* Unregister client data for a socket.
|
|
3839
|
+
*/
|
|
3840
|
+
removeClient(socketID) {
|
|
3841
|
+
// Get client data for this socket ID.
|
|
3842
|
+
const client = this.clientInfo.get(socketID);
|
|
3843
|
+
if (!client)
|
|
3844
|
+
return;
|
|
3845
|
+
// Remove client from list of connected sockets for this match.
|
|
3846
|
+
const { matchID } = client;
|
|
3847
|
+
const matchClients = this.roomInfo.get(matchID);
|
|
3848
|
+
matchClients.delete(socketID);
|
|
3849
|
+
// If the match is now empty, delete its promise queue & client ID list.
|
|
3850
|
+
if (matchClients.size === 0) {
|
|
3851
|
+
this.unsubscribePubSubChannel(matchID);
|
|
3852
|
+
this.roomInfo.delete(matchID);
|
|
3853
|
+
this.deleteMatchQueue(matchID);
|
|
3854
|
+
}
|
|
3855
|
+
// Remove client data from the client map.
|
|
3856
|
+
this.clientInfo.delete(socketID);
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* Register client data for a socket.
|
|
3860
|
+
*/
|
|
3861
|
+
addClient(client, game) {
|
|
3862
|
+
const { matchID, socket } = client;
|
|
3863
|
+
// Add client to list of connected sockets for this match.
|
|
3864
|
+
let matchClients = this.roomInfo.get(matchID);
|
|
3865
|
+
if (matchClients === undefined) {
|
|
3866
|
+
this.subscribePubSubChannel(matchID, game);
|
|
3867
|
+
matchClients = new Set();
|
|
3868
|
+
this.roomInfo.set(matchID, matchClients);
|
|
3869
|
+
}
|
|
3870
|
+
matchClients.add(socket.id);
|
|
3871
|
+
// Register data for this socket in the client map.
|
|
3872
|
+
this.clientInfo.set(socket.id, client);
|
|
3873
|
+
}
|
|
3874
|
+
subscribePubSubChannel(matchID, game) {
|
|
3875
|
+
const filterPlayerView = getFilterPlayerView(game);
|
|
3876
|
+
const broadcast = (payload) => {
|
|
3877
|
+
this.roomInfo.get(matchID).forEach((clientID) => {
|
|
3878
|
+
const client = this.clientInfo.get(clientID);
|
|
3879
|
+
const data = filterPlayerView(client.playerID, payload);
|
|
3880
|
+
emit(client.socket, data);
|
|
3881
|
+
});
|
|
3882
|
+
};
|
|
3883
|
+
this.pubSub.subscribe(getPubSubChannelId(matchID), broadcast);
|
|
3884
|
+
}
|
|
3885
|
+
unsubscribePubSubChannel(matchID) {
|
|
3886
|
+
this.pubSub.unsubscribeAll(getPubSubChannelId(matchID));
|
|
3887
|
+
}
|
|
3888
|
+
init(app, games, origins = []) {
|
|
3889
|
+
const io = new IO__default["default"]({
|
|
3890
|
+
ioOptions: {
|
|
3891
|
+
pingTimeout: PING_TIMEOUT,
|
|
3892
|
+
pingInterval: PING_INTERVAL,
|
|
3893
|
+
cors: {
|
|
3894
|
+
origins,
|
|
3895
|
+
},
|
|
3896
|
+
...this.socketOpts,
|
|
3897
|
+
},
|
|
3898
|
+
});
|
|
3899
|
+
app.context.io = io;
|
|
3900
|
+
io.attach(app, !!this.https, this.https);
|
|
3901
|
+
if (this.socketAdapter) {
|
|
3902
|
+
io.adapter(this.socketAdapter);
|
|
3903
|
+
}
|
|
3904
|
+
for (const game of games) {
|
|
3905
|
+
const nsp = app._io.of(game.name);
|
|
3906
|
+
const filterPlayerView = getFilterPlayerView(game);
|
|
3907
|
+
nsp.on('connection', (socket) => {
|
|
3908
|
+
socket.on('update', async (...args) => {
|
|
3909
|
+
const [action, stateID, matchID, playerID] = args;
|
|
3910
|
+
const master = new Master(game, app.context.db, TransportAPI(matchID, socket, filterPlayerView, this.pubSub), app.context.auth);
|
|
3911
|
+
const matchQueue = this.getMatchQueue(matchID);
|
|
3912
|
+
await matchQueue.add(() => master.onUpdate(action, stateID, matchID, playerID));
|
|
3913
|
+
});
|
|
3914
|
+
socket.on('sync', async (...args) => {
|
|
3915
|
+
const [matchID, playerID, credentials] = args;
|
|
3916
|
+
socket.join(matchID);
|
|
3917
|
+
this.removeClient(socket.id);
|
|
3918
|
+
const requestingClient = { socket, matchID, playerID, credentials };
|
|
3919
|
+
const transport = TransportAPI(matchID, socket, filterPlayerView, this.pubSub);
|
|
3920
|
+
const master = new Master(game, app.context.db, transport, app.context.auth);
|
|
3921
|
+
const syncResponse = await master.onSync(...args);
|
|
3922
|
+
if (syncResponse && syncResponse.error === 'unauthorized') {
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
this.addClient(requestingClient, game);
|
|
3926
|
+
await master.onConnectionChange(matchID, playerID, credentials, true);
|
|
3927
|
+
});
|
|
3928
|
+
socket.on('disconnect', async () => {
|
|
3929
|
+
const client = this.clientInfo.get(socket.id);
|
|
3930
|
+
this.removeClient(socket.id);
|
|
3931
|
+
if (client) {
|
|
3932
|
+
const { matchID, playerID, credentials } = client;
|
|
3933
|
+
const master = new Master(game, app.context.db, TransportAPI(matchID, socket, filterPlayerView, this.pubSub), app.context.auth);
|
|
3934
|
+
await master.onConnectionChange(matchID, playerID, credentials, false);
|
|
3935
|
+
}
|
|
3936
|
+
});
|
|
3937
|
+
socket.on('chat', async (...args) => {
|
|
3938
|
+
const [matchID] = args;
|
|
3939
|
+
const master = new Master(game, app.context.db, TransportAPI(matchID, socket, filterPlayerView, this.pubSub), app.context.auth);
|
|
3940
|
+
master.onChatMessage(...args);
|
|
3941
|
+
});
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
/**
|
|
3946
|
+
* Create a PQueue for a given matchID if none exists and return it.
|
|
3947
|
+
* @param matchID
|
|
3948
|
+
* @returns
|
|
3949
|
+
*/
|
|
3950
|
+
getMatchQueue(matchID) {
|
|
3951
|
+
if (!this.perMatchQueue.has(matchID)) {
|
|
3952
|
+
// PQueue should process only one action at a time.
|
|
3953
|
+
this.perMatchQueue.set(matchID, new PQueue__default["default"]({ concurrency: 1 }));
|
|
3954
|
+
}
|
|
3955
|
+
return this.perMatchQueue.get(matchID);
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Delete a PQueue for a given matchID.
|
|
3959
|
+
* @param matchID
|
|
3960
|
+
*/
|
|
3961
|
+
deleteMatchQueue(matchID) {
|
|
3962
|
+
this.perMatchQueue.delete(matchID);
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
/*
|
|
3967
|
+
* Copyright 2017 The boardgame.io Authors
|
|
3968
|
+
*
|
|
3969
|
+
* Use of this source code is governed by a MIT-style
|
|
3970
|
+
* license that can be found in the LICENSE file or at
|
|
3971
|
+
* https://opensource.org/licenses/MIT.
|
|
3972
|
+
*/
|
|
3973
|
+
/**
|
|
3974
|
+
* Build config object from server run arguments.
|
|
3975
|
+
*/
|
|
3976
|
+
const createServerRunConfig = (portOrConfig, callback) => portOrConfig && typeof portOrConfig === 'object'
|
|
3977
|
+
? {
|
|
3978
|
+
...portOrConfig,
|
|
3979
|
+
callback: portOrConfig.callback || callback,
|
|
3980
|
+
}
|
|
3981
|
+
: { port: portOrConfig, callback };
|
|
3982
|
+
const getPortFromServer = (server) => {
|
|
3983
|
+
const address = server.address();
|
|
3984
|
+
if (typeof address === 'string')
|
|
3985
|
+
return address;
|
|
3986
|
+
if (address === null)
|
|
3987
|
+
return null;
|
|
3988
|
+
return address.port;
|
|
3989
|
+
};
|
|
3990
|
+
/**
|
|
3991
|
+
* Instantiate a game server.
|
|
3992
|
+
*
|
|
3993
|
+
* @param games - The games that this server will handle.
|
|
3994
|
+
* @param db - The interface with the database.
|
|
3995
|
+
* @param transport - The interface with the clients.
|
|
3996
|
+
* @param authenticateCredentials - Function to test player credentials.
|
|
3997
|
+
* @param origins - Allowed origins to use this server, e.g. `['http://localhost:3000']`.
|
|
3998
|
+
* @param apiOrigins - Allowed origins to use the Lobby API, defaults to `origins`.
|
|
3999
|
+
* @param generateCredentials - Method for API to generate player credentials.
|
|
4000
|
+
* @param https - HTTPS configuration options passed through to the TLS module.
|
|
4001
|
+
* @param lobbyConfig - Configuration options for the Lobby API server.
|
|
4002
|
+
*/
|
|
4003
|
+
function Server({ games, db, transport, https, uuid, origins, apiOrigins = origins, generateCredentials = uuid, authenticateCredentials, }) {
|
|
4004
|
+
const app = new Koa__default["default"]();
|
|
4005
|
+
games = games.map((game) => ProcessGameConfig(game));
|
|
4006
|
+
if (db === undefined) {
|
|
4007
|
+
db = DBFromEnv();
|
|
4008
|
+
}
|
|
4009
|
+
app.context.db = db;
|
|
4010
|
+
const auth = new Auth({ authenticateCredentials, generateCredentials });
|
|
4011
|
+
app.context.auth = auth;
|
|
4012
|
+
if (transport === undefined) {
|
|
4013
|
+
transport = new SocketIO({ https });
|
|
4014
|
+
}
|
|
4015
|
+
if (origins === undefined) {
|
|
4016
|
+
console.warn('Server `origins` option is not set.\n' +
|
|
4017
|
+
'Since boardgame.io@0.45, CORS is not enabled by default and you must ' +
|
|
4018
|
+
'explicitly set the origins that are allowed to connect to the server.\n' +
|
|
4019
|
+
'See https://boardgame.io/documentation/#/api/Server');
|
|
4020
|
+
}
|
|
4021
|
+
transport.init(app, games, origins);
|
|
4022
|
+
const router = new Router__default["default"]();
|
|
4023
|
+
return {
|
|
4024
|
+
app,
|
|
4025
|
+
db,
|
|
4026
|
+
auth,
|
|
4027
|
+
router,
|
|
4028
|
+
transport,
|
|
4029
|
+
run: async (portOrConfig, callback) => {
|
|
4030
|
+
const serverRunConfig = createServerRunConfig(portOrConfig, callback);
|
|
4031
|
+
configureRouter({ router, db, games, uuid, auth });
|
|
4032
|
+
// DB
|
|
4033
|
+
await db.connect();
|
|
4034
|
+
// Lobby API
|
|
4035
|
+
const lobbyConfig = serverRunConfig.lobbyConfig;
|
|
4036
|
+
let apiServer;
|
|
4037
|
+
if (!lobbyConfig || !lobbyConfig.apiPort) {
|
|
4038
|
+
configureApp(app, router, apiOrigins);
|
|
4039
|
+
}
|
|
4040
|
+
else {
|
|
4041
|
+
// Run API in a separate Koa app.
|
|
4042
|
+
const api = new Koa__default["default"]();
|
|
4043
|
+
api.context.db = db;
|
|
4044
|
+
api.context.auth = auth;
|
|
4045
|
+
configureApp(api, router, apiOrigins);
|
|
4046
|
+
await new Promise((resolve) => {
|
|
4047
|
+
apiServer = api.listen(lobbyConfig.apiPort, resolve);
|
|
4048
|
+
});
|
|
4049
|
+
if (lobbyConfig.apiCallback)
|
|
4050
|
+
lobbyConfig.apiCallback();
|
|
4051
|
+
info(`API serving on ${getPortFromServer(apiServer)}...`);
|
|
4052
|
+
}
|
|
4053
|
+
// Run Game Server (+ API, if necessary).
|
|
4054
|
+
let appServer;
|
|
4055
|
+
await new Promise((resolve) => {
|
|
4056
|
+
appServer = app.listen(serverRunConfig.port, resolve);
|
|
4057
|
+
});
|
|
4058
|
+
if (serverRunConfig.callback)
|
|
4059
|
+
serverRunConfig.callback();
|
|
4060
|
+
info(`App serving on ${getPortFromServer(appServer)}...`);
|
|
4061
|
+
return { apiServer, appServer };
|
|
4062
|
+
},
|
|
4063
|
+
kill: (servers) => {
|
|
4064
|
+
if (servers.apiServer) {
|
|
4065
|
+
servers.apiServer.close();
|
|
4066
|
+
}
|
|
4067
|
+
servers.appServer.close();
|
|
4068
|
+
},
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
const LOCALHOST = /localhost:\d+/;
|
|
4073
|
+
const Origins = {
|
|
4074
|
+
LOCALHOST,
|
|
4075
|
+
LOCALHOST_IN_DEVELOPMENT: process.env.NODE_ENV === 'production' ? false : LOCALHOST,
|
|
4076
|
+
};
|
|
4077
|
+
|
|
4078
|
+
exports.FlatFile = FlatFile;
|
|
4079
|
+
exports.Master = Master;
|
|
4080
|
+
exports.Origins = Origins;
|
|
4081
|
+
exports.Server = Server;
|
|
4082
|
+
exports.SocketIO = SocketIO;
|
|
4083
|
+
exports.TransportAPI = TransportAPI;
|
|
4084
|
+
exports.configureApp = configureApp;
|
|
4085
|
+
exports.configureRouter = configureRouter;
|
|
4086
|
+
exports.createServerRunConfig = createServerRunConfig;
|
|
4087
|
+
exports.getPortFromServer = getPortFromServer;
|