@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.
Files changed (296) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/ai/package.json +7 -0
  4. package/client/package.json +7 -0
  5. package/core/package.json +7 -0
  6. package/debug/package.json +7 -0
  7. package/dist/boardgameio.es.js +14238 -0
  8. package/dist/boardgameio.js +14277 -0
  9. package/dist/boardgameio.min.js +16 -0
  10. package/dist/cjs/Debug-9d141c06.js +9586 -0
  11. package/dist/cjs/ai-e0e8a768.js +377 -0
  12. package/dist/cjs/ai.js +20 -0
  13. package/dist/cjs/client-76dec77b.js +258 -0
  14. package/dist/cjs/client-a22d7500.js +524 -0
  15. package/dist/cjs/client.js +26 -0
  16. package/dist/cjs/core.js +52 -0
  17. package/dist/cjs/debug.js +18 -0
  18. package/dist/cjs/filter-player-view-bb02e2f6.js +89 -0
  19. package/dist/cjs/initialize-267fcd69.js +61 -0
  20. package/dist/cjs/internal.js +25 -0
  21. package/dist/cjs/master-2904879d.js +320 -0
  22. package/dist/cjs/master.js +18 -0
  23. package/dist/cjs/multiplayer.js +23 -0
  24. package/dist/cjs/plugin-random-7425844d.js +229 -0
  25. package/dist/cjs/plugins.js +59 -0
  26. package/dist/cjs/react-native.js +182 -0
  27. package/dist/cjs/react.js +727 -0
  28. package/dist/cjs/reducer-16eec232.js +1203 -0
  29. package/dist/cjs/server.js +4087 -0
  30. package/dist/cjs/socketio-7a0837eb.js +478 -0
  31. package/dist/cjs/testing.js +30 -0
  32. package/dist/cjs/transport-b1874dfa.js +37 -0
  33. package/dist/cjs/turn-order-b2ff8740.js +1136 -0
  34. package/dist/cjs/util-fcfd8fb8.js +140 -0
  35. package/dist/esm/Debug-0141fe2d.js +9577 -0
  36. package/dist/esm/ai-5c06e761.js +371 -0
  37. package/dist/esm/ai.js +8 -0
  38. package/dist/esm/client-2e653027.js +522 -0
  39. package/dist/esm/client-5f57c3f2.js +255 -0
  40. package/dist/esm/client.js +16 -0
  41. package/dist/esm/core.js +40 -0
  42. package/dist/esm/debug.js +10 -0
  43. package/dist/esm/filter-player-view-2c6cc96f.js +87 -0
  44. package/dist/esm/initialize-11d626ca.js +59 -0
  45. package/dist/esm/internal.js +10 -0
  46. package/dist/esm/master-fa8f2e43.js +318 -0
  47. package/dist/esm/master.js +10 -0
  48. package/dist/esm/multiplayer.js +14 -0
  49. package/dist/esm/plugin-random-087f861e.js +226 -0
  50. package/dist/esm/plugins.js +55 -0
  51. package/dist/esm/react-native.js +173 -0
  52. package/dist/esm/react.js +716 -0
  53. package/dist/esm/reducer-c46da7e5.js +1198 -0
  54. package/dist/esm/socketio-c22ffa65.js +455 -0
  55. package/dist/esm/testing.js +26 -0
  56. package/dist/esm/transport-ce07b771.js +35 -0
  57. package/dist/esm/turn-order-376d315e.js +1091 -0
  58. package/dist/esm/util-b6147cef.js +135 -0
  59. package/dist/types/packages/ai.d.ts +5 -0
  60. package/dist/types/packages/client.d.ts +3 -0
  61. package/dist/types/packages/core.d.ts +5 -0
  62. package/dist/types/packages/debug.d.ts +2 -0
  63. package/dist/types/packages/internal.d.ts +8 -0
  64. package/dist/types/packages/master.d.ts +2 -0
  65. package/dist/types/packages/multiplayer.d.ts +3 -0
  66. package/dist/types/packages/plugins.d.ts +3 -0
  67. package/dist/types/packages/react-native.d.ts +2 -0
  68. package/dist/types/packages/react.d.ts +3 -0
  69. package/dist/types/packages/server.d.ts +6 -0
  70. package/dist/types/packages/testing.d.ts +1 -0
  71. package/dist/types/src/ai/ai.d.ts +53 -0
  72. package/dist/types/src/ai/ai.test.d.ts +1 -0
  73. package/dist/types/src/ai/bot.d.ts +40 -0
  74. package/dist/types/src/ai/mcts-bot.d.ts +60 -0
  75. package/dist/types/src/ai/random-bot.d.ts +27 -0
  76. package/dist/types/src/client/client.d.ts +104 -0
  77. package/dist/types/src/client/client.test.d.ts +1 -0
  78. package/dist/types/src/client/debug/tests/debug.test.d.ts +1 -0
  79. package/dist/types/src/client/manager.d.ts +61 -0
  80. package/dist/types/src/client/react.d.ts +75 -0
  81. package/dist/types/src/client/react.ssr.test.d.ts +4 -0
  82. package/dist/types/src/client/react.test.d.ts +1 -0
  83. package/dist/types/src/client/transport/dummy.d.ts +18 -0
  84. package/dist/types/src/client/transport/local.d.ts +59 -0
  85. package/dist/types/src/client/transport/local.test.d.ts +1 -0
  86. package/dist/types/src/client/transport/socketio.d.ts +45 -0
  87. package/dist/types/src/client/transport/socketio.test.d.ts +1 -0
  88. package/dist/types/src/client/transport/transport.d.ts +50 -0
  89. package/dist/types/src/client/transport/transport.test.d.ts +1 -0
  90. package/dist/types/src/core/action-creators.d.ts +144 -0
  91. package/dist/types/src/core/action-types.d.ts +10 -0
  92. package/dist/types/src/core/backwards-compatibility.d.ts +12 -0
  93. package/dist/types/src/core/constants.d.ts +6 -0
  94. package/dist/types/src/core/errors.d.ts +15 -0
  95. package/dist/types/src/core/flow.d.ts +28 -0
  96. package/dist/types/src/core/flow.test.d.ts +1 -0
  97. package/dist/types/src/core/game-methods.d.ts +9 -0
  98. package/dist/types/src/core/game.d.ts +26 -0
  99. package/dist/types/src/core/game.test.d.ts +1 -0
  100. package/dist/types/src/core/initialize.d.ts +9 -0
  101. package/dist/types/src/core/logger.d.ts +2 -0
  102. package/dist/types/src/core/player-view.d.ts +7 -0
  103. package/dist/types/src/core/player-view.test.d.ts +1 -0
  104. package/dist/types/src/core/reducer.d.ts +155 -0
  105. package/dist/types/src/core/reducer.test.d.ts +1 -0
  106. package/dist/types/src/core/turn-order.d.ts +179 -0
  107. package/dist/types/src/core/turn-order.test.d.ts +8 -0
  108. package/dist/types/src/lobby/client.d.ts +194 -0
  109. package/dist/types/src/lobby/client.test.d.ts +1 -0
  110. package/dist/types/src/lobby/connection.d.ts +44 -0
  111. package/dist/types/src/lobby/connection.test.d.ts +1 -0
  112. package/dist/types/src/lobby/create-match-form.d.ts +26 -0
  113. package/dist/types/src/lobby/login-form.d.ts +23 -0
  114. package/dist/types/src/lobby/match-instance.d.ts +31 -0
  115. package/dist/types/src/lobby/react.d.ts +113 -0
  116. package/dist/types/src/lobby/react.ssr.test.d.ts +4 -0
  117. package/dist/types/src/lobby/react.test.d.ts +1 -0
  118. package/dist/types/src/master/filter-player-view.d.ts +96 -0
  119. package/dist/types/src/master/filter-player-view.test.d.ts +1 -0
  120. package/dist/types/src/master/master.d.ts +94 -0
  121. package/dist/types/src/master/master.test.d.ts +1 -0
  122. package/dist/types/src/plugins/events/events.d.ts +54 -0
  123. package/dist/types/src/plugins/events/events.test.d.ts +1 -0
  124. package/dist/types/src/plugins/main.d.ts +75 -0
  125. package/dist/types/src/plugins/main.test.d.ts +1 -0
  126. package/dist/types/src/plugins/plugin-events.d.ts +5 -0
  127. package/dist/types/src/plugins/plugin-immer.d.ts +7 -0
  128. package/dist/types/src/plugins/plugin-immer.test.d.ts +1 -0
  129. package/dist/types/src/plugins/plugin-log.d.ts +14 -0
  130. package/dist/types/src/plugins/plugin-log.test.d.ts +1 -0
  131. package/dist/types/src/plugins/plugin-player.d.ts +29 -0
  132. package/dist/types/src/plugins/plugin-player.test.d.ts +1 -0
  133. package/dist/types/src/plugins/plugin-random.d.ts +4 -0
  134. package/dist/types/src/plugins/plugin-serializable.d.ts +7 -0
  135. package/dist/types/src/plugins/plugin-serializable.test.d.ts +1 -0
  136. package/dist/types/src/plugins/random/random.alea.d.ts +19 -0
  137. package/dist/types/src/plugins/random/random.d.ts +54 -0
  138. package/dist/types/src/plugins/random/random.test.d.ts +1 -0
  139. package/dist/types/src/server/api.d.ts +13 -0
  140. package/dist/types/src/server/api.test.d.ts +1 -0
  141. package/dist/types/src/server/auth.d.ts +38 -0
  142. package/dist/types/src/server/auth.test.d.ts +1 -0
  143. package/dist/types/src/server/cors.d.ts +4 -0
  144. package/dist/types/src/server/cors.test.d.ts +1 -0
  145. package/dist/types/src/server/db/base.d.ts +192 -0
  146. package/dist/types/src/server/db/flatfile.d.ts +44 -0
  147. package/dist/types/src/server/db/flatfile.test.d.ts +1 -0
  148. package/dist/types/src/server/db/index.d.ts +4 -0
  149. package/dist/types/src/server/db/index.test.d.ts +1 -0
  150. package/dist/types/src/server/db/inmemory.d.ts +43 -0
  151. package/dist/types/src/server/db/inmemory.test.d.ts +1 -0
  152. package/dist/types/src/server/db/localstorage.d.ts +7 -0
  153. package/dist/types/src/server/db/localstorage.test.d.ts +1 -0
  154. package/dist/types/src/server/index.d.ts +68 -0
  155. package/dist/types/src/server/index.test.d.ts +1 -0
  156. package/dist/types/src/server/transport/pubsub/generic-pub-sub.d.ts +6 -0
  157. package/dist/types/src/server/transport/pubsub/in-memory-pub-sub.d.ts +7 -0
  158. package/dist/types/src/server/transport/pubsub/in-memory-pub-sub.test.d.ts +1 -0
  159. package/dist/types/src/server/transport/socketio-simultaneous.test.d.ts +1 -0
  160. package/dist/types/src/server/transport/socketio.d.ts +65 -0
  161. package/dist/types/src/server/transport/socketio.test.d.ts +1 -0
  162. package/dist/types/src/server/util.d.ts +35 -0
  163. package/dist/types/src/testing/mock-random.d.ts +15 -0
  164. package/dist/types/src/testing/mock-random.test.d.ts +1 -0
  165. package/dist/types/src/types.d.ts +387 -0
  166. package/internal/package.json +7 -0
  167. package/master/package.json +7 -0
  168. package/multiplayer/package.json +7 -0
  169. package/package.json +211 -0
  170. package/plugins/package.json +7 -0
  171. package/react/package.json +7 -0
  172. package/react-native/package.json +7 -0
  173. package/server/package.json +6 -0
  174. package/src/ai/ai.test.ts +433 -0
  175. package/src/ai/ai.ts +84 -0
  176. package/src/ai/bot.ts +122 -0
  177. package/src/ai/mcts-bot.ts +331 -0
  178. package/src/ai/random-bot.ts +20 -0
  179. package/src/client/client.test.ts +993 -0
  180. package/src/client/client.ts +588 -0
  181. package/src/client/debug/Debug.svelte +239 -0
  182. package/src/client/debug/Menu.svelte +65 -0
  183. package/src/client/debug/ai/AI.svelte +215 -0
  184. package/src/client/debug/ai/Options.svelte +48 -0
  185. package/src/client/debug/info/Info.svelte +22 -0
  186. package/src/client/debug/info/Item.svelte +24 -0
  187. package/src/client/debug/log/Log.svelte +157 -0
  188. package/src/client/debug/log/LogEvent.svelte +149 -0
  189. package/src/client/debug/log/LogMetadata.svelte +7 -0
  190. package/src/client/debug/log/PhaseMarker.svelte +27 -0
  191. package/src/client/debug/log/TurnMarker.svelte +23 -0
  192. package/src/client/debug/main/ClientSwitcher.svelte +59 -0
  193. package/src/client/debug/main/Controls.svelte +58 -0
  194. package/src/client/debug/main/Hotkey.svelte +84 -0
  195. package/src/client/debug/main/InteractiveFunction.svelte +85 -0
  196. package/src/client/debug/main/Main.svelte +121 -0
  197. package/src/client/debug/main/Move.svelte +68 -0
  198. package/src/client/debug/main/PlayerInfo.svelte +70 -0
  199. package/src/client/debug/mcts/Action.svelte +22 -0
  200. package/src/client/debug/mcts/MCTS.svelte +78 -0
  201. package/src/client/debug/mcts/Table.svelte +98 -0
  202. package/src/client/debug/tests/JSONTree.mock.svelte +3 -0
  203. package/src/client/debug/tests/debug.test.ts +183 -0
  204. package/src/client/debug/utils/shortcuts.js +50 -0
  205. package/src/client/debug/utils/shortcuts.test.js +49 -0
  206. package/src/client/manager.ts +177 -0
  207. package/src/client/react-native.js +136 -0
  208. package/src/client/react-native.test.js +229 -0
  209. package/src/client/react.ssr.test.tsx +24 -0
  210. package/src/client/react.test.tsx +213 -0
  211. package/src/client/react.tsx +192 -0
  212. package/src/client/transport/dummy.ts +19 -0
  213. package/src/client/transport/local.test.ts +353 -0
  214. package/src/client/transport/local.ts +230 -0
  215. package/src/client/transport/socketio.test.ts +328 -0
  216. package/src/client/transport/socketio.ts +210 -0
  217. package/src/client/transport/transport.test.ts +27 -0
  218. package/src/client/transport/transport.ts +95 -0
  219. package/src/core/action-creators.ts +159 -0
  220. package/src/core/action-types.ts +18 -0
  221. package/src/core/backwards-compatibility.ts +23 -0
  222. package/src/core/constants.ts +6 -0
  223. package/src/core/errors.ts +35 -0
  224. package/src/core/flow.test.ts +2433 -0
  225. package/src/core/flow.ts +897 -0
  226. package/src/core/game-methods.ts +9 -0
  227. package/src/core/game.test.ts +286 -0
  228. package/src/core/game.ts +114 -0
  229. package/src/core/initialize.ts +77 -0
  230. package/src/core/logger.test.js +90 -0
  231. package/src/core/logger.ts +18 -0
  232. package/src/core/player-view.test.ts +50 -0
  233. package/src/core/player-view.ts +39 -0
  234. package/src/core/reducer.test.ts +991 -0
  235. package/src/core/reducer.ts +532 -0
  236. package/src/core/turn-order.test.ts +1123 -0
  237. package/src/core/turn-order.ts +473 -0
  238. package/src/lobby/client.test.ts +385 -0
  239. package/src/lobby/client.ts +358 -0
  240. package/src/lobby/connection.test.ts +207 -0
  241. package/src/lobby/connection.ts +162 -0
  242. package/src/lobby/create-match-form.tsx +122 -0
  243. package/src/lobby/login-form.tsx +75 -0
  244. package/src/lobby/match-instance.tsx +135 -0
  245. package/src/lobby/react.ssr.test.tsx +22 -0
  246. package/src/lobby/react.test.tsx +594 -0
  247. package/src/lobby/react.tsx +402 -0
  248. package/src/master/filter-player-view.test.ts +381 -0
  249. package/src/master/filter-player-view.ts +102 -0
  250. package/src/master/master.test.ts +1068 -0
  251. package/src/master/master.ts +492 -0
  252. package/src/plugins/events/events.test.ts +108 -0
  253. package/src/plugins/events/events.ts +209 -0
  254. package/src/plugins/main.test.ts +411 -0
  255. package/src/plugins/main.ts +314 -0
  256. package/src/plugins/plugin-events.ts +40 -0
  257. package/src/plugins/plugin-immer.test.ts +86 -0
  258. package/src/plugins/plugin-immer.ts +37 -0
  259. package/src/plugins/plugin-log.test.ts +37 -0
  260. package/src/plugins/plugin-log.ts +40 -0
  261. package/src/plugins/plugin-player.test.ts +172 -0
  262. package/src/plugins/plugin-player.ts +100 -0
  263. package/src/plugins/plugin-random.ts +40 -0
  264. package/src/plugins/plugin-serializable.test.ts +40 -0
  265. package/src/plugins/plugin-serializable.ts +55 -0
  266. package/src/plugins/random/random.alea.ts +109 -0
  267. package/src/plugins/random/random.test.ts +167 -0
  268. package/src/plugins/random/random.ts +198 -0
  269. package/src/server/api.test.ts +1699 -0
  270. package/src/server/api.ts +527 -0
  271. package/src/server/auth.test.ts +275 -0
  272. package/src/server/auth.ts +89 -0
  273. package/src/server/cors.test.ts +121 -0
  274. package/src/server/cors.ts +7 -0
  275. package/src/server/db/base.ts +296 -0
  276. package/src/server/db/flatfile.test.ts +221 -0
  277. package/src/server/db/flatfile.ts +228 -0
  278. package/src/server/db/index.test.ts +8 -0
  279. package/src/server/db/index.ts +12 -0
  280. package/src/server/db/inmemory.test.ts +143 -0
  281. package/src/server/db/inmemory.ts +143 -0
  282. package/src/server/db/localstorage.test.ts +73 -0
  283. package/src/server/db/localstorage.ts +44 -0
  284. package/src/server/index.test.ts +265 -0
  285. package/src/server/index.ts +175 -0
  286. package/src/server/transport/pubsub/generic-pub-sub.ts +11 -0
  287. package/src/server/transport/pubsub/in-memory-pub-sub.test.ts +47 -0
  288. package/src/server/transport/pubsub/in-memory-pub-sub.ts +28 -0
  289. package/src/server/transport/socketio-simultaneous.test.ts +603 -0
  290. package/src/server/transport/socketio.test.ts +303 -0
  291. package/src/server/transport/socketio.ts +279 -0
  292. package/src/server/util.ts +85 -0
  293. package/src/testing/mock-random.test.ts +45 -0
  294. package/src/testing/mock-random.ts +27 -0
  295. package/src/types.ts +511 -0
  296. package/testing/package.json +7 -0
