From d849889d83be3049898d8f89cb383c8672785c94 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger 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 { + _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 { + /** Ids of the players in the website */ + players: PlayerInfo[]; + 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>(repr); + +export interface GameDocument extends mongoose.Document, IAbstractGame { + _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, 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"