create-eigen-game 0.13.0 → 0.13.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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Reads the two deployment values that exist only once a Firebase project
3
+ * does, so the closing summary can name what is still missing.
4
+ *
5
+ * Reading only. `configure_firebase` writes them, into `wrangler.jsonc` and
6
+ * `app-config.json`, because it is also what a project runs later when
7
+ * reconfiguring, and one implementation is what keeps the scaffold-time path
8
+ * and the re-run path identical. This module exists so the summary can tell
9
+ * the difference between "filled in" and "still owed", not to do it again.
10
+ *
11
+ * Its own module for the same reason `summary.ts` is: `index.ts` runs a
12
+ * scaffold, and this is worth asserting without one. The package `exports` map
13
+ * does not expose it.
14
+ */
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+
18
+ /** What the Firebase step managed to settle, and what it left for the console. */
19
+ export interface FirebaseLink {
20
+ /** The project now in `wrangler.jsonc`, or null when FlutterFire recorded none. */
21
+ projectId: string | null;
22
+ /** The OAuth web client now in `app-config.json`, or null when Google sign-in is not enabled yet. */
23
+ googleWebClientId: string | null;
24
+ }
25
+
26
+ function readJson(path: string): unknown {
27
+ if (!existsSync(path)) return undefined;
28
+ try {
29
+ return JSON.parse(readFileSync(path, "utf8"));
30
+ } catch {
31
+ // A file this step only reads is never worth failing a finished scaffold
32
+ // for. The value counts as unset, and the summary says so.
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The project FlutterFire recorded for the Dart output this app uses.
39
+ *
40
+ * `firebase.json` is FlutterFire's own record rather than something this
41
+ * scaffold writes, so it is read defensively: a shape that does not match is
42
+ * treated as "not configured", which is the same answer as a run that never
43
+ * happened.
44
+ */
45
+ export function configuredProject(appRoot: string): string | null {
46
+ const record = readJson(resolve(appRoot, "firebase.json")) as { flutter?: { platforms?: { dart?: Record<string, { projectId?: unknown }> } } } | undefined;
47
+ const projectId = record?.flutter?.platforms?.dart?.["lib/firebase_options.dart"]?.projectId;
48
+ return typeof projectId === "string" && projectId !== "" ? projectId : null;
49
+ }
50
+
51
+ /**
52
+ * The OAuth web client id that `configure_firebase` copied into
53
+ * `app-config.json`, when there was one to copy.
54
+ *
55
+ * Empty is an ordinary outcome rather than a failure. Firebase creates that
56
+ * client when the Google sign-in provider is enabled, which is a console
57
+ * action no CLI can perform, so a project that has never had it enabled has
58
+ * nothing to offer here.
59
+ */
60
+ export function configuredWebClientId(appRoot: string): string | null {
61
+ const config = readJson(resolve(appRoot, "app-config.json")) as Record<string, unknown> | undefined;
62
+ const value = config?.GOOGLE_WEB_CLIENT_ID;
63
+ return typeof value === "string" && value !== "" ? value : null;
64
+ }
65
+
66
+ /** What the Firebase step settled, for the summary to report. */
67
+ export function readFirebaseLink(root: string): FirebaseLink {
68
+ const appRoot = resolve(root, "app");
69
+ return { projectId: configuredProject(appRoot), googleWebClientId: configuredWebClientId(appRoot) };
70
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Every question this CLI asks, in one module so the wording lives together
3
+ * and can be asserted. `cli.ts` is the bin entry and runs `main` on import,
4
+ * which makes anything defined there untestable.
5
+ *
6
+ * The organization prompt is the reason this drops to `@clack/core` rather
7
+ * than using `@clack/prompts`' `text`. The suffix: `flutter create --org X
8
+ * --project-name Y` derives `X.Y`, so the organization is a *prefix*, and the
9
+ * reliable way to say so is to render the rest of the identifier, dimmed,
10
+ * while it is being typed. `text` has no hook for that; a custom `TextPrompt`
11
+ * render returns a string, so it does.
12
+ *
13
+ * Everything else comes from `@clack/prompts` rather than being drawn here.
14
+ * Its symbols are exported for exactly this, and they are not constants: each
15
+ * falls back to ASCII on a terminal that cannot show the box-drawing
16
+ * characters, which a hand-typed `│` cannot do.
17
+ */
18
+ import type { Readable, Writable } from "node:stream";
19
+ import { TextPrompt } from "@clack/core";
20
+ import { confirm, isCancel, S_BAR, S_BAR_END, select, symbol } from "@clack/prompts";
21
+ import color from "picocolors";
22
+ import type { PackageManager } from "./index.js";
23
+
24
+ /**
25
+ * Where a question reads its answer from. Parameters for the same reason every
26
+ * clack prompt takes them: a prompt is otherwise the one part of a CLI that
27
+ * cannot be driven by a test.
28
+ */
29
+ export interface Io {
30
+ input?: Readable;
31
+ output?: Writable;
32
+ }
33
+
34
+ /**
35
+ * Two or more dot-separated Java identifiers. `flutter create --org` accepts
36
+ * anything and defers the complaint to Gradle, which reports it as a manifest
37
+ * error in a generated file, so `com.example-games` costs a full scaffold and
38
+ * a first build before anyone learns that a hyphen is not legal in a package
39
+ * segment.
40
+ */
41
+ export const ORG = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/;
42
+
43
+ /** The default, which an empty answer accepts. */
44
+ export const DEFAULT_ORG = "com.example";
45
+
46
+ const bar = color.gray(S_BAR);
47
+
48
+ /** A `com.acme.chess` answer to a game called `chess`, which yields `com.acme.chess.chess`. */
49
+ export function repeatsGameName(org: string, game: string): boolean {
50
+ return org.split(".").at(-1) === game;
51
+ }
52
+
53
+ /** The organization implied by an answer that already ends in the game name. */
54
+ export function withoutGameName(org: string): string | undefined {
55
+ const shorter = org.split(".").slice(0, -1).join(".");
56
+ return ORG.test(shorter) ? shorter : undefined;
57
+ }
58
+
59
+ /**
60
+ * Asks, with the game name rendered dimmed after the cursor so the shape of
61
+ * the answer is visible while it is being given.
62
+ *
63
+ * Returns the organization, `undefined` for the default, or `null` when
64
+ * cancelled.
65
+ *
66
+ * `input` and `output` default to this process's, and are parameters for the
67
+ * same reason every clack prompt takes them: a prompt is otherwise the one
68
+ * part of a CLI that cannot be driven by a test.
69
+ */
70
+ export async function askForOrg(game: string, registering: boolean, io: Io = {}): Promise<string | undefined | null> {
71
+ const input = io.input ?? process.stdin;
72
+ const output = io.output ?? process.stdout;
73
+ const suffix = color.dim(`.${game}`);
74
+ const prompt = new TextPrompt({
75
+ input,
76
+ output,
77
+ // An empty answer is the default, not a mistake.
78
+ validate: (value) => (value === undefined || value === "" || ORG.test(value) ? undefined : "Two or more dot-separated segments, as in com.example or dev.yourname.games."),
79
+ render() {
80
+ const typed = String(this.value ?? "");
81
+ const shown = typed === "" ? `${color.dim(DEFAULT_ORG)}${suffix}` : `${this.userInputWithCursor}${suffix}`;
82
+ const problem = this.state === "error" ? `\n${bar} ${color.yellow(this.error ?? "")}` : "";
83
+ return [`${bar}`, `${symbol(this.state)} Organization in reverse domain notation`, `${bar} ${shown}${problem}`, color.gray(S_BAR_END)].join("\n");
84
+ },
85
+ });
86
+
87
+ // The two things that make this answer permanent, said before it is given
88
+ // rather than after: Google Play freezes the identifier at the first upload,
89
+ // and FlutterFire matches an existing Android app on exactly this string, so
90
+ // it also decides whether the run adopts an app the chosen Firebase project
91
+ // already has or registers a new one.
92
+ output.write(`${bar} ${color.dim("Prefixes the Android applicationId, which Google Play makes permanent at first upload.")}\n`);
93
+ if (registering) output.write(`${bar} ${color.dim("Also the Android app registered in the Firebase project you pick next.")}\n`);
94
+
95
+ const answer = await prompt.prompt();
96
+ if (isCancel(answer)) return null;
97
+
98
+ const org = String(answer).trim();
99
+ if (org === "" || org === DEFAULT_ORG) return undefined;
100
+
101
+ // The mistake the dimmed suffix is there to prevent, caught for the person
102
+ // who pasted an identifier rather than typing one and never watched it grow.
103
+ const shorter = repeatsGameName(org, game) ? withoutGameName(org) : undefined;
104
+ if (shorter !== undefined) {
105
+ const shorten = await confirm({
106
+ message: `That gives ${color.yellow(`${org}.${game}`)}, the game name twice. Use ${color.green(shorter)} instead?`,
107
+ initialValue: true,
108
+ input,
109
+ output,
110
+ });
111
+ if (isCancel(shorten)) return null;
112
+ if (shorten) return shorter;
113
+ }
114
+
115
+ return org;
116
+ }
117
+
118
+ /**
119
+ * A yes/no question, with its reason on the line above.
120
+ *
121
+ * The three below share this so cancellation is handled in one place: ^C
122
+ * during any question has to mean the run stops having written nothing, and
123
+ * that is only true if every call site checks. `null` is that answer.
124
+ *
125
+ * `hint` is written rather than passed to `confirm`, which has nowhere to put
126
+ * it. It is the same dimmed line the organization prompt uses, and carries the
127
+ * thing that makes the default the default: a question whose answer is
128
+ * pre-chosen owes the reader why.
129
+ */
130
+ async function ask(message: string, initialValue: boolean, hint: string, io: Io): Promise<boolean | null> {
131
+ const input = io.input ?? process.stdin;
132
+ const output = io.output ?? process.stdout;
133
+ output.write(`${bar}\n${bar} ${color.dim(hint)}\n`);
134
+ const answer = await confirm({ message, initialValue, input, output });
135
+ return isCancel(answer) ? null : answer;
136
+ }
137
+
138
+ /**
139
+ * Asked only when {@link firebaseReadiness} found something missing, and
140
+ * defaulted to "no" because the honest answer usually is: the tools are two
141
+ * commands away, and the alternative is an app that throws at launch until
142
+ * they are installed anyway.
143
+ */
144
+ export function askToScaffoldWithoutFirebase(io: Io = {}): Promise<boolean | null> {
145
+ return ask("Scaffold anyway, and connect Firebase yourself later?", false, "Everything except the app's sign-in still works, and `firebase:configure` finishes the job.", io);
146
+ }
147
+
148
+ /** Defaulted to yes: the scaffold is a few hundred generated files, and a commit is what makes the first `git diff` the first game change rather than all of them. */
149
+ export function askForGit(io: Io = {}): Promise<boolean | null> {
150
+ return ask("Initialise a git repository and commit the scaffold?", true, "Commits once, so your first diff is your first game change.", io);
151
+ }
152
+
153
+ /**
154
+ * Defaulted to no, and the hint says why rather than leaving it as an
155
+ * arbitrary-looking choice: `release.yml` needs an upload keystore and a Play
156
+ * service account, so a project that takes the workflows before it has either
157
+ * gets a red `main` from its first push.
158
+ */
159
+ export function askForWorkflows(io: Io = {}): Promise<boolean | null> {
160
+ return ask("Add the GitHub Actions workflows?", false, "Release needs a signing keystore and a Play service account, so this fails until you have both. `add workflows` writes them later.", io);
161
+ }
162
+
163
+ /**
164
+ * The package manager, asked only when nothing else could say.
165
+ *
166
+ * `npm create` and `pnpm create` both set `npm_config_user_agent`, so this is
167
+ * reached by the paths that do not: a global install, or running the binary
168
+ * directly. Rare, but it decides what every generated script says, and the old
169
+ * silent fallback to pnpm was a guess printed into a project's package.json.
170
+ */
171
+ export async function askForPackageManager(io: Io = {}): Promise<PackageManager | null> {
172
+ const answer = await select({
173
+ message: "Which package manager should the generated scripts use?",
174
+ options: [
175
+ { value: "pnpm", label: "pnpm" },
176
+ { value: "npm", label: "npm" },
177
+ ],
178
+ input: io.input ?? process.stdin,
179
+ output: io.output ?? process.stdout,
180
+ });
181
+ return isCancel(answer) ? null : (answer as PackageManager);
182
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The closing summary, and the only output of a scaffold anyone reads.
3
+ *
4
+ * A run drives several tools with plenty to say, so where the project went,
5
+ * what to edit, and what to run are worth restating at the bottom. Returned in
6
+ * pieces rather than as one block, because the CLI renders each in a different
7
+ * shape: the outcomes as status lines, the commands as a boxed note, the
8
+ * destination as the closing line.
9
+ *
10
+ * Its own module so it can be asserted: `cli.ts` is the bin entry and runs
11
+ * `main` on import, which makes anything defined there untestable. The package
12
+ * `exports` map does not expose this, so it is internal despite being
13
+ * importable by the tests.
14
+ */
15
+ import type { PackageManager, ScaffoldResult } from "./index.js";
16
+
17
+ export interface Summary {
18
+ /** What became of the repository and of Firebase, when there is something to say. */
19
+ status: string[];
20
+ /** The commands worth having, as one block. */
21
+ next: string;
22
+ /** One-line asides, after the block. */
23
+ footnotes: string[];
24
+ /** The closing line. */
25
+ headline: string;
26
+ }
27
+
28
+ /** Two-column rows, aligned to the widest left cell. */
29
+ function columns(rows: [string, string][]): string[] {
30
+ const width = Math.max(...rows.map(([left]) => left.length));
31
+ return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`);
32
+ }
33
+
34
+ export function summarise(result: ScaffoldResult, manager: PackageManager, workflows: boolean): Summary {
35
+ const run = manager === "npm" ? "npm run" : "pnpm";
36
+ const status: string[] = [];
37
+
38
+ // Only the outcomes that leave the reader something to know. `failed` has
39
+ // already warned, in more detail than a summary line could carry.
40
+ if (result.git === "committed") status.push(`Committed as "Scaffold ${result.name}"`);
41
+ if (result.git === "existing") status.push("Created inside a repository you already had, so nothing was committed");
42
+ // `failed` has warned already, and the step reappears below as the next
43
+ // thing to run, which is the whole of the advice.
44
+ if (result.firebase === "configured") status.push(result.git === "committed" ? "Firebase is configured, and everything it generated is in that commit" : "Firebase is configured");
45
+ if (result.link?.projectId != null) status.push(`FIREBASE_PROJECT_ID is set to ${result.link.projectId}, and app-config.json points at the local Worker`);
46
+
47
+ // What is left, and only what is left. Everything here comes from the
48
+ // Firebase console rather than from any CLI, which is why a scaffold that
49
+ // filled in everything it could still ends with a list: the web target
50
+ // refuses to start without a VAPID key, and there is no OAuth client to copy
51
+ // until somebody enables the provider that creates it.
52
+ const remaining: [string, string][] = [];
53
+ if (result.link !== undefined) {
54
+ if (result.link.googleWebClientId === null) {
55
+ remaining.push(["Enable Google sign-in", "Console > Authentication > Sign-in method"]);
56
+ remaining.push(["GOOGLE_WEB_CLIENT_ID", "that same page, under Web SDK configuration"]);
57
+ }
58
+ remaining.push(["FIREBASE_VAPID_KEY", "Console > Project settings > Cloud Messaging > Web configuration"]);
59
+ }
60
+
61
+ const next = [
62
+ "Write the game",
63
+ ...columns([
64
+ ["server/src/module/v1.ts", "the authoritative rules"],
65
+ ["app/lib/game/v1/rules.dart", "the presentation twin"],
66
+ ]),
67
+ "",
68
+ `Then, from ${result.name}/`,
69
+ ...columns([
70
+ [`cd server && ${run} test:watch`, "rules and twin fixtures"],
71
+ [`cd server && ${run} dev`, "the Worker, on http://localhost:8787"],
72
+ [`${run} contract`, "regenerate after a schema change"],
73
+ // Named here because it is the one step the game itself cannot do
74
+ // without: rules, fixtures and the Worker all run unconfigured, and the
75
+ // app throws `Firebase is not configured` at launch until this has run
76
+ // once.
77
+ ...(result.firebase === "configured" ? [] : [[`${run} firebase:configure`, "connect Firebase before the app runs"] as [string, string]]),
78
+ ]),
79
+ ...(remaining.length === 0 ? [] : ["", "Before the app runs, from the Firebase console", ...columns(remaining), "", " The values go in app/app-config.json. Both are public, not secrets."]),
80
+ ].join("\n");
81
+
82
+ const footnotes = ["Docs: https://eigeninteractive.com"];
83
+ if (!workflows) footnotes.unshift("No CI workflows yet: `create-eigen-game add workflows` writes them when shipping is the next step.");
84
+
85
+ return { status, next, footnotes, headline: `${result.name} is ready, at ${result.root}` };
86
+ }
@@ -5,7 +5,7 @@ the authoritative TypeScript schemas. Regenerate them after changing a schema
5
5
  or shared fixture:
6
6
 
7
7
  ```sh
8
- dart run eigen_flutter:generate_payloads \
8
+ dart run eigen_codegen:generate_payloads \
9
9
  --contract ../server/game-contract.json \
10
10
  --output lib/game/generated/payloads.dart \
11
11
  --fixtures-output test/fixtures
@@ -1,6 +1,6 @@
1
+ import 'package:eigen_firebase/eigen_firebase.dart';
1
2
  import 'package:eigen_flutter/eigen_flutter.dart';
2
3
  import 'package:firebase_core/firebase_core.dart';
3
- import 'package:firebase_messaging/firebase_messaging.dart';
4
4
 
5
5
  import 'firebase_options.dart';
6
6
  import 'game/module.dart';
@@ -16,7 +16,7 @@ const _appHost = String.fromEnvironment('APP_HOST');
16
16
  const _authDomain = String.fromEnvironment('AUTH_DOMAIN');
17
17
 
18
18
  Future<void> main() async {
19
- await runEngineApp(
19
+ await runFirebaseEngineApp(
20
20
  module: const ExampleGameModule(),
21
21
  config: AppConfig(
22
22
  // `seedColor` is left at its default, the EigenInteractive teal that the
@@ -31,14 +31,17 @@ Future<void> main() async {
31
31
  ),
32
32
  engine: EngineConfig(
33
33
  apiBaseUrl: _apiBaseUrl,
34
- googleWebClientId: _googleWebClientId,
35
34
  appHost: _appHost.isEmpty ? null : _appHost,
36
- authDomain: _authDomain.isEmpty ? null : _authDomain,
37
- firebaseVapidKey: _firebaseVapidKey,
38
35
  ),
39
36
  ),
40
37
  firebaseOptions: DefaultFirebaseOptions.currentPlatform,
38
+ firebase: FirebaseAdapterConfig(
39
+ googleWebClientId: _googleWebClientId,
40
+ vapidKey: _firebaseVapidKey,
41
+ authDomain: _authDomain.isEmpty ? null : _authDomain,
42
+ ),
41
43
  onBackgroundMessage: _onBackgroundMessage,
44
+ telemetry: FirebaseTelemetryPolicy.releaseOnly(),
42
45
  );
43
46
  }
44
47
 
@@ -163,7 +163,7 @@ Required values are intentionally empty in a fresh scaffold. The app reports
163
163
  all missing or malformed declarations at startup instead of continuing with
164
164
  placeholder endpoints.
165
165
 
166
- The `eigen_flutter` Android plugin enables Firebase's current FID-based
166
+ The `eigen_firebase` Android plugin enables Firebase's current FID-based
167
167
  messaging mode and supplies a Firebase SDK new enough to support it. The
168
168
  scaffolder leaves Flutter's generated manifest and `gradle.properties`
169
169
  untouched; it adds only the application-level core-library desugaring block
@@ -233,7 +233,7 @@ the engine's mark on your listing. Replace them before a first release:
233
233
  | `assets/icon/icon_foreground.png` | 1024×1024 Android adaptive foreground, transparent, artwork inside the centre 66% |
234
234
  | `assets/icon/splash.png` / `splash_dark.png` | Splash artwork for light and dark |
235
235
 
236
- The notification icon is the exception: `eigen_flutter` ships a default
236
+ The notification icon is the exception: `eigen_firebase` ships a default
237
237
  `ic_notification` drawable, so nothing is missing and nothing breaks if you
238
238
  leave it. To use your own, add
239
239
  `android/app/src/main/res/drawable/ic_notification.xml`, a monochrome
@@ -4,10 +4,10 @@
4
4
  "scripts": {
5
5
  "build:android": "cd app && flutter build appbundle --release --dart-define-from-file=app-config.json",
6
6
  "build:web": "cd app && flutter build web --release --output ../server/public --dart-define-from-file=app-config.json",
7
- "contract": "cd server && {{PACKAGE_MANAGER}} run contract && cd ../app && dart run eigen_flutter:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures",
8
- "contract:check": "cd server && {{PACKAGE_MANAGER}} run contract:check && cd ../app && dart run eigen_flutter:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures --check",
7
+ "contract": "cd server && {{PACKAGE_MANAGER}} run contract && cd ../app && dart run eigen_codegen:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures",
8
+ "contract:check": "cd server && {{PACKAGE_MANAGER}} run contract:check && cd ../app && dart run eigen_codegen:generate_payloads --contract ../server/game-contract.json --output lib/game/generated/payloads.dart --fixtures-output test/fixtures --check",
9
9
  "deploy": "{{PACKAGE_MANAGER}} run build:web && cd server && {{PACKAGE_MANAGER}} run deploy",
10
- "firebase:configure": "cd app && dart run eigen_flutter:configure_firebase --worker ../server",
10
+ "firebase:configure": "cd app && dart run eigen_firebase:configure_firebase --worker ../server",
11
11
  "format": "biome check --write .",
12
12
  "lint": "biome check ."
13
13
  },
@@ -2,3 +2,4 @@
2
2
  # Firebase Console > Project settings > Service accounts. Never commit .dev.vars.
3
3
  FIREBASE_CLIENT_EMAIL=
4
4
  FIREBASE_PRIVATE_KEY=
5
+ SOCKET_TICKET_SECRET=replace-with-at-least-32-random-characters
@@ -28,13 +28,16 @@ the Worker's non-secret environment variables in local development and
28
28
  production. Copy `.dev.vars.example` to `.dev.vars`, then fill
29
29
  `FIREBASE_CLIENT_EMAIL` and `FIREBASE_PRIVATE_KEY` from the same Firebase
30
30
  project's service-account JSON. These credentials are required for FCM and
31
- complete Firebase account deletion; `.dev.vars` is git-ignored.
31
+ complete Firebase account deletion. Also replace `SOCKET_TICKET_SECRET` with at
32
+ least 32 random characters; `openssl rand -base64 32` generates one.
33
+ `.dev.vars` is git-ignored.
32
34
 
33
- For deployment, store both values as Wrangler secrets:
35
+ For deployment, store all three values as Wrangler secrets:
34
36
 
35
37
  ```sh
36
38
  npx wrangler secret put FIREBASE_CLIENT_EMAIL
37
39
  npx wrangler secret put FIREBASE_PRIVATE_KEY
40
+ npx wrangler secret put SOCKET_TICKET_SECRET
38
41
  ```
39
42
 
40
43
  `WEB_APP_ORIGIN` is the exact Flutter web origin the engine automatically
@@ -57,6 +57,7 @@ export const rulesV1: GameRules<State, Observation, Action, Config> = {
57
57
  // create asking for a range outside this, so it is the one place a
58
58
  // fixed-size game says so. Read `config` if the count is a creation choice.
59
59
  playerLimits: () => ({ minPlayers: 2, maxPlayers: 2 }),
60
+ timingOptions: () => [{ mode: "untimed" }],
60
61
 
61
62
  ratingPool: () => null,
62
63
  botSeatable: () => true,