@@ -0,0 +1,2433 @@
1
+ /*
2
+ * Copyright 2017 The boardgame.io Authors
3
+ *
4
+ * Use of this source code is governed by a MIT-style
5
+ * license that can be found in the LICENSE file or at
6
+ * https://opensource.org/licenses/MIT.
7
+ */
8
+
9
+ import { makeMove, gameEvent } from './action-creators';
10
+ import { Client } from '../client/client';
11
+ import { Flow } from './flow';
12
+ import { TurnOrder } from './turn-order';
13
+ import { error } from '../core/logger';
14
+ import type { Ctx, State, Game, PlayerID, MoveFn } from '../types';
15
+
16
+ jest.mock('../core/logger', () => ({
17
+ info: jest.fn(),
18
+ error: jest.fn(),
19
+ }));
20
+ afterEach(jest.clearAllMocks);
21
+
22
+ describe('phases', () => {
23
+ test('invalid phase name', () => {
24
+ const flow = Flow({
25
+ phases: { '': {} },
26
+ });
27
+ flow.init({ ctx: flow.ctx(2) } as State);
28
+ expect(error).toHaveBeenCalledWith('cannot specify phase with empty name');
29
+ });
30
+
31
+ test('onBegin / onEnd', () => {
32
+ const flow = Flow({
33
+ phases: {
34
+ A: {
35
+ start: true,
36
+ onBegin: ({ G }) => ({ ...G, setupA: true }),
37
+ onEnd: ({ G }) => ({ ...G, cleanupA: true }),
38
+ next: 'B',
39
+ },
40
+ B: {
41
+ onBegin: ({ G }) => ({ ...G, setupB: true }),
42
+ onEnd: ({ G }) => ({ ...G, cleanupB: true }),
43
+ next: 'A',
44
+ },
45
+ },
46
+
47
+ turn: {
48
+ order: {
49
+ first: ({ G }) => {
50
+ if (G.setupB && !G.cleanupB) return 1;
51
+ return 0;
52
+ },
53
+ next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
54
+ },
55
+ },
56
+ });
57
+
58
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
59
+ state = flow.init(state);
60
+ expect(state.G).toMatchObject({ setupA: true });
61
+ expect(state.ctx.currentPlayer).toBe('0');
62
+
63
+ state = flow.processEvent(state, gameEvent('endPhase'));
64
+ expect(state.G).toMatchObject({
65
+ setupA: true,
66
+ cleanupA: true,
67
+ setupB: true,
68
+ });
69
+ expect(state.ctx.currentPlayer).toBe('1');
70
+
71
+ state = flow.processEvent(state, gameEvent('endPhase'));
72
+ expect(state.G).toMatchObject({
73
+ setupA: true,
74
+ cleanupA: true,
75
+ setupB: true,
76
+ cleanupB: true,
77
+ });
78
+ expect(state.ctx.currentPlayer).toBe('0');
79
+ });
80
+
81
+ test('endIf', () => {
82
+ const flow = Flow({
83
+ phases: { A: { start: true, endIf: () => true, next: 'B' }, B: {} },
84
+ });
85
+
86
+ const state = { ctx: flow.ctx(2) } as State;
87
+
88
+ {
89
+ const t = flow.processEvent(state, gameEvent('endPhase'));
90
+ expect(t.ctx.phase).toBe('B');
91
+ }
92
+
93
+ {
94
+ const t = flow.processEvent(state, gameEvent('endTurn'));
95
+ expect(t.ctx.phase).toBe('B');
96
+ }
97
+
98
+ {
99
+ const t = flow.processMove(state, makeMove('').payload);
100
+ expect(t.ctx.phase).toBe('B');
101
+ }
102
+ });
103
+
104
+ describe('onEnd', () => {
105
+ let client: ReturnType<typeof Client>;
106
+
107
+ beforeAll(() => {
108
+ const game: Game = {
109
+ endIf: () => true,
110
+ onEnd: ({ G }) => {
111
+ G.onEnd = true;
112
+ },
113
+ };
114
+ client = Client({ game });
115
+ });
116
+
117
+ test('works', () => {
118
+ expect(client.getState().G).toEqual({
119
+ onEnd: true,
120
+ });
121
+ });
122
+ });
123
+
124
+ test('end phase on move', () => {
125
+ let endPhaseACount = 0;
126
+ let endPhaseBCount = 0;
127
+
128
+ const flow = Flow({
129
+ phases: {
130
+ A: {
131
+ start: true,
132
+ endIf: () => true,
133
+ onEnd: () => ++endPhaseACount,
134
+ next: 'B',
135
+ },
136
+ B: {
137
+ endIf: () => false,
138
+ onEnd: () => ++endPhaseBCount,
139
+ },
140
+ },
141
+ });
142
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
143
+
144
+ expect(state.ctx.phase).toBe('A');
145
+ state = flow.processMove(state, makeMove('').payload);
146
+ expect(state.ctx.phase).toBe('B');
147
+
148
+ expect(endPhaseACount).toEqual(1);
149
+ expect(endPhaseBCount).toEqual(0);
150
+ });
151
+
152
+ test('endPhase returns to null phase', () => {
153
+ const flow = Flow({
154
+ phases: { A: { start: true }, B: {}, C: {} },
155
+ });
156
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
157
+ state = flow.init(state);
158
+
159
+ expect(state.ctx.phase).toBe('A');
160
+ state = flow.processEvent(state, gameEvent('endPhase'));
161
+ expect(state.ctx.phase).toBe(null);
162
+ });
163
+
164
+ test('increment playOrderPos on phase end', () => {
165
+ const flow = Flow({
166
+ phases: { A: { start: true, next: 'B' }, B: { next: 'A' } },
167
+ });
168
+ let state = { G: {}, ctx: flow.ctx(3) } as State;
169
+ state = flow.init(state);
170
+
171
+ expect(state.ctx.playOrderPos).toBe(0);
172
+ state = flow.processEvent(state, gameEvent('endTurn'));
173
+ expect(state.ctx.playOrderPos).toBe(1);
174
+ state = flow.processEvent(state, gameEvent('endPhase'));
175
+ expect(state.ctx.playOrderPos).toBe(2);
176
+ });
177
+
178
+ describe('setPhase', () => {
179
+ let flow: ReturnType<typeof Flow>;
180
+ beforeEach(() => {
181
+ flow = Flow({
182
+ phases: { A: { start: true }, B: {} },
183
+ });
184
+ });
185
+
186
+ test('basic', () => {
187
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
188
+ state = flow.init(state);
189
+
190
+ expect(state.ctx.phase).toBe('A');
191
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
192
+ expect(state.ctx.phase).toBe('B');
193
+ });
194
+
195
+ test('invalid arg', () => {
196
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
197
+ state = flow.init(state);
198
+
199
+ expect(state.ctx.phase).toBe('A');
200
+ state = flow.processEvent(state, gameEvent('setPhase', 'C'));
201
+ expect(error).toBeCalledWith('invalid phase: C');
202
+ expect(state.ctx.phase).toBe(null);
203
+ });
204
+ });
205
+ });
206
+
207
+ describe('turn', () => {
208
+ test('onEnd', () => {
209
+ const onEnd = jest.fn(({ G }) => G);
210
+ const flow = Flow({
211
+ turn: { onEnd },
212
+ });
213
+ const state = { ctx: flow.ctx(2) } as State;
214
+ flow.init(state);
215
+
216
+ expect(onEnd).not.toHaveBeenCalled();
217
+ flow.processEvent(state, gameEvent('endTurn'));
218
+ expect(onEnd).toHaveBeenCalled();
219
+ });
220
+
221
+ describe('onMove', () => {
222
+ const onMove = () => ({ A: true });
223
+
224
+ test('top level callback', () => {
225
+ const flow = Flow({ turn: { onMove } });
226
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
227
+ state = flow.processMove(state, makeMove('').payload);
228
+ expect(state.G).toEqual({ A: true });
229
+ });
230
+
231
+ test('phase specific callback', () => {
232
+ const flow = Flow({
233
+ turn: { onMove },
234
+ phases: { B: { turn: { onMove: () => ({ B: true }) } } },
235
+ });
236
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
237
+ state = flow.processMove(state, makeMove('').payload);
238
+ expect(state.G).toEqual({ A: true });
239
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
240
+ state = flow.processMove(state, makeMove('').payload);
241
+ expect(state.G).toEqual({ B: true });
242
+ });
243
+
244
+ test('ctx with playerID', () => {
245
+ const playerID = 'playerID';
246
+ const flow = Flow({
247
+ turn: { onMove: ({ playerID }) => ({ playerID }) },
248
+ });
249
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
250
+ state = flow.processMove(
251
+ state,
252
+ makeMove('', undefined, 'playerID').payload
253
+ );
254
+ expect(state.G.playerID).toEqual(playerID);
255
+ });
256
+ });
257
+
258
+ describe('minMoves', () => {
259
+ describe('without phases', () => {
260
+ const flow = Flow({
261
+ turn: {
262
+ minMoves: 2,
263
+ },
264
+ });
265
+
266
+ test('player cannot endTurn if not enough moves were made', () => {
267
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
268
+
269
+ expect(state.ctx.turn).toBe(1);
270
+ expect(state.ctx.currentPlayer).toBe('0');
271
+
272
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
273
+ state = flow.processEvent(state, gameEvent('endTurn'));
274
+
275
+ expect(state.ctx.turn).toBe(1);
276
+ expect(state.ctx.currentPlayer).toBe('0');
277
+ });
278
+
279
+ test('player can endTurn after enough moves were made', () => {
280
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
281
+
282
+ expect(state.ctx.turn).toBe(1);
283
+ expect(state.ctx.currentPlayer).toBe('0');
284
+
285
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
286
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
287
+ state = flow.processEvent(state, gameEvent('endTurn'));
288
+
289
+ expect(state.ctx.turn).toBe(2);
290
+ expect(state.ctx.currentPlayer).toBe('1');
291
+ });
292
+ });
293
+
294
+ describe('with phases', () => {
295
+ const flow = Flow({
296
+ turn: { minMoves: 2 },
297
+ phases: {
298
+ B: {
299
+ turn: {
300
+ minMoves: 1,
301
+ },
302
+ },
303
+ },
304
+ });
305
+
306
+ test('player cannot endTurn if not enough moves were made in default phase', () => {
307
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
308
+
309
+ expect(state.ctx.turn).toBe(1);
310
+ expect(state.ctx.currentPlayer).toBe('0');
311
+
312
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
313
+ state = flow.processEvent(state, gameEvent('endTurn'));
314
+
315
+ expect(state.ctx.turn).toBe(1);
316
+ expect(state.ctx.currentPlayer).toBe('0');
317
+ });
318
+
319
+ test('player can endTurn after enough moves were made in default phase', () => {
320
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
321
+
322
+ expect(state.ctx.turn).toBe(1);
323
+ expect(state.ctx.currentPlayer).toBe('0');
324
+
325
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
326
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
327
+ state = flow.processEvent(state, gameEvent('endTurn'));
328
+
329
+ expect(state.ctx.turn).toBe(2);
330
+ expect(state.ctx.currentPlayer).toBe('1');
331
+ });
332
+
333
+ test('player cannot endTurn if no move was made in explicit phase', () => {
334
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
335
+
336
+ expect(state.ctx.turn).toBe(1);
337
+ expect(state.ctx.currentPlayer).toBe('0');
338
+
339
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
340
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
341
+ state = flow.processEvent(state, gameEvent('endTurn'));
342
+ state = flow.processMove(state, makeMove('move', null, '1').payload);
343
+
344
+ expect(state.ctx.turn).toBe(2);
345
+ expect(state.ctx.currentPlayer).toBe('1');
346
+
347
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
348
+ state = flow.processEvent(state, gameEvent('endTurn'));
349
+
350
+ expect(state.ctx.turn).toBe(3);
351
+ expect(state.ctx.currentPlayer).toBe('0');
352
+ });
353
+
354
+ test('player can endTurn after having made a move, fewer moves needed in explicit phase', () => {
355
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
356
+
357
+ expect(state.ctx.turn).toBe(1);
358
+ expect(state.ctx.currentPlayer).toBe('0');
359
+
360
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
361
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
362
+ state = flow.processEvent(state, gameEvent('endTurn'));
363
+ state = flow.processMove(state, makeMove('move', null, '1').payload);
364
+
365
+ expect(state.ctx.turn).toBe(2);
366
+ expect(state.ctx.currentPlayer).toBe('1');
367
+
368
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
369
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
370
+ state = flow.processEvent(state, gameEvent('endTurn'));
371
+
372
+ expect(state.ctx.turn).toBe(4);
373
+ expect(state.ctx.currentPlayer).toBe('1');
374
+ });
375
+ });
376
+ });
377
+
378
+ describe('maxMoves', () => {
379
+ describe('without phases', () => {
380
+ const flow = Flow({
381
+ turn: {
382
+ maxMoves: 2,
383
+ },
384
+ });
385
+
386
+ test('manual endTurn works, even if not enough moves were made', () => {
387
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
388
+
389
+ expect(state.ctx.turn).toBe(1);
390
+ expect(state.ctx.currentPlayer).toBe('0');
391
+
392
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
393
+ state = flow.processEvent(state, gameEvent('endTurn'));
394
+
395
+ expect(state.ctx.turn).toBe(2);
396
+ expect(state.ctx.currentPlayer).toBe('1');
397
+ });
398
+
399
+ test('turn automatically ends after making enough moves', () => {
400
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
401
+
402
+ expect(state.ctx.turn).toBe(1);
403
+ expect(state.ctx.currentPlayer).toBe('0');
404
+
405
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
406
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
407
+
408
+ expect(state.ctx.turn).toBe(2);
409
+ expect(state.ctx.currentPlayer).toBe('1');
410
+ });
411
+ });
412
+
413
+ describe('with phases', () => {
414
+ const flow = Flow({
415
+ turn: { maxMoves: 2 },
416
+ phases: {
417
+ B: {
418
+ turn: { maxMoves: 1 },
419
+ },
420
+ },
421
+ });
422
+
423
+ test('manual endTurn works in all phases, even if fewer than maxMoves have been made', () => {
424
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
425
+
426
+ expect(state.ctx.turn).toBe(1);
427
+ expect(state.ctx.currentPlayer).toBe('0');
428
+
429
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
430
+ state = flow.processEvent(state, gameEvent('endTurn'));
431
+
432
+ expect(state.ctx.turn).toBe(2);
433
+ expect(state.ctx.currentPlayer).toBe('1');
434
+
435
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
436
+
437
+ expect(state.ctx.turn).toBe(3);
438
+ expect(state.ctx.currentPlayer).toBe('0');
439
+
440
+ state = flow.processEvent(state, gameEvent('endTurn'));
441
+
442
+ expect(state.ctx.turn).toBe(4);
443
+ expect(state.ctx.currentPlayer).toBe('1');
444
+ });
445
+
446
+ test('automatic endTurn triggers after fewer moves in different phase', () => {
447
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
448
+
449
+ expect(state.ctx.turn).toBe(1);
450
+ expect(state.ctx.currentPlayer).toBe('0');
451
+
452
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
453
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
454
+
455
+ expect(state.ctx.turn).toBe(2);
456
+ expect(state.ctx.currentPlayer).toBe('1');
457
+
458
+ state = flow.processEvent(state, gameEvent('setPhase', 'B'));
459
+
460
+ expect(state.ctx.turn).toBe(3);
461
+ expect(state.ctx.currentPlayer).toBe('0');
462
+
463
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
464
+
465
+ expect(state.ctx.turn).toBe(4);
466
+ expect(state.ctx.currentPlayer).toBe('1');
467
+ });
468
+ });
469
+
470
+ test('with noLimit moves', () => {
471
+ const flow = Flow({
472
+ turn: {
473
+ maxMoves: 2,
474
+ },
475
+ moves: {
476
+ A: () => {},
477
+ B: {
478
+ move: () => {},
479
+ noLimit: true,
480
+ },
481
+ },
482
+ });
483
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
484
+ expect(state.ctx.turn).toBe(1);
485
+ expect(state.ctx.numMoves).toBe(0);
486
+ state = flow.processMove(state, makeMove('A', null, '0').payload);
487
+ expect(state.ctx.turn).toBe(1);
488
+ expect(state.ctx.numMoves).toBe(1);
489
+ state = flow.processMove(state, makeMove('B', null, '0').payload);
490
+ expect(state.ctx.turn).toBe(1);
491
+ expect(state.ctx.numMoves).toBe(1);
492
+ state = flow.processMove(state, makeMove('A', null, '0').payload);
493
+ expect(state.ctx.turn).toBe(2);
494
+ expect(state.ctx.numMoves).toBe(0);
495
+ });
496
+ });
497
+
498
+ describe('endIf', () => {
499
+ test('global', () => {
500
+ const game: Game = {
501
+ moves: {
502
+ A: () => ({ endTurn: true }),
503
+ B: ({ G }) => G,
504
+ },
505
+ turn: { endIf: ({ G }) => G.endTurn },
506
+ };
507
+ const client = Client({ game });
508
+
509
+ expect(client.getState().ctx.currentPlayer).toBe('0');
510
+ client.moves.B();
511
+ expect(client.getState().ctx.currentPlayer).toBe('0');
512
+ client.moves.A();
513
+ expect(client.getState().ctx.currentPlayer).toBe('1');
514
+ });
515
+
516
+ test('phase specific', () => {
517
+ const game: Game = {
518
+ moves: {
519
+ A: () => ({ endTurn: true }),
520
+ B: ({ G }) => G,
521
+ },
522
+ phases: {
523
+ A: { start: true, turn: { endIf: ({ G }) => G.endTurn } },
524
+ },
525
+ };
526
+ const client = Client({ game });
527
+
528
+ expect(client.getState().ctx.currentPlayer).toBe('0');
529
+ client.moves.B();
530
+ expect(client.getState().ctx.currentPlayer).toBe('0');
531
+ client.moves.A();
532
+ expect(client.getState().ctx.currentPlayer).toBe('1');
533
+ });
534
+
535
+ test('return value', () => {
536
+ const game: Game = {
537
+ moves: {
538
+ A: ({ G }) => G,
539
+ },
540
+ turn: { endIf: () => ({ next: '2' }) },
541
+ };
542
+ const client = Client({ game, numPlayers: 3 });
543
+
544
+ expect(client.getState().ctx.currentPlayer).toBe('0');
545
+ client.moves.A();
546
+ expect(client.getState().ctx.currentPlayer).toBe('2');
547
+ });
548
+ });
549
+
550
+ test('endTurn is not called twice in one move', () => {
551
+ const flow = Flow({
552
+ turn: { endIf: () => true },
553
+ phases: {
554
+ A: { start: true, endIf: ({ G }) => G.endPhase, next: 'B' },
555
+ B: {},
556
+ },
557
+ });
558
+
559
+ let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
560
+
561
+ expect(state.ctx.phase).toBe('A');
562
+ expect(state.ctx.currentPlayer).toBe('0');
563
+ expect(state.ctx.turn).toBe(1);
564
+
565
+ state = flow.processMove(state, makeMove('').payload);
566
+
567
+ expect(state.ctx.phase).toBe('A');
568
+ expect(state.ctx.currentPlayer).toBe('1');
569
+ expect(state.ctx.turn).toBe(2);
570
+
571
+ state.G = { endPhase: true };
572
+
573
+ state = flow.processMove(state, makeMove('').payload);
574
+
575
+ expect(state.ctx.phase).toBe('B');
576
+ expect(state.ctx.currentPlayer).toBe('0');
577
+ expect(state.ctx.turn).toBe(3);
578
+ });
579
+ });
580
+
581
+ describe('stages', () => {
582
+ let client: ReturnType<typeof Client>;
583
+
584
+ beforeAll(() => {
585
+ const A = () => {};
586
+ const B = () => {};
587
+
588
+ const game: Game = {
589
+ moves: { A },
590
+ turn: {
591
+ stages: {
592
+ B: { moves: { B } },
593
+ C: {},
594
+ },
595
+ },
596
+ };
597
+
598
+ client = Client({ game });
599
+ });
600
+
601
+ beforeEach(() => {
602
+ jest.resetAllMocks();
603
+ });
604
+
605
+ describe('no stage', () => {
606
+ test('A is allowed', () => {
607
+ client.moves.A();
608
+ expect(error).not.toBeCalled();
609
+ });
610
+
611
+ test('B is not allowed', () => {
612
+ client.moves.B();
613
+ expect(error).toBeCalledWith('disallowed move: B');
614
+ });
615
+ });
616
+
617
+ describe('stage B', () => {
618
+ beforeAll(() => {
619
+ client.events.setStage('B');
620
+ });
621
+
622
+ test('A is not allowed', () => {
623
+ client.moves.A();
624
+ expect(error).toBeCalledWith('disallowed move: A');
625
+ });
626
+
627
+ test('B is allowed', () => {
628
+ client.moves.B();
629
+ expect(error).not.toBeCalled();
630
+ });
631
+ });
632
+
633
+ describe('stage C', () => {
634
+ beforeAll(() => {
635
+ client.events.setStage('C');
636
+ });
637
+
638
+ test('A is allowed', () => {
639
+ client.moves.A();
640
+ expect(error).not.toBeCalled();
641
+ });
642
+
643
+ test('B is not allowed', () => {
644
+ client.moves.B();
645
+ expect(error).toBeCalledWith('disallowed move: B');
646
+ });
647
+ });
648
+
649
+ test('stage updates can be reacted to in turn.endIf', () => {
650
+ const client = Client({
651
+ game: {
652
+ turn: {
653
+ activePlayers: {
654
+ all: 'A',
655
+ },
656
+ stages: {
657
+ A: {
658
+ moves: {
659
+ leaveStage: ({ events }) => void events.endStage(),
660
+ },
661
+ },
662
+ },
663
+ endIf: ({ ctx }) => ctx.activePlayers === null,
664
+ },
665
+ },
666
+ });
667
+
668
+ let state = client.getState();
669
+ expect(state.ctx.turn).toBe(1);
670
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
671
+
672
+ client.updatePlayerID('0');
673
+
674
+ client.moves.leaveStage();
675
+ state = client.getState();
676
+ expect(state.ctx.turn).toBe(1);
677
+ expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
678
+
679
+ client.updatePlayerID('1');
680
+ client.moves.leaveStage();
681
+ state = client.getState();
682
+ expect(state.ctx.turn).toBe(2);
683
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
684
+ });
685
+
686
+ test('stage changes due to move limits are seen by turn.endIf', () => {
687
+ const client = Client({
688
+ game: {
689
+ turn: {
690
+ activePlayers: {
691
+ currentPlayer: 'A',
692
+ maxMoves: 1,
693
+ },
694
+ endIf: ({ ctx }) => ctx.activePlayers === null,
695
+ stages: {
696
+ A: {
697
+ moves: {
698
+ A: () => ({ moved: true }),
699
+ },
700
+ },
701
+ },
702
+ },
703
+ },
704
+ });
705
+
706
+ let state = client.getState();
707
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
708
+ client.moves.A();
709
+ state = client.getState();
710
+ expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
711
+ });
712
+ });
713
+
714
+ describe('stage events', () => {
715
+ describe('setStage', () => {
716
+ test('basic', () => {
717
+ const flow = Flow({});
718
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
719
+ state = flow.init(state);
720
+
721
+ expect(state.ctx.activePlayers).toBeNull();
722
+ state = flow.processEvent(state, gameEvent('setStage', 'A'));
723
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
724
+ });
725
+
726
+ test('object syntax', () => {
727
+ const flow = Flow({});
728
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
729
+ state = flow.init(state);
730
+
731
+ expect(state.ctx.activePlayers).toBeNull();
732
+ state = flow.processEvent(state, gameEvent('setStage', { stage: 'A' }));
733
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
734
+ });
735
+
736
+ test('with multiple active players', () => {
737
+ const flow = Flow({
738
+ turn: {
739
+ activePlayers: { all: 'A', minMoves: 2, maxMoves: 5 },
740
+ },
741
+ });
742
+ let state = { G: {}, ctx: flow.ctx(3) } as State;
743
+ state = flow.init(state);
744
+
745
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' });
746
+ state = flow.processEvent(
747
+ state,
748
+ gameEvent('setStage', { stage: 'B', minMoves: 1 })
749
+ );
750
+ expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'A' });
751
+
752
+ state = flow.processEvent(
753
+ state,
754
+ gameEvent('setStage', { stage: 'B', maxMoves: 1 }, '1')
755
+ );
756
+ expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'B', '2': 'A' });
757
+ });
758
+
759
+ test('resets move count', () => {
760
+ const flow = Flow({
761
+ moves: { A: () => {} },
762
+ turn: {
763
+ activePlayers: { currentPlayer: 'A' },
764
+ },
765
+ });
766
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
767
+ state = flow.init(state);
768
+
769
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
770
+ state = flow.processMove(state, makeMove('A', null, '0').payload);
771
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
772
+ state = flow.processEvent(state, gameEvent('setStage', 'B'));
773
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
774
+ });
775
+
776
+ test('with min moves', () => {
777
+ const flow = Flow({});
778
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
779
+ state = flow.init(state);
780
+
781
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
782
+ expect(state.ctx._activePlayersMaxMoves).toBeNull();
783
+ state = flow.processEvent(
784
+ state,
785
+ gameEvent('setStage', { stage: 'A', minMoves: 1 })
786
+ );
787
+ expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1 });
788
+ expect(state.ctx._activePlayersMaxMoves).toBeNull();
789
+ });
790
+
791
+ test('with max moves', () => {
792
+ const flow = Flow({});
793
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
794
+ state = flow.init(state);
795
+
796
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
797
+ expect(state.ctx._activePlayersMaxMoves).toBeNull();
798
+ state = flow.processEvent(
799
+ state,
800
+ gameEvent('setStage', { stage: 'A', maxMoves: 1 })
801
+ );
802
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
803
+ expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1 });
804
+ });
805
+
806
+ test('empty argument ends stage', () => {
807
+ const flow = Flow({ turn: { activePlayers: { currentPlayer: 'A' } } });
808
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
809
+ state = flow.init(state);
810
+
811
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
812
+ state = flow.processEvent(state, gameEvent('setStage', {}));
813
+ expect(state.ctx.activePlayers).toBeNull();
814
+ });
815
+
816
+ describe('disallowed in hooks', () => {
817
+ const setStage: MoveFn = ({ events }) => {
818
+ events.setStage('A');
819
+ };
820
+
821
+ test('phase.onBegin', () => {
822
+ const game: Game = {
823
+ phases: {
824
+ A: {
825
+ start: true,
826
+ onBegin: setStage,
827
+ },
828
+ },
829
+ };
830
+ Client({ game });
831
+ expect(error).toHaveBeenCalled();
832
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
833
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
834
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
835
+ });
836
+
837
+ test('phase.onEnd', () => {
838
+ const game: Game = {
839
+ phases: {
840
+ A: {
841
+ start: true,
842
+ onEnd: setStage,
843
+ },
844
+ },
845
+ };
846
+ const client = Client({ game });
847
+ expect(error).not.toHaveBeenCalled();
848
+ client.events.endPhase();
849
+ expect(error).toHaveBeenCalled();
850
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
851
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
852
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
853
+ });
854
+
855
+ test('turn.onBegin', () => {
856
+ const game: Game = {
857
+ turn: {
858
+ onBegin: setStage,
859
+ },
860
+ };
861
+ Client({ game });
862
+ expect(error).toHaveBeenCalled();
863
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
864
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
865
+ expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/);
866
+ });
867
+
868
+ test('turn.onEnd', () => {
869
+ const game: Game = {
870
+ turn: {
871
+ onEnd: setStage,
872
+ },
873
+ };
874
+ const client = Client({ game });
875
+ expect(error).not.toHaveBeenCalled();
876
+ client.events.endTurn();
877
+ expect(error).toHaveBeenCalled();
878
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
879
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
880
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
881
+ });
882
+ });
883
+ });
884
+
885
+ describe('endStage', () => {
886
+ test('basic', () => {
887
+ const flow = Flow({
888
+ turn: {
889
+ activePlayers: { currentPlayer: 'A' },
890
+ },
891
+ });
892
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
893
+ state = flow.init(state);
894
+
895
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
896
+ state = flow.processEvent(state, gameEvent('endStage'));
897
+ expect(state.ctx.activePlayers).toBeNull();
898
+ });
899
+
900
+ test('with multiple active players', () => {
901
+ const flow = Flow({
902
+ turn: {
903
+ activePlayers: { all: 'A', maxMoves: 5 },
904
+ },
905
+ });
906
+ let state = { G: {}, ctx: flow.ctx(3) } as State;
907
+ state = flow.init(state);
908
+
909
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' });
910
+ state = flow.processEvent(state, gameEvent('endStage'));
911
+ expect(state.ctx.activePlayers).toEqual({ '1': 'A', '2': 'A' });
912
+ });
913
+
914
+ test('with min moves', () => {
915
+ const flow = Flow({
916
+ turn: {
917
+ activePlayers: { all: 'A', minMoves: 2 },
918
+ },
919
+ });
920
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
921
+ state = flow.init(state);
922
+
923
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
924
+
925
+ state = flow.processEvent(state, gameEvent('endStage'));
926
+
927
+ // player 0 is not allowed to end the stage, they haven't made any move yet
928
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
929
+
930
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
931
+ state = flow.processEvent(state, gameEvent('endStage'));
932
+
933
+ // player 0 is still not allowed to end the stage, they haven't made the minimum number of moves
934
+ expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
935
+
936
+ state = flow.processMove(state, makeMove('move', null, '0').payload);
937
+ state = flow.processEvent(state, gameEvent('endStage'));
938
+
939
+ // having made 2 moves, player 0 was allowed to end the stage
940
+ expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
941
+ });
942
+
943
+ test('maintains move count', () => {
944
+ const flow = Flow({
945
+ moves: { A: () => {} },
946
+ turn: {
947
+ activePlayers: { currentPlayer: 'A' },
948
+ },
949
+ });
950
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
951
+ state = flow.init(state);
952
+
953
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
954
+ state = flow.processMove(state, makeMove('A', null, '0').payload);
955
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
956
+ state = flow.processEvent(state, gameEvent('endStage'));
957
+ expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
958
+ });
959
+
960
+ test('sets to next', () => {
961
+ const flow = Flow({
962
+ turn: {
963
+ activePlayers: { currentPlayer: 'A1', others: 'B1' },
964
+ stages: {
965
+ A1: { next: 'A2' },
966
+ B1: { next: 'B2' },
967
+ },
968
+ },
969
+ });
970
+ let state = { G: {}, ctx: flow.ctx(2) } as State;
971
+ state = flow.init(state);
972
+
973
+ expect(state.ctx.activePlayers).toMatchObject({
974
+ '0': 'A1',
975
+ '1': 'B1',
976
+ });
977
+
978
+ state = flow.processEvent(state, gameEvent('endStage', null, '0'));
979
+
980
+ expect(state.ctx.activePlayers).toMatchObject({
981
+ '0': 'A2',
982
+ '1': 'B1',
983
+ });
984
+
985
+ state = flow.processEvent(state, gameEvent('endStage', null, '1'));
986
+
987
+ expect(state.ctx.activePlayers).toMatchObject({
988
+ '0': 'A2',
989
+ '1': 'B2',
990
+ });
991
+ });
992
+
993
+ describe('disallowed in hooks', () => {
994
+ const endStage: MoveFn = ({ events }) => {
995
+ events.endStage();
996
+ };
997
+
998
+ test('phase.onBegin', () => {
999
+ const game: Game = {
1000
+ phases: {
1001
+ A: {
1002
+ start: true,
1003
+ onBegin: endStage,
1004
+ },
1005
+ },
1006
+ };
1007
+ Client({ game });
1008
+ expect(error).toHaveBeenCalled();
1009
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1010
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1011
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
1012
+ });
1013
+
1014
+ test('phase.onEnd', () => {
1015
+ const game: Game = {
1016
+ phases: {
1017
+ A: {
1018
+ start: true,
1019
+ onEnd: endStage,
1020
+ },
1021
+ },
1022
+ };
1023
+ const client = Client({ game });
1024
+ expect(error).not.toHaveBeenCalled();
1025
+ client.events.endPhase();
1026
+ expect(error).toHaveBeenCalled();
1027
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1028
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1029
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
1030
+ });
1031
+
1032
+ test('turn.onBegin', () => {
1033
+ const game: Game = {
1034
+ turn: {
1035
+ onBegin: endStage,
1036
+ },
1037
+ };
1038
+ Client({ game });
1039
+ expect(error).toHaveBeenCalled();
1040
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1041
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1042
+ expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/);
1043
+ });
1044
+
1045
+ test('turn.onEnd', () => {
1046
+ const game: Game = {
1047
+ turn: {
1048
+ onEnd: endStage,
1049
+ },
1050
+ };
1051
+ const client = Client({ game });
1052
+ expect(error).not.toHaveBeenCalled();
1053
+ client.events.endTurn();
1054
+ expect(error).toHaveBeenCalled();
1055
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1056
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1057
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
1058
+ });
1059
+ });
1060
+ });
1061
+
1062
+ describe('setActivePlayers', () => {
1063
+ test('basic', () => {
1064
+ const client = Client({
1065
+ numPlayers: 3,
1066
+ game: {
1067
+ turn: {
1068
+ onBegin: ({ events }) => {
1069
+ events.setActivePlayers({ currentPlayer: 'A' });
1070
+ },
1071
+ },
1072
+ moves: {
1073
+ updateActivePlayers: ({ events }) => {
1074
+ events.setActivePlayers({ others: 'B' });
1075
+ },
1076
+ },
1077
+ },
1078
+ });
1079
+ expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' });
1080
+ client.moves.updateActivePlayers();
1081
+ expect(client.getState().ctx.activePlayers).toEqual({
1082
+ '1': 'B',
1083
+ '2': 'B',
1084
+ });
1085
+ });
1086
+
1087
+ describe('in hooks', () => {
1088
+ const setActivePlayers: MoveFn = ({ events }) => {
1089
+ events.setActivePlayers({ currentPlayer: 'A' });
1090
+ };
1091
+
1092
+ test('disallowed in phase.onBegin', () => {
1093
+ const game: Game = {
1094
+ phases: {
1095
+ A: {
1096
+ start: true,
1097
+ onBegin: setActivePlayers,
1098
+ },
1099
+ },
1100
+ };
1101
+ Client({ game });
1102
+ expect(error).toHaveBeenCalled();
1103
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1104
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1105
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
1106
+ });
1107
+
1108
+ test('disallowed in phase.onEnd', () => {
1109
+ const game: Game = {
1110
+ phases: {
1111
+ A: {
1112
+ start: true,
1113
+ onEnd: setActivePlayers,
1114
+ },
1115
+ },
1116
+ };
1117
+ const client = Client({ game });
1118
+ expect(error).not.toHaveBeenCalled();
1119
+ client.events.endPhase();
1120
+ expect(error).toHaveBeenCalled();
1121
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1122
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1123
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
1124
+ });
1125
+
1126
+ test('allowed in turn.onBegin', () => {
1127
+ const client = Client({
1128
+ game: {
1129
+ turn: { onBegin: setActivePlayers },
1130
+ },
1131
+ });
1132
+ expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' });
1133
+ expect(error).not.toHaveBeenCalled();
1134
+ });
1135
+
1136
+ test('disallowed in turn.onEnd', () => {
1137
+ const game: Game = {
1138
+ turn: {
1139
+ onEnd: setActivePlayers,
1140
+ },
1141
+ };
1142
+ const client = Client({ game });
1143
+ expect(error).not.toHaveBeenCalled();
1144
+ client.events.endTurn();
1145
+ expect(error).toHaveBeenCalled();
1146
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1147
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1148
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
1149
+ });
1150
+ });
1151
+ });
1152
+ });
1153
+
1154
+ test('init', () => {
1155
+ let flow = Flow({
1156
+ phases: { A: { start: true, onEnd: () => ({ done: true }) } },
1157
+ });
1158
+
1159
+ const orig = flow.ctx(2);
1160
+ let state = { G: {}, ctx: orig } as State;
1161
+ state = flow.processEvent(state, gameEvent('init'));
1162
+ expect(state).toEqual({ G: {}, ctx: orig });
1163
+
1164
+ flow = Flow({
1165
+ phases: { A: { start: true, onBegin: () => ({ done: true }) } },
1166
+ });
1167
+
1168
+ state = { ctx: orig } as State;
1169
+ state = flow.init(state);
1170
+ expect(state.G).toMatchObject({ done: true });
1171
+ });
1172
+
1173
+ test('next', () => {
1174
+ const flow = Flow({
1175
+ phases: {
1176
+ A: { start: true, next: () => 'C' },
1177
+ B: {},
1178
+ C: {},
1179
+ },
1180
+ });
1181
+
1182
+ let state = { ctx: flow.ctx(3) } as State;
1183
+ state = flow.processEvent(state, gameEvent('endPhase'));
1184
+
1185
+ expect(state.ctx.phase).toEqual('C');
1186
+ });
1187
+
1188
+ describe('endIf', () => {
1189
+ test('basic', () => {
1190
+ const flow = Flow({ endIf: ({ G }) => G.win });
1191
+
1192
+ let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
1193
+ state = flow.processEvent(state, gameEvent('endTurn'));
1194
+ expect(state.ctx.gameover).toBe(undefined);
1195
+
1196
+ state.G = { win: 'A' };
1197
+
1198
+ {
1199
+ const t = flow.processEvent(state, gameEvent('endTurn'));
1200
+ expect(t.ctx.gameover).toBe('A');
1201
+ }
1202
+
1203
+ {
1204
+ const t = flow.processMove(state, makeMove('move').payload);
1205
+ expect(t.ctx.gameover).toBe('A');
1206
+ }
1207
+ });
1208
+
1209
+ test('phase automatically ends', () => {
1210
+ const game: Game = {
1211
+ phases: {
1212
+ A: {
1213
+ start: true,
1214
+ moves: {
1215
+ A: () => ({ win: 'A' }),
1216
+ B: ({ G }) => G,
1217
+ },
1218
+ },
1219
+ },
1220
+ endIf: ({ G }) => G.win,
1221
+ };
1222
+ const client = Client({ game });
1223
+
1224
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1225
+ client.moves.B();
1226
+ expect(client.getState().ctx.gameover).toBe(undefined);
1227
+
1228
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1229
+ client.moves.A();
1230
+ expect(client.getState().ctx.gameover).toBe('A');
1231
+ expect(
1232
+ client.getState().deltalog[client.getState().deltalog.length - 1].action
1233
+ .payload.type
1234
+ ).toBe('endPhase');
1235
+ });
1236
+
1237
+ test('during game initialization with phases', () => {
1238
+ const flow = Flow({
1239
+ phases: {
1240
+ A: {
1241
+ start: true,
1242
+ },
1243
+ },
1244
+ endIf: () => 'gameover',
1245
+ });
1246
+
1247
+ const state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
1248
+ expect(state.ctx.gameover).toBe('gameover');
1249
+ });
1250
+ });
1251
+
1252
+ test('isPlayerActive', () => {
1253
+ const playerID = '0';
1254
+
1255
+ const flow = Flow({});
1256
+ expect(flow.isPlayerActive({}, {} as Ctx, playerID)).toBe(false);
1257
+ expect(
1258
+ flow.isPlayerActive(
1259
+ {},
1260
+ { currentPlayer: '0', activePlayers: { '1': '' } } as unknown as Ctx,
1261
+ playerID
1262
+ )
1263
+ ).toBe(false);
1264
+ expect(flow.isPlayerActive({}, { currentPlayer: '0' } as Ctx, playerID)).toBe(
1265
+ true
1266
+ );
1267
+ });
1268
+
1269
+ describe('endGame', () => {
1270
+ let client: ReturnType<typeof Client>;
1271
+ beforeEach(() => {
1272
+ const game: Game = {
1273
+ events: { endGame: true },
1274
+ };
1275
+ client = Client({ game });
1276
+ });
1277
+
1278
+ test('without arguments', () => {
1279
+ client.events.endGame();
1280
+ expect(client.getState().ctx.gameover).toBe(true);
1281
+ });
1282
+
1283
+ test('with arguments', () => {
1284
+ client.events.endGame(42);
1285
+ expect(client.getState().ctx.gameover).toBe(42);
1286
+ });
1287
+ });
1288
+
1289
+ describe('endTurn args', () => {
1290
+ const flow = Flow({
1291
+ phases: { A: { start: true, next: 'B' }, B: {}, C: {} },
1292
+ });
1293
+
1294
+ const state = { ctx: flow.ctx(3) } as State;
1295
+
1296
+ beforeEach(() => {
1297
+ jest.resetAllMocks();
1298
+ });
1299
+
1300
+ test('no args', () => {
1301
+ let t = state;
1302
+ t = flow.processEvent(t, gameEvent('endPhase'));
1303
+ t = flow.processEvent(t, gameEvent('endTurn'));
1304
+ expect(t.ctx.playOrderPos).toBe(1);
1305
+ expect(t.ctx.currentPlayer).toBe('1');
1306
+ expect(t.ctx.phase).toBe('B');
1307
+ });
1308
+
1309
+ test('invalid arg to endTurn', () => {
1310
+ let t = state;
1311
+ t = flow.processEvent(t, gameEvent('endTurn', '2'));
1312
+ expect(error).toBeCalledWith(`invalid argument to endTurn: 2`);
1313
+ expect(t.ctx.currentPlayer).toBe('0');
1314
+ });
1315
+
1316
+ test('valid args', () => {
1317
+ let t = state;
1318
+ t = flow.processEvent(t, gameEvent('endTurn', { next: '2' }));
1319
+ expect(t.ctx.playOrderPos).toBe(2);
1320
+ expect(t.ctx.currentPlayer).toBe('2');
1321
+ });
1322
+ });
1323
+
1324
+ describe('pass args', () => {
1325
+ const flow = Flow({
1326
+ phases: { A: { start: true, next: 'B' }, B: {}, C: {} },
1327
+ });
1328
+
1329
+ const state = { ctx: flow.ctx(3) } as State;
1330
+
1331
+ beforeEach(() => {
1332
+ jest.resetAllMocks();
1333
+ });
1334
+
1335
+ test('no args', () => {
1336
+ let t = state;
1337
+ t = flow.processEvent(t, gameEvent('pass'));
1338
+ expect(t.ctx.turn).toBe(1);
1339
+ expect(t.ctx.playOrderPos).toBe(1);
1340
+ expect(t.ctx.currentPlayer).toBe('1');
1341
+ });
1342
+
1343
+ test('invalid arg to pass', () => {
1344
+ let t = state;
1345
+ t = flow.processEvent(t, gameEvent('pass', '2'));
1346
+ expect(error).toBeCalledWith(`invalid argument to endTurn: 2`);
1347
+ expect(t.ctx.currentPlayer).toBe('0');
1348
+ });
1349
+
1350
+ test('valid args', () => {
1351
+ let t = state;
1352
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1353
+ expect(t.ctx.turn).toBe(1);
1354
+ expect(t.ctx.playOrderPos).toBe(0);
1355
+ expect(t.ctx.currentPlayer).toBe('1');
1356
+ });
1357
+
1358
+ test('removing all players ends phase', () => {
1359
+ let t = state;
1360
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1361
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1362
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1363
+ expect(t.ctx.playOrderPos).toBe(0);
1364
+ expect(t.ctx.currentPlayer).toBe('0');
1365
+ expect(t.ctx.phase).toBe('B');
1366
+ });
1367
+
1368
+ test('playOrderPos does not go out of bounds when passing at the end of the list', () => {
1369
+ let t = state;
1370
+ t = flow.processEvent(t, gameEvent('pass'));
1371
+ t = flow.processEvent(t, gameEvent('pass'));
1372
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1373
+ expect(t.ctx.currentPlayer).toBe('0');
1374
+ });
1375
+
1376
+ test('removing a player deeper into play order returns correct updated playOrder', () => {
1377
+ let t = state;
1378
+ t = flow.processEvent(t, gameEvent('pass'));
1379
+ t = flow.processEvent(t, gameEvent('pass', { remove: true }));
1380
+ expect(t.ctx.playOrderPos).toBe(1);
1381
+ expect(t.ctx.currentPlayer).toBe('2');
1382
+ });
1383
+ });
1384
+
1385
+ test('undoable moves', () => {
1386
+ const game: Game = {
1387
+ moves: {
1388
+ A: {
1389
+ move: () => ({ A: true }),
1390
+ undoable: ({ ctx }) => {
1391
+ return ctx.phase == 'A';
1392
+ },
1393
+ },
1394
+ B: {
1395
+ move: () => ({ B: true }),
1396
+ undoable: false,
1397
+ },
1398
+ C: () => ({ C: true }),
1399
+ },
1400
+
1401
+ phases: {
1402
+ A: { start: true },
1403
+ B: {},
1404
+ },
1405
+ };
1406
+
1407
+ const client = Client({ game });
1408
+
1409
+ client.moves.A();
1410
+ expect(client.getState().G).toEqual({ A: true });
1411
+ client.undo();
1412
+ expect(client.getState().G).toEqual({});
1413
+ client.moves.B();
1414
+ expect(client.getState().G).toEqual({ B: true });
1415
+ client.undo();
1416
+ expect(client.getState().G).toEqual({ B: true });
1417
+ client.moves.C();
1418
+ expect(client.getState().G).toEqual({ C: true });
1419
+ client.undo();
1420
+ expect(client.getState().G).toEqual({ B: true });
1421
+
1422
+ client.reset();
1423
+ client.events.setPhase('B');
1424
+ expect(client.getState().ctx.phase).toBe('B');
1425
+
1426
+ client.moves.A();
1427
+ expect(client.getState().G).toEqual({ A: true });
1428
+ client.undo();
1429
+ expect(client.getState().G).toEqual({ A: true });
1430
+ client.moves.B();
1431
+ expect(client.getState().G).toEqual({ B: true });
1432
+ client.undo();
1433
+ expect(client.getState().G).toEqual({ B: true });
1434
+ client.moves.C();
1435
+ expect(client.getState().G).toEqual({ C: true });
1436
+ client.undo();
1437
+ expect(client.getState().G).toEqual({ B: true });
1438
+ });
1439
+
1440
+ describe('moveMap', () => {
1441
+ const game: Game = {
1442
+ moves: { A: () => {} },
1443
+
1444
+ turn: {
1445
+ stages: {
1446
+ SA: {
1447
+ moves: {
1448
+ A: () => {},
1449
+ },
1450
+ },
1451
+ },
1452
+ },
1453
+
1454
+ phases: {
1455
+ PA: {
1456
+ moves: {
1457
+ A: () => {},
1458
+ },
1459
+
1460
+ turn: {
1461
+ stages: {
1462
+ SB: {
1463
+ moves: {
1464
+ A: () => {},
1465
+ },
1466
+ },
1467
+ },
1468
+ },
1469
+ },
1470
+ },
1471
+ };
1472
+
1473
+ test('basic', () => {
1474
+ const { moveMap } = Flow(game);
1475
+ expect(Object.keys(moveMap)).toEqual(['PA.A', 'PA.SB.A', '.SA.A']);
1476
+ });
1477
+ });
1478
+
1479
+ describe('infinite loops', () => {
1480
+ test('infinite loop of self-ending phases via endIf', () => {
1481
+ const endIf = () => true;
1482
+ const game: Game = {
1483
+ phases: {
1484
+ A: { endIf, next: 'B', start: true },
1485
+ B: { endIf, next: 'A' },
1486
+ },
1487
+ };
1488
+ const client = Client({ game });
1489
+ expect(client.getState().ctx.phase).toBe(null);
1490
+ });
1491
+
1492
+ test('infinite endPhase loop from phase.onBegin', () => {
1493
+ const onBegin = ({ events }) => void events.endPhase();
1494
+ const game: Game = {
1495
+ phases: {
1496
+ A: {
1497
+ onBegin,
1498
+ next: 'B',
1499
+ start: true,
1500
+ moves: {
1501
+ a: ({ events }) => void events.endPhase(),
1502
+ },
1503
+ },
1504
+ B: { onBegin, next: 'C' },
1505
+ C: { onBegin, next: 'A' },
1506
+ },
1507
+ };
1508
+
1509
+ // The onBegin fails to end the phase during initialisation.
1510
+ const client = Client({ game, numPlayers: 3 });
1511
+ let state = client.getState();
1512
+ expect(state.ctx.phase).toBe('A');
1513
+ expect(state.ctx.turn).toBe(1);
1514
+ expect(error).toHaveBeenCalled();
1515
+ {
1516
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1517
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1518
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
1519
+ }
1520
+ jest.clearAllMocks();
1521
+
1522
+ // Moves also fail because of the infinite loop (the game is stuck).
1523
+ client.moves.a();
1524
+ state = client.getState();
1525
+ expect(error).toHaveBeenCalled();
1526
+ {
1527
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1528
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1529
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
1530
+ }
1531
+ expect(state.ctx.phase).toBe('A');
1532
+ expect(state.ctx.turn).toBe(1);
1533
+ });
1534
+
1535
+ test('double phase ending from client event and turn.onEnd', () => {
1536
+ const game: Game = {
1537
+ turn: {
1538
+ onEnd: ({ events }) => void events.endPhase(),
1539
+ },
1540
+ phases: {
1541
+ A: { next: 'B', start: true },
1542
+ B: { next: 'C' },
1543
+ C: { next: 'A' },
1544
+ },
1545
+ };
1546
+ const client = Client({ game });
1547
+
1548
+ let state = client.getState();
1549
+ expect(state.ctx.phase).toBe('A');
1550
+ expect(state.ctx.turn).toBe(1);
1551
+ client.events.endPhase();
1552
+
1553
+ state = client.getState();
1554
+ expect(state.ctx.phase).toBe('B');
1555
+ expect(state.ctx.turn).toBe(2);
1556
+ });
1557
+
1558
+ test('infinite turn endings from turn.onBegin', () => {
1559
+ const game: Game = {
1560
+ moves: {
1561
+ endTurn: ({ events }) => {
1562
+ events.endTurn();
1563
+ },
1564
+ },
1565
+ turn: {
1566
+ onBegin: ({ events }) => void events.endTurn(),
1567
+ },
1568
+ };
1569
+ const client = Client({ game });
1570
+ const initialState = client.getState();
1571
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1572
+
1573
+ // Trigger infinite loop
1574
+ client.moves.endTurn();
1575
+
1576
+ // Expect state to be unchanged and error to be logged.
1577
+ expect(error).toHaveBeenCalled();
1578
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1579
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1580
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
1581
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1582
+ expect(client.getState()).toEqual({ ...initialState, deltalog: [] });
1583
+ });
1584
+
1585
+ test('double turn ending from event and endIf', () => {
1586
+ const game: Game = {
1587
+ moves: {
1588
+ endTurn: ({ events }) => {
1589
+ events.endTurn();
1590
+ },
1591
+ },
1592
+ turn: {
1593
+ endIf: () => true,
1594
+ },
1595
+ };
1596
+ const client = Client({ game });
1597
+
1598
+ // turn.endIf is ignored during game setup.
1599
+ let state = client.getState();
1600
+ expect(state.ctx.currentPlayer).toBe('0');
1601
+ expect(state.ctx.turn).toBe(1);
1602
+
1603
+ // turn.endIf is ignored when the turn was just ended.
1604
+ client.moves.endTurn();
1605
+ state = client.getState();
1606
+ expect(state.ctx.currentPlayer).toBe('1');
1607
+ expect(state.ctx.turn).toBe(2);
1608
+ });
1609
+
1610
+ test('endIf that triggers endIf', () => {
1611
+ const game: Game = {
1612
+ phases: {
1613
+ A: {
1614
+ endIf: ({ events }) => {
1615
+ events.setActivePlayers({ currentPlayer: 'A' });
1616
+ },
1617
+ },
1618
+ },
1619
+ };
1620
+ const client = Client({ game });
1621
+ client.events.setPhase('A');
1622
+ expect(error).toHaveBeenCalled();
1623
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1624
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1625
+ expect(errorMessage).toMatch(
1626
+ /Events must be called from moves or the `.+` hooks./
1627
+ );
1628
+ });
1629
+ });
1630
+
1631
+ describe('events in hooks', () => {
1632
+ const moves = {
1633
+ setAutoEnd: () => ({ shouldEnd: true }),
1634
+ };
1635
+
1636
+ describe('endTurn', () => {
1637
+ const conditionalEndTurn = ({ G, events }) => {
1638
+ if (!G.shouldEnd) return;
1639
+ G.shouldEnd = false;
1640
+ events.endTurn();
1641
+ };
1642
+
1643
+ test('can end turn from turn.onBegin', () => {
1644
+ const client = Client({
1645
+ game: { moves, turn: { onBegin: conditionalEndTurn } },
1646
+ });
1647
+
1648
+ client.moves.setAutoEnd();
1649
+
1650
+ let state = client.getState();
1651
+ expect(state.ctx.turn).toBe(1);
1652
+ expect(state.ctx.currentPlayer).toBe('0');
1653
+
1654
+ client.events.endTurn();
1655
+ state = client.getState();
1656
+ expect(state.ctx.turn).toBe(3);
1657
+ expect(state.ctx.currentPlayer).toBe('0');
1658
+ });
1659
+
1660
+ test('cannot end turn from phase.onBegin', () => {
1661
+ const client = Client({
1662
+ game: {
1663
+ moves,
1664
+ phases: {
1665
+ A: { onBegin: conditionalEndTurn },
1666
+ },
1667
+ },
1668
+ });
1669
+
1670
+ client.moves.setAutoEnd();
1671
+
1672
+ let state = client.getState();
1673
+ expect(state.ctx.turn).toBe(1);
1674
+ expect(state.ctx.currentPlayer).toBe('0');
1675
+ expect(state.ctx.phase).toBeNull();
1676
+
1677
+ client.events.setPhase('A');
1678
+ state = client.getState();
1679
+ expect(state.ctx.turn).toBe(2);
1680
+ expect(state.ctx.currentPlayer).toBe('1');
1681
+ expect(state.ctx.phase).toBe('A');
1682
+ });
1683
+
1684
+ test('can end turn from turn.onBegin at start of phase', () => {
1685
+ const client = Client({
1686
+ game: {
1687
+ moves,
1688
+ phases: {
1689
+ A: {
1690
+ turn: { onBegin: conditionalEndTurn },
1691
+ },
1692
+ },
1693
+ },
1694
+ });
1695
+
1696
+ client.moves.setAutoEnd();
1697
+
1698
+ let state = client.getState();
1699
+ expect(state.ctx.phase).toBeNull();
1700
+ expect(state.ctx.turn).toBe(1);
1701
+ expect(state.ctx.currentPlayer).toBe('0');
1702
+
1703
+ client.events.setPhase('A');
1704
+ state = client.getState();
1705
+ expect(state.ctx.phase).toBe('A');
1706
+ expect(state.ctx.turn).toBe(3);
1707
+ expect(state.ctx.currentPlayer).toBe('0');
1708
+ });
1709
+
1710
+ test('cannot end turn from turn.onEnd', () => {
1711
+ const client = Client({
1712
+ game: {
1713
+ moves,
1714
+ turn: { onEnd: conditionalEndTurn },
1715
+ },
1716
+ });
1717
+
1718
+ let state = client.getState();
1719
+ expect(state.ctx.turn).toBe(1);
1720
+ expect(state.ctx.currentPlayer).toBe('0');
1721
+
1722
+ client.moves.setAutoEnd();
1723
+ client.events.endTurn();
1724
+ state = client.getState();
1725
+ expect(state.ctx.turn).toBe(1);
1726
+ expect(state.ctx.currentPlayer).toBe('0');
1727
+ expect(error).toHaveBeenCalled();
1728
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1729
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1730
+ expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/);
1731
+ });
1732
+
1733
+ test('cannot end turn from phase.onEnd', () => {
1734
+ const client = Client({
1735
+ game: {
1736
+ moves,
1737
+ phases: {
1738
+ A: {
1739
+ start: true,
1740
+ onEnd: conditionalEndTurn,
1741
+ },
1742
+ },
1743
+ },
1744
+ });
1745
+
1746
+ let state = client.getState();
1747
+ expect(state.ctx.turn).toBe(1);
1748
+ expect(state.ctx.currentPlayer).toBe('0');
1749
+ expect(state.ctx.phase).toBe('A');
1750
+
1751
+ client.moves.setAutoEnd();
1752
+ client.events.endPhase();
1753
+ state = client.getState();
1754
+ expect(state.ctx.turn).toBe(1);
1755
+ expect(state.ctx.currentPlayer).toBe('0');
1756
+ expect(state.ctx.phase).toBe('A');
1757
+ expect(error).toHaveBeenCalled();
1758
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1759
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1760
+ expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/);
1761
+ });
1762
+ });
1763
+
1764
+ describe('endPhase', () => {
1765
+ const conditionalEndPhase = ({ G, events }) => {
1766
+ if (!G.shouldEnd) return;
1767
+ G.shouldEnd = false;
1768
+ events.endPhase();
1769
+ };
1770
+
1771
+ test('can end phase from turn.onBegin', () => {
1772
+ const client = Client({
1773
+ game: {
1774
+ moves,
1775
+ phases: {
1776
+ A: {
1777
+ start: true,
1778
+ turn: { onBegin: conditionalEndPhase },
1779
+ },
1780
+ },
1781
+ },
1782
+ });
1783
+
1784
+ let state = client.getState();
1785
+ expect(state.ctx.turn).toBe(1);
1786
+ expect(state.ctx.currentPlayer).toBe('0');
1787
+ expect(state.ctx.phase).toBe('A');
1788
+
1789
+ client.moves.setAutoEnd();
1790
+ client.events.endTurn();
1791
+ state = client.getState();
1792
+ expect(state.ctx.turn).toBe(3);
1793
+ expect(state.ctx.currentPlayer).toBe('0');
1794
+ expect(state.ctx.phase).toBeNull();
1795
+ });
1796
+
1797
+ test('can end phase from phase.onBegin', () => {
1798
+ const client = Client({
1799
+ game: {
1800
+ moves,
1801
+ phases: {
1802
+ A: { onBegin: conditionalEndPhase },
1803
+ },
1804
+ },
1805
+ });
1806
+
1807
+ let state = client.getState();
1808
+ expect(state.ctx.turn).toBe(1);
1809
+ expect(state.ctx.currentPlayer).toBe('0');
1810
+ expect(state.ctx.phase).toBeNull();
1811
+
1812
+ client.moves.setAutoEnd();
1813
+ client.events.setPhase('A');
1814
+ state = client.getState();
1815
+ expect(state.ctx.turn).toBe(3);
1816
+ expect(state.ctx.currentPlayer).toBe('0');
1817
+ expect(state.ctx.phase).toBeNull();
1818
+ });
1819
+
1820
+ test('can end phase from turn.onEnd', () => {
1821
+ const client = Client({
1822
+ game: {
1823
+ moves,
1824
+ phases: {
1825
+ A: {
1826
+ start: true,
1827
+ turn: { onEnd: conditionalEndPhase },
1828
+ },
1829
+ },
1830
+ },
1831
+ });
1832
+
1833
+ let state = client.getState();
1834
+ expect(state.ctx.turn).toBe(1);
1835
+ expect(state.ctx.currentPlayer).toBe('0');
1836
+ expect(state.ctx.phase).toBe('A');
1837
+
1838
+ client.moves.setAutoEnd();
1839
+ client.events.endTurn();
1840
+ state = client.getState();
1841
+ // TODO: This is likely not the desired behaviour. Turn 1 is ended,
1842
+ // then the phase is ended, automatically ending turn 2, ending up in turn 3.
1843
+ // Turn 2 is effectively skipped. Works better with TurnOrder.CONTINUE.
1844
+ expect(state.ctx.turn).toBe(3);
1845
+ expect(state.ctx.currentPlayer).toBe('0');
1846
+ expect(state.ctx.phase).toBeNull();
1847
+ });
1848
+
1849
+ test('cannot end phase from phase.onEnd', () => {
1850
+ const client = Client({
1851
+ game: {
1852
+ moves,
1853
+ phases: {
1854
+ A: {
1855
+ start: true,
1856
+ next: 'B',
1857
+ onEnd: conditionalEndPhase,
1858
+ },
1859
+ B: {},
1860
+ },
1861
+ },
1862
+ });
1863
+
1864
+ let state = client.getState();
1865
+ expect(state.ctx.turn).toBe(1);
1866
+ expect(state.ctx.currentPlayer).toBe('0');
1867
+ expect(state.ctx.phase).toBe('A');
1868
+
1869
+ client.moves.setAutoEnd();
1870
+ client.events.endPhase();
1871
+ state = client.getState();
1872
+ expect(state.ctx.turn).toBe(1);
1873
+ expect(state.ctx.currentPlayer).toBe('0');
1874
+ expect(state.ctx.phase).toBe('A');
1875
+ expect(error).toHaveBeenCalled();
1876
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
1877
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
1878
+ expect(errorMessage).toMatch(
1879
+ /`setPhase` & `endPhase` are disallowed in a phase’s `onEnd` hook/
1880
+ );
1881
+ });
1882
+ });
1883
+ });
1884
+
1885
+ describe('activePlayers', () => {
1886
+ test('sets activePlayers at each turn', () => {
1887
+ const game: Game = {
1888
+ turn: {
1889
+ stages: { A: {}, B: {} },
1890
+ activePlayers: {
1891
+ currentPlayer: 'A',
1892
+ others: 'B',
1893
+ },
1894
+ },
1895
+ };
1896
+
1897
+ const client = Client({ game, numPlayers: 3 });
1898
+
1899
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1900
+ expect(client.getState().ctx.activePlayers).toEqual({
1901
+ '0': 'A',
1902
+ '1': 'B',
1903
+ '2': 'B',
1904
+ });
1905
+
1906
+ client.events.endTurn();
1907
+
1908
+ expect(client.getState().ctx.currentPlayer).toBe('1');
1909
+ expect(client.getState().ctx.activePlayers).toEqual({
1910
+ '0': 'B',
1911
+ '1': 'A',
1912
+ '2': 'B',
1913
+ });
1914
+ });
1915
+ });
1916
+
1917
+ test('events in hooks triggered by moves should be processed', () => {
1918
+ const game: Game = {
1919
+ turn: {
1920
+ onBegin: ({ events }) => {
1921
+ events.setActivePlayers({ currentPlayer: 'A' });
1922
+ },
1923
+ },
1924
+ moves: {
1925
+ endTurn: ({ events }) => {
1926
+ events.endTurn();
1927
+ },
1928
+ },
1929
+ };
1930
+
1931
+ const client = Client({ game, numPlayers: 3 });
1932
+
1933
+ expect(client.getState().ctx.currentPlayer).toBe('0');
1934
+ expect(client.getState().ctx.activePlayers).toEqual({
1935
+ '0': 'A',
1936
+ });
1937
+
1938
+ client.moves.endTurn();
1939
+
1940
+ expect(client.getState().ctx.currentPlayer).toBe('1');
1941
+ expect(client.getState().ctx.activePlayers).toEqual({
1942
+ '1': 'A',
1943
+ });
1944
+ });
1945
+
1946
+ test('stage events should not be processed out of turn', () => {
1947
+ const game: Game = {
1948
+ phases: {
1949
+ A: {
1950
+ start: true,
1951
+ turn: {
1952
+ activePlayers: {
1953
+ all: 'A1',
1954
+ },
1955
+ stages: {
1956
+ A1: {
1957
+ moves: {
1958
+ endStage: ({ G, events }) => {
1959
+ G.endStage = true;
1960
+ events.endStage();
1961
+ },
1962
+ },
1963
+ },
1964
+ },
1965
+ },
1966
+ endIf: ({ G }) => G.endStage,
1967
+ next: 'B',
1968
+ },
1969
+ B: {
1970
+ turn: {
1971
+ activePlayers: {
1972
+ all: 'B1',
1973
+ },
1974
+ stages: {
1975
+ B1: {},
1976
+ },
1977
+ },
1978
+ },
1979
+ },
1980
+ };
1981
+
1982
+ const client = Client({ game, numPlayers: 3 });
1983
+
1984
+ expect(client.getState().ctx.activePlayers).toEqual({
1985
+ '0': 'A1',
1986
+ '1': 'A1',
1987
+ '2': 'A1',
1988
+ });
1989
+
1990
+ client.moves.endStage();
1991
+
1992
+ expect(client.getState().ctx.activePlayers).toEqual({
1993
+ '0': 'B1',
1994
+ '1': 'B1',
1995
+ '2': 'B1',
1996
+ });
1997
+ });
1998
+
1999
+ describe('backwards compatibility for moveLimit', () => {
2000
+ test('turn config maps moveLimit to minMoves/maxMoves', () => {
2001
+ const flow = Flow({
2002
+ moves: {
2003
+ pass: () => {},
2004
+ },
2005
+ turn: {
2006
+ moveLimit: 2,
2007
+ },
2008
+ });
2009
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
2010
+
2011
+ expect(state.ctx.turn).toBe(1);
2012
+ expect(state.ctx.currentPlayer).toBe('0');
2013
+
2014
+ state = flow.processMove(state, makeMove('pass', null, '0').payload);
2015
+ state = flow.processMove(state, makeMove('pass', null, '0').payload);
2016
+
2017
+ expect(state.ctx.turn).toBe(2);
2018
+ expect(state.ctx.currentPlayer).toBe('1');
2019
+
2020
+ state = flow.processMove(state, makeMove('pass', null, '1').payload);
2021
+
2022
+ state = flow.processEvent(state, gameEvent('endTurn', null, '1'));
2023
+
2024
+ // player should not be able to endTurn because they haven't made minMoves yet
2025
+
2026
+ expect(state.ctx.turn).toBe(2);
2027
+ expect(state.ctx.currentPlayer).toBe('1');
2028
+ });
2029
+
2030
+ test('setActivePlayers maps moveLimit to maxMoves only', () => {
2031
+ const flow = Flow({});
2032
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
2033
+
2034
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
2035
+ expect(state.ctx._activePlayersMaxMoves).toBeNull();
2036
+
2037
+ state = flow.processEvent(
2038
+ state,
2039
+ gameEvent('setActivePlayers', { all: 'A', moveLimit: 1 })
2040
+ );
2041
+
2042
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
2043
+ expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 1 });
2044
+ });
2045
+
2046
+ test('setStage maps moveLimit to maxMoves only', () => {
2047
+ const flow = Flow({});
2048
+ let state = flow.init({ ctx: flow.ctx(2) } as State);
2049
+
2050
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
2051
+ expect(state.ctx._activePlayersMaxMoves).toBeNull();
2052
+ state = flow.processEvent(
2053
+ state,
2054
+ gameEvent('setStage', { stage: 'A', moveLimit: 2 })
2055
+ );
2056
+ expect(state.ctx._activePlayersMinMoves).toBeNull();
2057
+ expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2 });
2058
+ });
2059
+ });
2060
+
2061
+ // These tests serve to document the order in which the various game hooks
2062
+ // are executed and also to catch any potential breaking changes.
2063
+ describe('hook execution order', () => {
2064
+ const calls: string[] = [];
2065
+ afterEach(() => {
2066
+ calls.length = 0;
2067
+ });
2068
+
2069
+ const client = Client({
2070
+ playerID: '0',
2071
+ game: {
2072
+ moves: {
2073
+ move: () => void calls.push('move'),
2074
+ setStage: ({ events }) => {
2075
+ events.setStage('A');
2076
+ calls.push('moves.setStage');
2077
+ },
2078
+ endStage: ({ events }) => {
2079
+ events.endStage();
2080
+ calls.push('moves.endStage');
2081
+ },
2082
+ setActivePlayers: ({ events }) => {
2083
+ events.setActivePlayers({ all: 'A', minMoves: 1, maxMoves: 1 });
2084
+ calls.push('moves.setActivePlayers');
2085
+ },
2086
+ },
2087
+ endIf: () => void calls.push('game.endIf'),
2088
+ onEnd: () => void calls.push('game.onEnd'),
2089
+ turn: {
2090
+ activePlayers: { all: 'A' },
2091
+ endIf: () => void calls.push('turn.endIf'),
2092
+ onBegin: () => void calls.push('turn.onBegin'),
2093
+ onMove: () => void calls.push('turn.onMove'),
2094
+ onEnd: () => void calls.push('turn.onEnd'),
2095
+ order: {
2096
+ first: () => calls.push('turn.order.first') && 0,
2097
+ next: () => calls.push('turn.order.next') && 0,
2098
+ playOrder: () => calls.push('turn.order.playOrder') && ['0', '1'],
2099
+ },
2100
+ },
2101
+ phases: {
2102
+ A: {
2103
+ start: true,
2104
+ next: 'B',
2105
+ endIf: () => void calls.push('phaseA.endIf'),
2106
+ onBegin: () => void calls.push('phaseA.onBegin'),
2107
+ onEnd: () => void calls.push('phaseA.onEnd'),
2108
+ },
2109
+ B: {
2110
+ next: 'A',
2111
+ endIf: () => void calls.push('phaseB.endIf'),
2112
+ onBegin: () => void calls.push('phaseB.onBegin'),
2113
+ onEnd: () => void calls.push('phaseB.onEnd'),
2114
+ },
2115
+ },
2116
+ },
2117
+ });
2118
+
2119
+ test('hooks called during setup', () => {
2120
+ expect(calls).toEqual([
2121
+ 'game.endIf',
2122
+ 'phaseA.endIf',
2123
+ 'phaseA.onBegin',
2124
+ 'game.endIf',
2125
+ 'phaseA.endIf',
2126
+ 'turn.order.playOrder',
2127
+ 'turn.order.first',
2128
+ 'turn.onBegin',
2129
+ 'game.endIf',
2130
+ 'phaseA.endIf',
2131
+ ]);
2132
+ });
2133
+
2134
+ test('hooks called on move', () => {
2135
+ client.moves.move();
2136
+ expect(calls).toEqual([
2137
+ 'move',
2138
+ 'turn.onMove',
2139
+ 'game.endIf',
2140
+ 'phaseA.endIf',
2141
+ 'turn.endIf',
2142
+ ]);
2143
+ });
2144
+
2145
+ test('hooks called on setStage', () => {
2146
+ client.events.setStage('B');
2147
+ expect(calls).toEqual([
2148
+ 'game.endIf',
2149
+ 'phaseA.endIf',
2150
+ 'game.endIf',
2151
+ 'phaseA.endIf',
2152
+ 'turn.endIf',
2153
+ ]);
2154
+ });
2155
+
2156
+ test('hooks called on endStage', () => {
2157
+ client.updatePlayerID('1');
2158
+ client.events.endStage();
2159
+ client.updatePlayerID('0');
2160
+ expect(calls).toEqual([
2161
+ 'game.endIf',
2162
+ 'phaseA.endIf',
2163
+ 'game.endIf',
2164
+ 'phaseA.endIf',
2165
+ 'turn.endIf',
2166
+ ]);
2167
+ });
2168
+
2169
+ test('hooks called on setActivePlayers', () => {
2170
+ client.events.setActivePlayers({});
2171
+ expect(calls).toEqual(['game.endIf', 'phaseA.endIf', 'turn.endIf']);
2172
+ });
2173
+
2174
+ test('hooks called on setStage triggered by move', () => {
2175
+ client.moves.setStage();
2176
+ expect(calls).toEqual([
2177
+ 'moves.setStage',
2178
+ 'turn.onMove',
2179
+ 'game.endIf',
2180
+ 'phaseA.endIf',
2181
+ 'turn.endIf',
2182
+ 'game.endIf',
2183
+ 'phaseA.endIf',
2184
+ 'game.endIf',
2185
+ 'phaseA.endIf',
2186
+ 'turn.endIf',
2187
+ ]);
2188
+ });
2189
+
2190
+ test('hooks called on endStage triggered by move', () => {
2191
+ client.moves.endStage();
2192
+ expect(calls).toEqual([
2193
+ 'moves.endStage',
2194
+ 'turn.onMove',
2195
+ 'game.endIf',
2196
+ 'phaseA.endIf',
2197
+ 'turn.endIf',
2198
+ 'game.endIf',
2199
+ 'phaseA.endIf',
2200
+ 'game.endIf',
2201
+ 'phaseA.endIf',
2202
+ 'turn.endIf',
2203
+ ]);
2204
+ });
2205
+
2206
+ test('hooks called on setActivePlayers triggered by move', () => {
2207
+ client.moves.setActivePlayers();
2208
+ expect(calls).toEqual([
2209
+ 'moves.setActivePlayers',
2210
+ 'turn.onMove',
2211
+ 'game.endIf',
2212
+ 'phaseA.endIf',
2213
+ 'turn.endIf',
2214
+ 'game.endIf',
2215
+ 'phaseA.endIf',
2216
+ 'turn.endIf',
2217
+ ]);
2218
+ });
2219
+
2220
+ test('hooks called on stage end triggered by maxMoves', () => {
2221
+ client.updatePlayerID('1');
2222
+ client.moves.move();
2223
+ client.updatePlayerID('0');
2224
+ expect(calls).toEqual([
2225
+ 'move',
2226
+ 'turn.onMove',
2227
+ 'game.endIf',
2228
+ 'phaseA.endIf',
2229
+ 'turn.endIf',
2230
+ ]);
2231
+ });
2232
+
2233
+ test('hooks called on endTurn', () => {
2234
+ client.events.endTurn();
2235
+ expect(calls).toEqual([
2236
+ 'turn.onEnd',
2237
+ 'game.endIf',
2238
+ 'phaseA.endIf',
2239
+ 'turn.order.next',
2240
+ 'game.endIf',
2241
+ 'phaseA.endIf',
2242
+ 'turn.onBegin',
2243
+ 'game.endIf',
2244
+ 'phaseA.endIf',
2245
+ ]);
2246
+ });
2247
+
2248
+ test('hooks called on endPhase', () => {
2249
+ client.events.endPhase();
2250
+ expect(calls).toEqual([
2251
+ 'turn.onEnd',
2252
+ 'phaseA.onEnd',
2253
+ 'game.endIf',
2254
+ 'game.endIf',
2255
+ 'phaseB.endIf',
2256
+ 'phaseB.onBegin',
2257
+ 'game.endIf',
2258
+ 'phaseB.endIf',
2259
+ 'turn.order.playOrder',
2260
+ 'turn.order.first',
2261
+ 'turn.onBegin',
2262
+ 'game.endIf',
2263
+ 'phaseB.endIf',
2264
+ ]);
2265
+ });
2266
+
2267
+ test('hooks called on endGame', () => {
2268
+ client.events.endGame(5);
2269
+ expect(calls).toEqual(['phaseB.onEnd', 'game.onEnd']);
2270
+ });
2271
+ });
2272
+
2273
+ describe('game function signatures', () => {
2274
+ const moveA = jest.fn();
2275
+ let game: Game;
2276
+
2277
+ let client: ReturnType<typeof Client>;
2278
+
2279
+ // Helpers to check the objects game functions are called with.
2280
+ const expectCtx = expect.objectContaining({ numPlayers: 2 });
2281
+ const expectEvents = expect.objectContaining({
2282
+ endTurn: expect.any(Function),
2283
+ });
2284
+ const expectRandom = expect.objectContaining({
2285
+ D6: expect.any(Function),
2286
+ });
2287
+ const FnContext = ({
2288
+ playerID,
2289
+ G = 'G',
2290
+ }: { playerID?: PlayerID; G?: any } = {}) => {
2291
+ const context: any = {
2292
+ G,
2293
+ ctx: expectCtx,
2294
+ events: expectEvents,
2295
+ random: expectRandom,
2296
+ testPluginAPI: { foo: 'bar' },
2297
+ };
2298
+ if (playerID !== undefined) context.playerID = playerID;
2299
+ return expect.objectContaining(context);
2300
+ };
2301
+
2302
+ beforeEach(() => {
2303
+ game = {
2304
+ setup: jest.fn(() => 'G'),
2305
+
2306
+ plugins: [
2307
+ {
2308
+ name: 'testPluginAPI',
2309
+ api: () => ({ foo: 'bar' }),
2310
+ },
2311
+ ],
2312
+
2313
+ onEnd: jest.fn(),
2314
+ endIf: jest.fn(({ G }) => G == 'gameover'),
2315
+
2316
+ moves: {
2317
+ A: (...args) => moveA(...args),
2318
+ endGame: () => 'gameover',
2319
+ },
2320
+
2321
+ turn: {
2322
+ order: {
2323
+ playOrder: jest.fn(({ ctx }) =>
2324
+ [...Array.from({ length: ctx.numPlayers })].map((_, i) => i + '')
2325
+ ),
2326
+ first: jest.fn(TurnOrder.DEFAULT.first),
2327
+ next: jest.fn(TurnOrder.DEFAULT.next),
2328
+ },
2329
+ onBegin: jest.fn(),
2330
+ onMove: jest.fn(),
2331
+ onEnd: jest.fn(),
2332
+ endIf: jest.fn(),
2333
+ },
2334
+
2335
+ phases: {
2336
+ A: {
2337
+ onBegin: jest.fn(),
2338
+ onEnd: jest.fn(),
2339
+ endIf: jest.fn(),
2340
+ },
2341
+ },
2342
+
2343
+ events: {
2344
+ endPhase: true,
2345
+ },
2346
+ };
2347
+
2348
+ client = Client({ game, playerID: '0' });
2349
+ });
2350
+
2351
+ afterEach(() => {
2352
+ jest.resetAllMocks();
2353
+ });
2354
+
2355
+ test('game.setup', () => {
2356
+ expect(game.setup).lastCalledWith(
2357
+ // setup context object
2358
+ expect.objectContaining({
2359
+ ctx: expectCtx,
2360
+ events: expectEvents,
2361
+ random: expectRandom,
2362
+ }),
2363
+ // setupData
2364
+ undefined
2365
+ );
2366
+ });
2367
+
2368
+ test('game.onEnd', () => {
2369
+ client.events.endGame();
2370
+ expect(game.onEnd).lastCalledWith(FnContext());
2371
+ });
2372
+
2373
+ test('game.endIf', () => {
2374
+ client.moves.endGame();
2375
+ expect(game.endIf).lastCalledWith(FnContext({ G: 'gameover' }));
2376
+ });
2377
+
2378
+ test('game.turn.order.playOrder', () => {
2379
+ expect(game.turn.order.playOrder).lastCalledWith(FnContext());
2380
+ });
2381
+
2382
+ test('game.turn.order.first', () => {
2383
+ expect(game.turn.order.first).lastCalledWith(FnContext());
2384
+ });
2385
+
2386
+ test('game.turn.order.next', () => {
2387
+ client.events.endTurn();
2388
+ expect(game.turn.order.next).lastCalledWith(FnContext());
2389
+ });
2390
+
2391
+ test('game.turn.onBegin', () => {
2392
+ expect(game.turn.onBegin).lastCalledWith(FnContext());
2393
+ });
2394
+
2395
+ test('game.turn.onMove', () => {
2396
+ client.moves.A();
2397
+ expect(game.turn.onMove).lastCalledWith(FnContext());
2398
+ });
2399
+
2400
+ test('game.turn.onEnd', () => {
2401
+ client.events.endTurn();
2402
+ expect(game.turn.onEnd).lastCalledWith(FnContext());
2403
+ });
2404
+
2405
+ test('game.turn.endIf', () => {
2406
+ client.moves.A();
2407
+ expect(game.turn.endIf).lastCalledWith(FnContext());
2408
+ });
2409
+
2410
+ test('move', () => {
2411
+ client.moves.A('arg');
2412
+ expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 'arg');
2413
+ client.moves.A(2, 'args');
2414
+ expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 2, 'args');
2415
+ });
2416
+
2417
+ test('game.phases.phase.onBegin', () => {
2418
+ client.events.setPhase('A');
2419
+ expect(game.phases.A.onBegin).lastCalledWith(FnContext());
2420
+ });
2421
+
2422
+ test('game.phases.phase.onEnd', () => {
2423
+ client.events.setPhase('A');
2424
+ client.updatePlayerID('1');
2425
+ client.events.endPhase();
2426
+ expect(game.phases.A.onEnd).lastCalledWith(FnContext());
2427
+ });
2428
+
2429
+ test('game.phases.phase.endIf', () => {
2430
+ client.events.setPhase('A');
2431
+ expect(game.phases.A.endIf).lastCalledWith(FnContext());
2432
+ });
2433
+ });