From 228e5773d03ca45bcae21986df268e273e8982d2 Mon Sep 17 00:00:00 2001
From: Gregor Zeitlinger <gregor.zeitlinger@zalando.de>
Date: Fri, 6 Aug 2021 16:44:27 +0200
Subject: [PATCH] :sparkles: (all) add batch module for analyzing and replaying
 games

---
 batch/package.json   |  28 ++++
 batch/pnpm-lock.yaml | 341 +++++++++++++++++++++++++++++++++++++++++++
 batch/src/game.ts    | 224 ++++++++++++++++++++++++++++
 batch/src/replay.ts  |  97 ++++++++++++
 batch/src/stats.ts   | 264 +++++++++++++++++++++++++++++++++
 batch/src/util.ts    |  20 +++
 batch/tsconfig.json  |  18 +++
 pnpm-workspace.yaml  |   1 +
 8 files changed, 993 insertions(+)
 create mode 100644 batch/package.json
 create mode 100644 batch/pnpm-lock.yaml
 create mode 100644 batch/src/game.ts
 create mode 100755 batch/src/replay.ts
 create mode 100755 batch/src/stats.ts
 create mode 100644 batch/src/util.ts
 create mode 100644 batch/tsconfig.json

diff --git a/batch/package.json b/batch/package.json
new file mode 100644
index 00000000..5f6295d5
--- /dev/null
+++ b/batch/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "@gaia-project/batch",
+  "version": "0.1",
+  "description": "Extracts statistics from games",
+  "type": "commonjs",
+  "contributors": [
+    "zeitlinger"
+  ],
+  "repository": "git@github.com:boardgamers-mono/gaia-project.git",
+  "scripts": {
+    "build": "tsc -p .",
+    "stats": "ts-node src/stats.ts",
+    "stats-replay-errors": "ts-node src/stats.ts replay-errors",
+    "replay": "ts-node src/replay.ts"
+  },
+  "dependencies": {
+    "@gaia-project/engine": "workspace:../viewer",
+    "csv-writer": "^1.6.0",
+    "lodash": "^4.17.15",
+    "mongoose": "^5.9.10"
+  },
+  "license": "MIT",
+  "devDependencies": {
+    "@types/node": "^16.4.13",
+    "ts-node": "^10.1.0",
+    "typescript": "^4.3.5"
+  }
+}
diff --git a/batch/pnpm-lock.yaml b/batch/pnpm-lock.yaml
new file mode 100644
index 00000000..cbc66ec5
--- /dev/null
+++ b/batch/pnpm-lock.yaml
@@ -0,0 +1,341 @@
+lockfileVersion: 5.3
+
+specifiers:
+  '@gaia-project/engine': workspace:../viewer
+  '@types/node': ^16.4.13
+  csv-writer: ^1.6.0
+  lodash: ^4.17.15
+  mongoose: ^5.9.10
+  ts-node: ^10.1.0
+  typescript: ^4.3.5
+
+dependencies:
+  '@gaia-project/engine': link:../viewer
+  csv-writer: 1.6.0
+  lodash: 4.17.21
+  mongoose: 5.13.5
+
+devDependencies:
+  '@types/node': 16.4.13
+  ts-node: 10.1.0_dea0625f6d31b223e93dc3dc354b8b43
+  typescript: 4.3.5
+
+packages:
+
+  /@tsconfig/node10/1.0.8:
+    resolution: {integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==}
+    dev: true
+
+  /@tsconfig/node12/1.0.9:
+    resolution: {integrity: sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==}
+    dev: true
+
+  /@tsconfig/node14/1.0.1:
+    resolution: {integrity: sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==}
+    dev: true
+
+  /@tsconfig/node16/1.0.2:
+    resolution: {integrity: sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==}
+    dev: true
+
+  /@types/bson/4.0.5:
+    resolution: {integrity: sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==}
+    dependencies:
+      '@types/node': 16.4.13
+    dev: false
+
+  /@types/mongodb/3.6.20:
+    resolution: {integrity: sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==}
+    dependencies:
+      '@types/bson': 4.0.5
+      '@types/node': 16.4.13
+    dev: false
+
+  /@types/node/16.4.13:
+    resolution: {integrity: sha512-bLL69sKtd25w7p1nvg9pigE4gtKVpGTPojBFLMkGHXuUgap2sLqQt2qUnqmVCDfzGUL0DRNZP+1prIZJbMeAXg==}
+
+  /arg/4.1.3:
+    resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
+    dev: true
+
+  /bl/2.2.1:
+    resolution: {integrity: sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==}
+    dependencies:
+      readable-stream: 2.3.7
+      safe-buffer: 5.2.1
+    dev: false
+
+  /bluebird/3.5.1:
+    resolution: {integrity: sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==}
+    dev: false
+
+  /bson/1.1.6:
+    resolution: {integrity: sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==}
+    engines: {node: '>=0.6.19'}
+    dev: false
+
+  /buffer-from/1.1.2:
+    resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+    dev: true
+
+  /core-util-is/1.0.2:
+    resolution: {integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=}
+    dev: false
+
+  /create-require/1.1.1:
+    resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
+    dev: true
+
+  /csv-writer/1.6.0:
+    resolution: {integrity: sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==}
+    dev: false
+
+  /debug/3.1.0:
+    resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==}
+    dependencies:
+      ms: 2.0.0
+    dev: false
+
+  /denque/1.5.0:
+    resolution: {integrity: sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==}
+    engines: {node: '>=0.10'}
+    dev: false
+
+  /diff/4.0.2:
+    resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+    engines: {node: '>=0.3.1'}
+    dev: true
+
+  /inherits/2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+    dev: false
+
+  /isarray/1.0.0:
+    resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
+    dev: false
+
+  /kareem/2.3.2:
+    resolution: {integrity: sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==}
+    dev: false
+
+  /lodash/4.17.21:
+    resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+    dev: false
+
+  /make-error/1.3.6:
+    resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+    dev: true
+
+  /memory-pager/1.5.0:
+    resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
+    dev: false
+    optional: true
+
+  /mongodb/3.6.10:
+    resolution: {integrity: sha512-fvIBQBF7KwCJnDZUnFFy4WqEFP8ibdXeFANnylW19+vOwdjOAvqIzPdsNCEMT6VKTHnYu4K64AWRih0mkFms6Q==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      aws4: '*'
+      bson-ext: '*'
+      kerberos: '*'
+      mongodb-client-encryption: '*'
+      mongodb-extjson: '*'
+      snappy: '*'
+    peerDependenciesMeta:
+      aws4:
+        optional: true
+      bson-ext:
+        optional: true
+      kerberos:
+        optional: true
+      mongodb-client-encryption:
+        optional: true
+      mongodb-extjson:
+        optional: true
+      snappy:
+        optional: true
+    dependencies:
+      bl: 2.2.1
+      bson: 1.1.6
+      denque: 1.5.0
+      optional-require: 1.0.3
+      safe-buffer: 5.2.1
+    optionalDependencies:
+      saslprep: 1.0.3
+    dev: false
+
+  /mongoose-legacy-pluralize/1.0.2_mongoose@5.13.5:
+    resolution: {integrity: sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==}
+    peerDependencies:
+      mongoose: '*'
+    dependencies:
+      mongoose: 5.13.5
+    dev: false
+
+  /mongoose/5.13.5:
+    resolution: {integrity: sha512-sSUAk9GWgA8r3w3nVNrNjBaDem86aevwXO8ltDMKzCf+rjnteMMQkXHQdn1ePkt7alROEPZYCAjiRjptWRSPiQ==}
+    engines: {node: '>=4.0.0'}
+    dependencies:
+      '@types/mongodb': 3.6.20
+      bson: 1.1.6
+      kareem: 2.3.2
+      mongodb: 3.6.10
+      mongoose-legacy-pluralize: 1.0.2_mongoose@5.13.5
+      mpath: 0.8.3
+      mquery: 3.2.5
+      ms: 2.1.2
+      optional-require: 1.0.3
+      regexp-clone: 1.0.0
+      safe-buffer: 5.2.1
+      sift: 13.5.2
+      sliced: 1.0.1
+    transitivePeerDependencies:
+      - aws4
+      - bson-ext
+      - kerberos
+      - mongodb-client-encryption
+      - mongodb-extjson
+      - snappy
+    dev: false
+
+  /mpath/0.8.3:
+    resolution: {integrity: sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==}
+    engines: {node: '>=4.0.0'}
+    dev: false
+
+  /mquery/3.2.5:
+    resolution: {integrity: sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==}
+    engines: {node: '>=4.0.0'}
+    dependencies:
+      bluebird: 3.5.1
+      debug: 3.1.0
+      regexp-clone: 1.0.0
+      safe-buffer: 5.1.2
+      sliced: 1.0.1
+    dev: false
+
+  /ms/2.0.0:
+    resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}
+    dev: false
+
+  /ms/2.1.2:
+    resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+    dev: false
+
+  /optional-require/1.0.3:
+    resolution: {integrity: sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==}
+    engines: {node: '>=4'}
+    dev: false
+
+  /process-nextick-args/2.0.1:
+    resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+    dev: false
+
+  /readable-stream/2.3.7:
+    resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==}
+    dependencies:
+      core-util-is: 1.0.2
+      inherits: 2.0.4
+      isarray: 1.0.0
+      process-nextick-args: 2.0.1
+      safe-buffer: 5.1.2
+      string_decoder: 1.1.1
+      util-deprecate: 1.0.2
+    dev: false
+
+  /regexp-clone/1.0.0:
+    resolution: {integrity: sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==}
+    dev: false
+
+  /safe-buffer/5.1.2:
+    resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+    dev: false
+
+  /safe-buffer/5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+    dev: false
+
+  /saslprep/1.0.3:
+    resolution: {integrity: sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==}
+    engines: {node: '>=6'}
+    dependencies:
+      sparse-bitfield: 3.0.3
+    dev: false
+    optional: true
+
+  /sift/13.5.2:
+    resolution: {integrity: sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==}
+    dev: false
+
+  /sliced/1.0.1:
+    resolution: {integrity: sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=}
+    dev: false
+
+  /source-map-support/0.5.19:
+    resolution: {integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==}
+    dependencies:
+      buffer-from: 1.1.2
+      source-map: 0.6.1
+    dev: true
+
+  /source-map/0.6.1:
+    resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+    engines: {node: '>=0.10.0'}
+    dev: true
+
+  /sparse-bitfield/3.0.3:
+    resolution: {integrity: sha1-/0rm5oZWBWuks+eSqzM004JzyhE=}
+    dependencies:
+      memory-pager: 1.5.0
+    dev: false
+    optional: true
+
+  /string_decoder/1.1.1:
+    resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+    dependencies:
+      safe-buffer: 5.1.2
+    dev: false
+
+  /ts-node/10.1.0_dea0625f6d31b223e93dc3dc354b8b43:
+    resolution: {integrity: sha512-6szn3+J9WyG2hE+5W8e0ruZrzyk1uFLYye6IGMBadnOzDh8aP7t8CbFpsfCiEx2+wMixAhjFt7lOZC4+l+WbEA==}
+    engines: {node: '>=12.0.0'}
+    hasBin: true
+    peerDependencies:
+      '@swc/core': '>=1.2.50'
+      '@swc/wasm': '>=1.2.50'
+      '@types/node': '*'
+      typescript: '>=2.7'
+    peerDependenciesMeta:
+      '@swc/core':
+        optional: true
+      '@swc/wasm':
+        optional: true
+    dependencies:
+      '@tsconfig/node10': 1.0.8
+      '@tsconfig/node12': 1.0.9
+      '@tsconfig/node14': 1.0.1
+      '@tsconfig/node16': 1.0.2
+      '@types/node': 16.4.13
+      arg: 4.1.3
+      create-require: 1.1.1
+      diff: 4.0.2
+      make-error: 1.3.6
+      source-map-support: 0.5.19
+      typescript: 4.3.5
+      yn: 3.1.1
+    dev: true
+
+  /typescript/4.3.5:
+    resolution: {integrity: sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==}
+    engines: {node: '>=4.2.0'}
+    hasBin: true
+    dev: true
+
+  /util-deprecate/1.0.2:
+    resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
+    dev: false
+
+  /yn/3.1.1:
+    resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+    engines: {node: '>=6'}
+    dev: true
diff --git a/batch/src/game.ts b/batch/src/game.ts
new file mode 100644
index 00000000..1d87a020
--- /dev/null
+++ b/batch/src/game.ts
@@ -0,0 +1,224 @@
+import mongoose, { Schema, Types } from "mongoose";
+
+// all in this file copied from boardgamers-mono
+
+export interface PlayerInfo<T = string> {
+  _id: T;
+  remainingTime: number;
+  score: number;
+  dropped: boolean;
+  // Not dropped but quit after someone else dropped
+  quit: boolean;
+  name: string;
+  faction?: string;
+  voteCancel?: boolean;
+  ranking?: number;
+  elo?: {
+    initial?: number;
+    delta?: number;
+  };
+}
+
+export interface IAbstractGame<T = string, Game = any, GameOptions = any> {
+  /** Ids of the players in the website */
+  players: PlayerInfo<T>[];
+  creator: T;
+
+  currentPlayers?: Array<{
+    _id: T;
+    timerStart: Date;
+    deadline: Date;
+  }>;
+
+  /** Game data */
+  data: Game;
+
+  context: {
+    round: number;
+  };
+
+  options: {
+    setup: {
+      seed: string;
+      nbPlayers: number;
+      randomPlayerOrder: boolean;
+    };
+    timing: {
+      timePerGame: number;
+      timePerMove: number;
+      /* UTC-based time of play, by default all day, during which the timer is active, in seconds */
+      timer: {
+        // eg 3600 = start at 1 am
+        start: number;
+        // eg 3600*23 = end at 11 pm
+        end: number;
+      };
+      // The game will be cancelled if the game isn't full at this time
+      scheduledStart: Date;
+    };
+    meta: {
+      unlisted: boolean;
+      minimumKarma: number;
+    };
+  };
+
+  game: {
+    name: string; // e.g. "gaia-project"
+    version: number; // e.g. 1
+    expansions: string[]; // e.g. ["spaceships"]
+
+    options: GameOptions;
+  };
+
+  status: "open" | "pending" | "active" | "ended";
+  cancelled: boolean;
+
+  updatedAt: Date;
+  createdAt: Date;
+  lastMove: Date;
+}
+
+const repr = {
+  _id: {
+    type: String,
+    trim: true,
+    minlength: [2, "A game id must be at least 2 characters"] as [number, string],
+    maxlength: [25, "A game id must be at most 25 characters"] as [number, string],
+  },
+  players: {
+    type: [
+      {
+        _id: {
+          type: Schema.Types.ObjectId,
+          ref: "User",
+          index: true,
+        },
+
+        name: String,
+        remainingTime: Number,
+        score: Number,
+        dropped: Boolean,
+        quit: Boolean,
+        faction: String,
+        voteCancel: Boolean,
+        ranking: Number,
+        elo: {
+          initial: Number,
+          delta: Number,
+        },
+      },
+    ],
+    default: () => [],
+  },
+  creator: {
+    type: Schema.Types.ObjectId,
+    index: true,
+  },
+  currentPlayers: [
+    {
+      _id: {
+        type: Schema.Types.ObjectId,
+        ref: "User",
+        index: true,
+      },
+      deadline: {
+        type: Date,
+        index: true,
+      },
+      timerStart: Date,
+    },
+  ],
+  lastMove: {
+    type: Date,
+    index: true,
+  },
+  createdAt: {
+    type: Date,
+    index: true,
+  },
+  updatedAt: {
+    type: Date,
+    index: true,
+  },
+  data: {},
+  status: {
+    type: String,
+    enum: ["open", "pending", "active", "ended"],
+    default: "open",
+  },
+  cancelled: {
+    type: Boolean,
+    default: false,
+  },
+  options: {
+    setup: {
+      randomPlayerOrder: {
+        type: Boolean,
+        default: true,
+      },
+      nbPlayers: {
+        type: Number,
+        default: 2,
+      },
+      seed: {
+        //this is the name
+        type: String,
+        trim: true,
+        minlength: [2, "A game seed must be at least 2 characters"] as [number, string],
+        maxlength: [25, "A game seed must be at most 25 characters"] as [number, string],
+      },
+    },
+    timing: {
+      timePerMove: {
+        type: Number,
+        default: 15 * 60,
+        min: 0,
+        max: 24 * 3600,
+      },
+      timePerGame: {
+        type: Number,
+        default: 15 * 24 * 3600,
+        min: 60,
+        max: 15 * 24 * 3600,
+        // enum: [1 * 3600, 24 * 3600, 3 * 24 * 3600, 15 * 24 * 3600]
+      },
+      timer: {
+        start: {
+          type: Number,
+          min: 0,
+          max: 24 * 3600 - 1,
+        },
+        end: {
+          type: Number,
+          min: 0,
+          max: 24 * 3600 - 1,
+        },
+      },
+      scheduledStart: Date,
+    },
+    meta: {
+      unlisted: Boolean,
+      minimumKarma: Number,
+    },
+  },
+
+  context: {
+    round: Number,
+  },
+
+  game: {
+    name: String,
+    version: Number,
+    expansions: [String],
+
+    options: {},
+  },
+};
+
+const schema = new Schema<GameDocument, mongoose.Model<GameDocument>>(repr);
+
+export interface GameDocument extends mongoose.Document, IAbstractGame<Types.ObjectId> {
+  _id: string;
+}
+
+export const Game = mongoose.model("Game", schema);
diff --git a/batch/src/replay.ts b/batch/src/replay.ts
new file mode 100755
index 00000000..9f779813
--- /dev/null
+++ b/batch/src/replay.ts
@@ -0,0 +1,97 @@
+import * as fs from "fs";
+import * as process from "process";
+import Engine from "../../engine";
+import { replay } from "../../engine/wrapper";
+import { Game, GameDocument } from "./game";
+import { connectMongo, shouldReplay } from "./util";
+
+const engineVersion = new Engine().version;
+
+async function main() {
+  let success = 0;
+  let errors = 0;
+  let replayed = 0;
+  let cancelled = 0;
+  let active = 0;
+  let expansion = 0;
+
+  let progress = 0;
+
+  const outcomes = () => ({
+    success,
+    errors,
+    replayed,
+    cancelled,
+    active,
+    expansion,
+  });
+
+  async function process(game: GameDocument) {
+    progress++;
+    if (progress % 10 == 0) {
+      console.log("progress", progress);
+    }
+
+    if (game.cancelled) {
+      cancelled++;
+      return;
+    }
+    if (game.status !== "ended") {
+      active++;
+      return;
+    }
+
+    if (game.game.expansions.length > 0) {
+      expansion++;
+      return;
+    }
+
+    if (!shouldReplay(game)) {
+      success++;
+      return;
+    }
+    let data = game.data as Engine;
+
+    if (shouldReplay(game)) {
+      const file = `replay/${game._id}.json`;
+      if (!fs.existsSync(file)) {
+        console.log("replay " + game._id);
+        data = await replay(data);
+        data.replayVersion = engineVersion;
+        const oldPlayers = game.players;
+        for (let i = 0; i < oldPlayers.length && i < oldPlayers.length; i++) {
+          data.players[i].name = oldPlayers[i].name;
+          data.players[i].dropped = oldPlayers[i].dropped;
+        }
+
+        game.data = data;
+        fs.writeFileSync(file, JSON.stringify(game.toJSON()), { encoding: "utf8" });
+      }
+    }
+
+    replayed++;
+  }
+
+  connectMongo();
+
+  // .where("_id").equals("Costly-amount-263") //for testing
+  for await (const game of Game.find().where("game.name").equals("gaia-project")) {
+    try {
+      await process(game);
+    } catch (e) {
+      console.log(game._id);
+      // console.log(JSON.stringify(game));
+      console.log(e);
+      errors++;
+    }
+  }
+
+  console.log(outcomes());
+}
+
+const start = new Date();
+main().then(() => {
+  console.log("done");
+  console.log(new Date().getTime() - start.getTime());
+  process.exit(0);
+});
diff --git a/batch/src/stats.ts b/batch/src/stats.ts
new file mode 100755
index 00000000..5314b9fb
--- /dev/null
+++ b/batch/src/stats.ts
@@ -0,0 +1,264 @@
+import { createObjectCsvWriter } from "csv-writer";
+import * as fs from "fs";
+import { sortBy, sumBy } from "lodash";
+import * as process from "process";
+import Engine, { Booster, Command, Player, roundScorings } from "../../engine";
+import { boosterNames } from "../../viewer/src/data/boosters";
+import { advancedTechTileNames, baseTechTileNames } from "../../viewer/src/data/tech-tiles";
+import { parsedMove } from "../../viewer/src/logic/recent";
+import { Game, GameDocument, PlayerInfo } from "./game";
+import { connectMongo, shouldReplay } from "./util";
+import { ChartSetup } from "../../viewer/src/logic/charts/chart-factory";
+
+const errorDir = "error/";
+
+function getDetailStats(commonProps: any, data: Engine, pl: Player) {
+  const newDetailRow = (round: any) => {
+    const d = {
+      round: round,
+    };
+    Object.assign(d, commonProps);
+    return d;
+  };
+
+  const rows: any[] = [];
+
+  const chartSetup = new ChartSetup(data, true);
+  const fam = chartSetup.families.filter((f) => f != "Final Scoring Conditions" && f != "Federations");
+
+  for (let family of fam) {
+    const f = chartSetup.chartFactory(family);
+    const sources = f.sources(family);
+
+    const details = f.newDetails(data, pl.player, sources, "except-final", family, false);
+    for (let detail of details) {
+      const dataPoints = detail.getDataPoints();
+      if (rows.length == 0) {
+        for (let round = 0; round < dataPoints.length; round++) {
+          rows.push(newDetailRow(round));
+        }
+        rows.push(newDetailRow("total"));
+      }
+      const key = `${family} - ${detail.label}`;
+      let last = 0;
+      dataPoints.forEach((value, index) => {
+        rows[index][key] = value - last;
+        last = value;
+      });
+      rows[dataPoints.length][key] = dataPoints[dataPoints.length - 1];
+    }
+  }
+  return rows;
+}
+
+function getGameStats(pl: Player, outerPlayer: PlayerInfo<any>, data: Engine, game: GameDocument, commonProps: any) {
+  const playerProp = (key: string, def: any) => (key in outerPlayer ? outerPlayer[key] ?? def : def);
+  const rank = sortBy(data.players, (p: Player) => -p.data.victoryPoints);
+  const rankWithoutBid = sortBy(data.players, (p: Player) => -(p.data.victoryPoints + (p.data.bid ?? 0))); // bid is positive
+
+  const date = game.createdAt;
+
+  function toIso(date: any) {
+    return typeof date == "string" ? date : date?.toISOString();
+  }
+
+  const row = {
+    initialTurnOrder: pl.player + 1,
+    version: data.version ?? "1.0.0",
+    players: data.players.length,
+    started: toIso(game.createdAt),
+    ended: toIso(game.lastMove),
+    variant: data.options.factionVariant,
+    auction: data.options.auction,
+    layout: data.options.layout,
+    randomFactions: data.options.randomFactions ?? false,
+    rotateSectors: !data.options.advancedRules,
+    rank: rank.indexOf(pl) + 1,
+    rankWithoutBid: rankWithoutBid.indexOf(pl) + 1,
+    playerDropped: playerProp("dropped", false),
+    playerQuit: playerProp("quit", false),
+  };
+
+  Object.assign(row, commonProps);
+
+  for (let pos in data.tiles.techs) {
+    if (pos === "move") {
+      continue;
+    }
+    const tech = data.tiles.techs[pos].tile;
+    row[`tech-${pos}`] = advancedTechTileNames[tech] ?? baseTechTileNames[tech].name;
+  }
+
+  row["finalA"] = data.tiles.scorings.final[0];
+  row["finalB"] = data.tiles.scorings.final[1];
+
+  data.tiles.scorings.round.forEach((tile, index) => {
+    row[`roundScoring${index + 1}`] = roundScorings[tile][0];
+  });
+
+  for (let booster of Booster.values()) {
+    row[`booster ${boosterNames[booster].name}`] = data.tiles.boosters[booster] ? 1 : 0;
+  }
+
+  let i = 1;
+  for (const move of data.moveHistory) {
+    const command = parsedMove(move).commands[0];
+    if (command.command == Command.Build && command.faction === pl.faction) {
+      const hex = data.map.getS(command.args[1]);
+      // data.map.distance()
+      row[`startPosition${i}`] = hex.toString();
+      row[`startPositionDistance${i}`] = (Math.abs(hex.q) + Math.abs(hex.r) + Math.abs(-hex.q - hex.r)) / 2;
+      i++;
+    } else if (command.command == Command.ChooseRoundBooster) {
+      break;
+    }
+  }
+
+  for (; i < 4; i++) {
+    //so that all columns are filled to get the correct headers
+    row[`startPosition${i}`] = "";
+    row[`startPositionDistance${i}`] = "";
+  }
+
+  return row;
+}
+
+function getStats(game: GameDocument, data: Engine): { game: any[]; detail: any[] } {
+  const avgElo =
+    sumBy(game.players, (p: PlayerInfo) => (p.elo?.initial ?? 0) + (p.elo?.delta ?? 0)) / game.players.length;
+
+  return data.players
+    .flatMap((pl) => {
+      const outerPlayer = game.players.find((p) => p.faction === pl.faction);
+
+      const commonProps = {
+        id: game._id,
+        player: outerPlayer.name,
+        faction: pl.faction,
+        score: outerPlayer.score,
+        scoreWithoutBid: outerPlayer.score + (pl.data.bid ?? 0), // bid is positive
+        eloInitial: outerPlayer.elo?.initial,
+        eloDelta: outerPlayer.elo?.delta,
+        averageElo: avgElo,
+      };
+      const gameRow = getGameStats(pl, outerPlayer, data, game, commonProps);
+      return { game: [gameRow], detail: getDetailStats(commonProps, data, pl) };
+    })
+    .reduce((a, b) => {
+      a.game.push(...b.game);
+      a.detail.push(...b.detail);
+      return a;
+    });
+}
+
+function readJson(file: string) {
+  return JSON.parse(fs.readFileSync(file, { encoding: "utf8" }));
+}
+
+async function main(args: string[]) {
+  const replayErrors = args.length > 0 && args[0] == "replay-errors";
+
+  let success = 0;
+  let errors = 0;
+  let skipReplay = 0;
+  let cancelled = 0;
+  let active = 0;
+  let expansion = 0;
+
+  let progress = 0;
+
+  const outcomes = () => ({
+    success,
+    errors,
+    skipReplay,
+    cancelled,
+    active,
+    expansion,
+  });
+
+  let gameWriter = null;
+  let detailWriter = null;
+
+  async function process(game: GameDocument) {
+    progress++;
+    if (progress % 10 == 0) {
+      console.log("progress", progress);
+    }
+
+    if (game.cancelled) {
+      cancelled++;
+      return;
+    }
+    if (game.status !== "ended") {
+      active++;
+      return;
+    }
+
+    if (game.game.expansions.length > 0) {
+      expansion++;
+      return;
+    }
+
+    let data: Engine;
+
+    if (shouldReplay(game)) {
+      const file = `replay/${game._id}.json`;
+      if (fs.existsSync(file)) {
+        data = Engine.fromData(readJson(file));
+      } else {
+        skipReplay++;
+        return;
+      }
+    } else {
+      data = Engine.fromData(game.data);
+    }
+
+    const stats = getStats(game, data);
+
+    if (gameWriter == null) {
+      const append = replayErrors
+      gameWriter = createObjectCsvWriter({
+        path: "gaia-stats-game.csv",
+        header: Object.keys(stats.game[0]).map((k) => ({ id: k, title: k })),
+        append,
+      });
+      detailWriter = createObjectCsvWriter({
+        path: "gaia-stats-turns.csv",
+        header: Object.keys(stats.detail[0]).map((k) => ({ id: k, title: k })),
+        append,
+      });
+    }
+
+    await gameWriter.writeRecords(stats.game);
+    await detailWriter.writeRecords(stats.detail);
+    success++;
+  }
+
+  if (replayErrors) {
+    for (const file of fs.readdirSync(errorDir)) {
+      console.log(file);
+      await process(readJson(errorDir + file));
+    }
+  } else {
+    connectMongo();
+    for await (const game of Game.find().where("game.name").equals("gaia-project")) {
+      try {
+        await process(game);
+      } catch (e) {
+        console.log(game._id);
+        console.log(e);
+        fs.writeFileSync(errorDir + game._id + ".json", JSON.stringify(game.toJSON()), { encoding: "utf8" });
+        errors++;
+      }
+    }
+  }
+
+  console.log(outcomes());
+}
+
+const start = new Date();
+main(process.argv.slice(2)).then(() => {
+  console.log("done");
+  console.log(new Date().getTime() - start.getTime());
+  process.exit(0);
+});
diff --git a/batch/src/util.ts b/batch/src/util.ts
new file mode 100644
index 00000000..fbba0adb
--- /dev/null
+++ b/batch/src/util.ts
@@ -0,0 +1,20 @@
+import mongoose from "mongoose";
+import Engine from "../../engine";
+import { GameDocument } from "./game";
+
+export function connectMongo() {
+  mongoose.connect("mongodb://127.0.0.1:27017", { dbName: "test", useNewUrlParser: true });
+
+  mongoose.connection.on("error", (err) => {
+    console.error(err);
+  });
+
+  mongoose.connection.on("open", async () => {
+    console.log("connected to database!");
+  });
+}
+
+export function shouldReplay(game: GameDocument) {
+  const data = game.data as Engine;
+  return game.options.setup.nbPlayers != data.players.length || !data.advancedLog?.length;
+}
diff --git a/batch/tsconfig.json b/batch/tsconfig.json
new file mode 100644
index 00000000..53ceeada
--- /dev/null
+++ b/batch/tsconfig.json
@@ -0,0 +1,18 @@
+{
+  "compilerOptions": {
+    /* Basic Options */
+    "target": "es2019",
+    "module": "CommonJS",
+    "moduleResolution": "node",
+    "sourceMap": true,
+    "outDir": "./dist",
+    "baseUrl": ".",
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "resolveJsonModule": true
+  },
+  "include": ["src/**/*.ts", "src/*.ts"],
+  "exclude": ["node_modules"]
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index db6342e6..31b971c5 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,3 +2,4 @@ packages:
   - "viewer"
   - "engine"
   - "old-ui"
+  - "batch"