From bd85a00a92bda2ed12f6a2de2209ef213fee82de 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 --- .gitignore | 2 + batch/package.json | 27 ++ batch/pnpm-lock.yaml | 341 ++++++++++++++++++ batch/src/game.ts | 224 ++++++++++++ batch/src/replay.ts | 92 +++++ batch/src/stats.ts | 240 ++++++++++++ batch/src/util.ts | 20 + batch/tsconfig.json | 18 + engine/src/available-command.spec.ts | 2 +- engine/src/available-command.ts | 24 +- engine/src/cost.spec.ts | 4 +- engine/src/cost.ts | 8 +- engine/src/engine.ts | 69 ++-- engine/src/player.spec.ts | 2 +- engine/src/player.ts | 15 +- engine/src/tiles/boosters.spec.ts | 3 +- engine/wrapper.ts | 4 +- pnpm-workspace.yaml | 1 + viewer/src/logic/chart-factory.ts | 2 +- .../adv-tech-pass/advanced-tech-tiles.json | 100 +++++ .../chartTests/adv-tech-pass/test-case.json | 2 +- .../all-families/advanced-tech-tiles.json | 109 ++++++ viewer/src/logic/charts.ts | 20 +- viewer/src/logic/final-scoring.ts | 7 +- viewer/src/logic/simple-charts.ts | 40 +- viewer/src/self-contained.ts | 2 +- 26 files changed, 1307 insertions(+), 71 deletions(-) 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 create mode 100644 viewer/src/logic/chartTests/adv-tech-pass/advanced-tech-tiles.json create mode 100644 viewer/src/logic/chartTests/all-families/advanced-tech-tiles.json diff --git a/.gitignore b/.gitignore index 3c3629e6..1c8d3d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules +batch/replay/ +batch/dist/ diff --git a/batch/package.json b/batch/package.json new file mode 100644 index 00000000..a41478b5 --- /dev/null +++ b/batch/package.json @@ -0,0 +1,27 @@ +{ + "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", + "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..2618ece1 --- /dev/null +++ b/batch/src/replay.ts @@ -0,0 +1,92 @@ +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"; + +async function main() { + let success = 0; + let errors = 0; + let cancelled = 0; + let active = 0; + let expansion = 0; + + let progress = 0; + + const outcomes = () => ({ + success, + errors, + 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)) { + errors++; + 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); + fs.writeFileSync(`replay/${game._id}.bak`, JSON.stringify(data), { encoding: "utf8" }); + data = await replay(data); + 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; + } + + fs.writeFileSync(file, JSON.stringify(data.toJSON()), { encoding: "utf8" }); + } + } + + success++; + } + + 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((r) => { + 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..86eb993f --- /dev/null +++ b/batch/src/stats.ts @@ -0,0 +1,240 @@ +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 { chartFactory, families } from "../../viewer/src/logic/chart-factory"; +import { parsedMove } from "../../viewer/src/logic/recent"; +import { Game, GameDocument, PlayerInfo } from "./game"; +import { connectMongo, shouldReplay } from "./util"; + +const fam = families().filter((f) => f != "Final Scoring Conditions" && f != "Federations"); + +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[] = []; + + for (let family of fam) { + const f = 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 row = { + initialTurnOrder: pl.player + 1, + version: data.version ?? "1.0.0", + players: data.players.length, + started: game.createdAt?.toISOString(), + ended: game.lastMove?.toISOString(), + 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; + }); +} + +async function main() { + 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(JSON.parse(fs.readFileSync(file, { encoding: "utf8" }))); + } else { + skipReplay++; + return; + } + } else { + data = Engine.fromData(game.data); + } + + const stats = getStats(game, data); + + if (gameWriter == null) { + gameWriter = createObjectCsvWriter({ + path: "gaia-stats-game.csv", + header: Object.keys(stats.game[0]).map((k) => ({ id: k, title: k })), + }); + detailWriter = createObjectCsvWriter({ + path: "gaia-stats-turns.csv", + header: Object.keys(stats.detail[0]).map((k) => ({ id: k, title: k })), + }); + } + + await gameWriter.writeRecords(stats.game); + await detailWriter.writeRecords(stats.detail); + success++; + } + + 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(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/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..09d6497c --- /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/engine/src/available-command.spec.ts b/engine/src/available-command.spec.ts index 1e515b0e..92aa6f6f 100644 --- a/engine/src/available-command.spec.ts +++ b/engine/src/available-command.spec.ts @@ -98,7 +98,7 @@ describe("Available commands", () => { const player = { data: d } as Player; function possible() { - return possibleBoardActions(actions, player)[0].data.poweracts.map((a) => a.name); + return possibleBoardActions(actions, player, false)[0].data.poweracts.map((a) => a.name); } expect(possible()).to.include("power1"); diff --git a/engine/src/available-command.ts b/engine/src/available-command.ts index 7a93f9bf..67cd4565 100644 --- a/engine/src/available-command.ts +++ b/engine/src/available-command.ts @@ -205,7 +205,7 @@ export function generate(engine: Engine, subPhase: SubPhase = null, data?: any): ...possibleBuildings(engine, player), ...possibleFederations(engine, player), ...possibleResearchAreas(engine, player, UPGRADE_RESEARCH_COST), - ...possibleBoardActions(engine.boardActions, engine.player(player)), + ...possibleBoardActions(engine.boardActions, engine.player(player), engine.replay), ...possibleSpecialActions(engine, player), ...possibleFreeActions(engine.player(player)), ...possibleRoundBoosters(engine, player), @@ -278,14 +278,15 @@ function addPossibleNewPlanet( planet: Planet, building: Building, buildings: AvailableBuilding[], - lastRound: boolean + lastRound: boolean, + replay: boolean ) { - const qicNeeded = qicForDistance(map, hex, pl); + const qicNeeded = qicForDistance(map, hex, pl, replay); if (qicNeeded === null) { return; } - const check = pl.canBuild(planet, building, lastRound, { + const check = pl.canBuild(planet, building, lastRound, replay, { addedCost: [new Reward(qicNeeded.amount, Resource.Qic)], }); @@ -370,7 +371,10 @@ export function possibleBuildings(engine: Engine, player: Player): AvailableComm for (const upgrade of upgraded) { const check = engine .player(player) - .canBuild(hex.data.planet, upgrade, engine.isLastRound, { isolated, existingBuilding: building }); + .canBuild(hex.data.planet, upgrade, engine.isLastRound, engine.replay, { + isolated, + existingBuilding: building, + }); if (check) { buildings.push(newAvailableBuilding(upgrade, hex, check, true)); } @@ -382,7 +386,7 @@ export function possibleBuildings(engine: Engine, player: Player): AvailableComm // No need for terra forming if already occupied by another faction const planet = hex.occupied() ? pl.planet : hex.data.planet; const building = hex.data.planet === Planet.Transdim ? Building.GaiaFormer : Building.Mine; - addPossibleNewPlanet(map, hex, pl, planet, building, buildings, engine.isLastRound); + addPossibleNewPlanet(map, hex, pl, planet, building, buildings, engine.isLastRound, engine.replay); } } // end for hex @@ -412,7 +416,7 @@ export function possibleSpaceStations(engine: Engine, player: Player): Available } const building = Building.SpaceStation; - addPossibleNewPlanet(map, hex, pl, pl.planet, building, buildings, engine.isLastRound); + addPossibleNewPlanet(map, hex, pl, pl.planet, building, buildings, engine.isLastRound, engine.replay); } if (buildings.length > 0) { @@ -494,7 +498,7 @@ export function possibleSpecialActions(engine: Engine, player: Player) { return commands; } -export function possibleBoardActions(actions: BoardActions, p: PlayerObject): AvailableCommand[] { +export function possibleBoardActions(actions: BoardActions, p: PlayerObject, replay: boolean): AvailableCommand[] { const commands: AvailableCommand[] = []; // not allowed if everything is lost - see https://github.com/boardgamers/gaia-project/issues/76 @@ -512,7 +516,7 @@ export function possibleBoardActions(actions: BoardActions, p: PlayerObject): Av (pwract) => actions[pwract] === null && p.data.canPay(Reward.parse(boardActions[pwract].cost)) && - boardActions[pwract].income.some((income) => Reward.parse(income).some((reward) => canGain(reward))) + boardActions[pwract].income.some((income) => Reward.parse(income).some((reward) => replay || canGain(reward))) ); // Prevent using the rescore action if no federation token @@ -687,7 +691,7 @@ export function possibleSpaceLostPlanet(engine: Engine, player: Player) { if (hex.data.planet !== Planet.Empty || hex.data.federations || hex.data.building) { continue; } - const qicNeeded = qicForDistance(engine.map, hex, p); + const qicNeeded = qicForDistance(engine.map, hex, p, engine.replay); if (qicNeeded.amount > data.qics) { continue; diff --git a/engine/src/cost.spec.ts b/engine/src/cost.spec.ts index 941c4ff6..85f3d985 100644 --- a/engine/src/cost.spec.ts +++ b/engine/src/cost.spec.ts @@ -131,7 +131,7 @@ describe("cost", () => { player: PlayerEnum.Player1, } as Player; - const qic = qicForDistance(m, m.getS(g.location), p); + const qic = qicForDistance(m, m.getS(g.location), p, false); expect(qic).to.deep.equal(test.want); }); } @@ -179,7 +179,7 @@ describe("cost", () => { it(test.name, () => { const g = test.give; const p = { temporaryStep: g.temporaryStep, terraformCostDiscount: g.discount } as PlayerData; - const cost = terraformingCost(p, g.steps); + const cost = terraformingCost(p, g.steps, false); expect(cost).to.deep.equal(test.want.cost); }); } diff --git a/engine/src/cost.ts b/engine/src/cost.ts index d0752c34..015baa86 100644 --- a/engine/src/cost.ts +++ b/engine/src/cost.ts @@ -8,12 +8,12 @@ import Reward from "./reward"; const TERRAFORMING_COST = 3; const QIC_RANGE_UPGRADE = 2; -export function terraformingCost(d: PlayerData, steps: number): Reward | null { +export function terraformingCost(d: PlayerData, steps: number, replay: boolean): Reward | null { const oreNeeded = (temporaryStep: number) => (TERRAFORMING_COST - d.terraformCostDiscount) * Math.max(steps - temporaryStep, 0); const cost = oreNeeded(d.temporaryStep); - if (d.temporaryStep > 0 && oreNeeded(0) === cost) { + if (!replay && d.temporaryStep > 0 && oreNeeded(0) === cost) { // not allowed - see https://github.com/boardgamers/gaia-project/issues/76 // OR (for booster) there's no reason to activate the booster and not use it return null; @@ -23,7 +23,7 @@ export function terraformingCost(d: PlayerData, steps: number): Reward | null { export type QicNeeded = { amount: number; distance: number; warning?: BuildWarning } | null; -export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject): QicNeeded { +export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject, replay: boolean): QicNeeded { const distance = (acceptGaiaFormer: boolean) => { const hexes = acceptGaiaFormer ? Array.from(map.grid.values()).filter((loc) => loc.data.player === pl.player) @@ -37,7 +37,7 @@ export function qicForDistance(map: SpaceMap, hex: GaiaHex, pl: PlayerObject): Q const d = distance(false); const qicNeeded = qic(pl.data.temporaryRange, d); - if (pl.data.temporaryRange > 0 && qic(0, distance(false)) === qicNeeded) { + if (!replay && pl.data.temporaryRange > 0 && qic(0, distance(false)) === qicNeeded) { // there's no reason to activate the booster and not use it return null; } diff --git a/engine/src/engine.ts b/engine/src/engine.ts index 35a89f8d..f3d1149b 100644 --- a/engine/src/engine.ts +++ b/engine/src/engine.ts @@ -227,6 +227,7 @@ export default class Engine { oldPhase: Phase; randomFactions?: Faction[]; version = version; + replay: boolean; // be more permissive during replay get expansions() { return 0; @@ -266,13 +267,19 @@ export default class Engine { // Tells the UI if the new move should be on the same line or not newTurn = true; - constructor(moves: string[] = [], options: EngineOptions = {}, engineVersion?: string) { + constructor(moves: string[] = [], options: EngineOptions = {}, engineVersion?: string, replay = false) { this.options = options; if (engineVersion) { this.version = engineVersion; } + this.replay = replay; + if (replay) { + this.options.noFedCheck = true; + this.options.flexibleFederations = true; + } this.sanitizeOptions(); this.loadMoves(moves); + this.options = options; } /** Fix old options passed. To remove when legacy data is no more in database */ @@ -311,12 +318,23 @@ export default class Engine { } move(_move: string, allowIncomplete = true) { - assert(this.newTurn, "Cannot execute a move after executing an incomplete move"); + if (this.replay) { + this.newTurn = true; + } else { + assert(this.newTurn, "Cannot execute a move after executing an incomplete move"); + } const execute = () => { - if (!this.executeMove(move)) { - assert(allowIncomplete, `Move ${move} (line ${this.moveHistory.length + 1}) is not complete!`); - this.newTurn = false; + try { + if (!this.executeMove(move)) { + if (!this.replay) { + assert(allowIncomplete, `Move ${move} (line ${this.moveHistory.length + 1}) is not complete!`); + } + this.newTurn = false; + } + } catch (e) { + console.log(this.assertContext()); + throw e; } }; @@ -329,7 +347,9 @@ export default class Engine { execute(); } - assert(this.turnMoves.length === 0, "Unnecessary commands at the end of the turn: " + this.turnMoves.join(". ")); + if (!this.replay) { + assert(this.turnMoves.length === 0, "Unnecessary commands at the end of the turn: " + this.turnMoves.join(". ")); + } this.moveHistory.push(moveToShow); } @@ -711,7 +731,7 @@ export default class Engine { return false; } - static fromData(data: Record) { + static fromData(data: Record): Engine { const engine = new Engine(); delete engine.version; @@ -836,10 +856,9 @@ export default class Engine { } } - assert( - this.playerToMove === (player as PlayerEnum), - "Wrong turn order in move " + move + ", expected player " + (this.playerToMove + 1) + this.assertContext() - ); + if (!this.replay) { + assert(this.playerToMove === (player as PlayerEnum), "Wrong turn order in move " + move + ", expected player " + (this.playerToMove + 1)); + } this.processedPlayer = player; const split = params.split ?? true; @@ -896,7 +915,7 @@ export default class Engine { if (subphase) { this.generateAvailableCommands(subphase, data); if (this.availableCommands.length === 0) { - if (required) { + if (required && !this.replay) { // not allowed - see https://github.com/boardgamers/gaia-project/issues/76 this.availableCommands = [{ name: Command.DeadEnd, player: this.currentPlayer, data: subphase }]; } else { @@ -927,10 +946,14 @@ export default class Engine { } checkCommand(command: Command) { - assert( - (this.availableCommand = this.findAvailableCommand(this.playerToMove, command)), - `Command ${command} is not in the list of available commands: ${this.assertContext()}` - ); + this.availableCommand = this.findAvailableCommand(this.playerToMove, command); + if (!this.availableCommand) { + if (this.replay) { + console.log("need to patch available command"); + } else { + assert(this.availableCommand, `Command ${command} is not in the list of available commands`); + } + } } private assertContext(): string { @@ -1525,10 +1548,12 @@ export default class Engine { } [Command.Bid](player: PlayerEnum, faction: string, bid: number) { - const bidsAC = this.avCommand().data.bids; - const bidAC = bidsAC.find((b) => b.faction === faction); - assert(bidAC.bid.includes(+bid), "You have to bid the right amount"); - assert(bidAC, `${faction} is not in the available factions`); + if (!this.replay) { + const bidsAC = this.avCommand().data.bids; + const bidAC = bidsAC.find((b) => b.faction === faction); + assert(bidAC, `${faction} is not in the available factions`); + assert(bidAC.bid.includes(+bid), "You have to bid the right amount"); + } this.executeBid(player, faction, bid); } @@ -1746,7 +1771,7 @@ export default class Engine { return false; }; - assert(isPossible(cost, income), `spend ${cost} for ${income} is not allowed: ${this.assertContext()}`); + assert(isPossible(cost, income), `spend ${cost} for ${income} is not allowed`); pl.payCosts(cost, Command.Spend); pl.gainRewards(income, Command.Spend); @@ -1796,7 +1821,7 @@ export default class Engine { [Command.FormFederation](player: PlayerEnum, hexes: string, federation: Federation) { const pl = this.player(player); - const fedInfo = pl.checkAndGetFederationInfo(hexes, this.map, this.options.flexibleFederations); + const fedInfo = pl.checkAndGetFederationInfo(hexes, this.map, this.options.flexibleFederations, this.replay); assert(fedInfo, `Impossible to form federation at ${hexes}`); assert( diff --git a/engine/src/player.spec.ts b/engine/src/player.spec.ts index 99b089b9..141f7e33 100644 --- a/engine/src/player.spec.ts +++ b/engine/src/player.spec.ts @@ -17,7 +17,7 @@ describe("Player", () => { player.faction = Faction.Terrans; player.loadFaction(standard); - const { cost } = player.canBuild(Planet.Terra, Building.Mine, false, { + const { cost } = player.canBuild(Planet.Terra, Building.Mine, false, false, { addedCost: [new Reward(1, Resource.Qic)], }); diff --git a/engine/src/player.ts b/engine/src/player.ts index 8d6e4fe2..5af156d7 100644 --- a/engine/src/player.ts +++ b/engine/src/player.ts @@ -247,6 +247,7 @@ export default class Player extends EventEmitter { targetPlanet: Planet, building: Building, lastRound: boolean, + replay: boolean, { isolated, addedCost, @@ -285,7 +286,7 @@ export default class Player extends EventEmitter { } else if (building === Building.Mine) { // habitability costs if (targetPlanet === Planet.Gaia) { - if (this.data.temporaryStep > 0) { + if (this.data.temporaryStep > 0 && !replay) { // not allowed - see https://github.com/boardgamers/gaia-project/issues/76 // OR (for booster) there's no reason to activate the booster and not use it return null; @@ -300,7 +301,7 @@ export default class Player extends EventEmitter { } else { // Get the number of terraforming steps to pay discounting terraforming track steps = terraformingStepsRequired(factionPlanet(this.faction), targetPlanet); - const reward = terraformingCost(this.data, steps); + const reward = terraformingCost(this.data, steps, replay); if (reward === null) { return null; @@ -1058,7 +1059,7 @@ export default class Player extends EventEmitter { this.federationCache = null; } - checkAndGetFederationInfo(location: string, map: SpaceMap, flexible: boolean): FederationInfo { + checkAndGetFederationInfo(location: string, map: SpaceMap, flexible: boolean, replay: boolean): FederationInfo { const hexes = this.hexesForFederationLocation(location, map); // Check if no forbidden square @@ -1079,13 +1080,17 @@ export default class Player extends EventEmitter { const info = this.federationInfo(hexes); - assert(info.newSatellites <= this.maxSatellites, "Federation requires too many satellites"); + if (!replay) { + assert(info.newSatellites <= this.maxSatellites, "Federation requires too many satellites"); + } // Check if outclassed by available federations const available = this.availableFederations(map, flexible); const outclasser = available.find((fed) => isOutclassedBy(info, fed)); - assert(!outclasser, "Federation is outclassed by other federation at " + (outclasser?.hexes ?? []).join(",")); + if (!replay) { + assert(!outclasser, "Federation is outclassed by other federation at " + (outclasser?.hexes ?? []).join(",")); + } // Check if federation can be built with less satellites if (!flexible) { diff --git a/engine/src/tiles/boosters.spec.ts b/engine/src/tiles/boosters.spec.ts index 02f667d1..83384abf 100644 --- a/engine/src/tiles/boosters.spec.ts +++ b/engine/src/tiles/boosters.spec.ts @@ -137,8 +137,7 @@ describe("boosters", () => { `); expect(() => new Engine([...moves], { factionVariant: "more-balanced" })).to.throw( - "Command endturn is not in the list of available commands: last command: firaks pass booster9 returning booster4, index: 10,\n" + - ' available: [{"name":"deadEnd","player":0,"data":"buildMineOrGaiaFormer"}]' + "Command endturn is not in the list of available commands" ); }); }); diff --git a/engine/wrapper.ts b/engine/wrapper.ts index e89c48a6..6eff5de9 100644 --- a/engine/wrapper.ts +++ b/engine/wrapper.ts @@ -180,10 +180,10 @@ export function factions(engine: Engine) { return engine.players.map((pl) => pl.faction); } -export async function replay(engine: Engine) { +export async function replay(engine: Engine): Promise { const oldPlayers = engine.players; - engine = new Engine(engine.moveHistory, engine.options); + engine = new Engine(engine.moveHistory, engine.options, engine.version ?? "1.0.0", true); //fall back to unknown version assert(engine.newTurn, "Last move of the game is incomplete"); 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" diff --git a/viewer/src/logic/chart-factory.ts b/viewer/src/logic/chart-factory.ts index f866ff75..5632c847 100644 --- a/viewer/src/logic/chart-factory.ts +++ b/viewer/src/logic/chart-factory.ts @@ -165,7 +165,7 @@ const vpChartFactory: ChartFactory = { const chartFactories: ChartFactory[] = [simpleChartFactory, vpChartFactory]; -function chartFactory(family: ChartFamily): ChartFactory { +export function chartFactory(family: ChartFamily): ChartFactory { return chartFactories.find((f) => f.canHandle(family)); } diff --git a/viewer/src/logic/chartTests/adv-tech-pass/advanced-tech-tiles.json b/viewer/src/logic/chartTests/adv-tech-pass/advanced-tech-tiles.json new file mode 100644 index 00000000..1fdcee13 --- /dev/null +++ b/viewer/src/logic/chartTests/adv-tech-pass/advanced-tech-tiles.json @@ -0,0 +1,100 @@ +{ + "tableMeta": { + "weights": null, + "colors": [ + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + } + ], + "datasetMeta": { + "Bal T'aks (1)": { + "label": "Bal T'aks", + "total": 1, + "weightedTotal": null + }, + "Ivits (0)": { + "label": "Ivits", + "total": 0, + "weightedTotal": null + }, + "Gleens (0)": { + "label": "Gleens", + "total": 0, + "weightedTotal": null + } + } + }, + "labels": [ + "3 VP / federation when passing", + "2 VP when researching", + "q,5c special action", + "2 VP / mine", + "3 VP / lab when passing", + "1 ore / sector", + "1 VP / planet type when passing", + "2 VP / gaia planet", + "4 VP / ts", + "2 VP / sector", + "3o special action", + "5 VP / federation", + "3k special action", + "3 VP when building a mine", + "3 VP when building ts" + ], + "datasets": [ + { + "label": "Bal T'aks (1)", + "data": [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "label": "Ivits (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "label": "Gleens (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ] +} diff --git a/viewer/src/logic/chartTests/adv-tech-pass/test-case.json b/viewer/src/logic/chartTests/adv-tech-pass/test-case.json index 7304aa06..72717924 100644 --- a/viewer/src/logic/chartTests/adv-tech-pass/test-case.json +++ b/viewer/src/logic/chartTests/adv-tech-pass/test-case.json @@ -1,5 +1,5 @@ { - "families": ["Victory Points"], + "families": ["Victory Points", "Advanced Tech Tiles"], "options": { "layout": "standard" }, diff --git a/viewer/src/logic/chartTests/all-families/advanced-tech-tiles.json b/viewer/src/logic/chartTests/all-families/advanced-tech-tiles.json new file mode 100644 index 00000000..7de2f52a --- /dev/null +++ b/viewer/src/logic/chartTests/all-families/advanced-tech-tiles.json @@ -0,0 +1,109 @@ +{ + "tableMeta": { + "weights": null, + "colors": [ + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + }, + { + "color": "--tech-tile" + } + ], + "datasetMeta": { + "Itars (0)": { + "label": "Itars", + "total": 0, + "weightedTotal": null + }, + "Geoden (0)": { + "label": "Geoden", + "total": 0, + "weightedTotal": null + }, + "Taklons (0)": { + "label": "Taklons", + "total": 0, + "weightedTotal": null + }, + "Lantids (0)": { + "label": "Lantids", + "total": 0, + "weightedTotal": null + } + } + }, + "labels": [ + "3 VP / federation when passing", + "2 VP when researching", + "q,5c special action", + "2 VP / mine", + "3 VP / lab when passing", + "1 ore / sector", + "1 VP / planet type when passing", + "2 VP / gaia planet", + "4 VP / ts", + "2 VP / sector", + "3o special action", + "5 VP / federation", + "3k special action", + "3 VP when building a mine", + "3 VP when building ts" + ], + "datasets": [ + { + "label": "Itars (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "label": "Geoden (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "label": "Taklons (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + }, + { + "label": "Lantids (0)", + "data": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + } + ] +} diff --git a/viewer/src/logic/charts.ts b/viewer/src/logic/charts.ts index 12b23048..0f73276d 100644 --- a/viewer/src/logic/charts.ts +++ b/viewer/src/logic/charts.ts @@ -71,6 +71,17 @@ export function extractChanges(player: PlayerEnum, extractChange: EventFilter): }; } +function lastIndex(includeRounds: IncludeRounds): number { + switch (includeRounds) { + case "last": + return 0; + case "except-final": + return 6; + case "all": + return 7; + } +} + export function getDataPoints( data: Engine, initialValue: number, @@ -110,12 +121,8 @@ export function getDataPoints( if (projectedEndValue != null) { counter += projectedEndValue(); } - if (includeRounds === "all") { - perRoundData[7] = counter; - } else if (includeRounds === "last") { - perRoundData[0] = counter; - } + perRoundData[lastIndex(includeRounds)] = counter; return perRoundData; } @@ -194,7 +201,8 @@ export function weightedSum(data: Engine, player: PlayerEnum, factories: Dataset export function initialResearch(player: Player) { const research = new Map(); - factionBoard(player.faction, player.factionVariant).income[0].rewards.forEach((r) => { + const board = player.board ?? factionBoard(player.faction, player.factionVariant); + board.income[0].rewards.forEach((r) => { if (r.type.startsWith("up-")) { research.set(r.type.slice(3) as ResearchField, 1); } diff --git a/viewer/src/logic/final-scoring.ts b/viewer/src/logic/final-scoring.ts index d0d299bc..8ac43462 100644 --- a/viewer/src/logic/final-scoring.ts +++ b/viewer/src/logic/final-scoring.ts @@ -222,8 +222,13 @@ const finalScoringTableRows: FinalScoringTableRow[] = Object.keys(finalScoringSo }, { name: "(Power value for federations)", - color: "--res-power", + color: "--res-knowledge", contributors: ["Regular Building", "Lost Planet", "Space Station", "Lantids Guest Mine"], + }, + { + name: "(Other players can charge power)", + color: "--res-power", + contributors: ["Regular Building", "Lost Planet", "Lantids Guest Mine"], } ); diff --git a/viewer/src/logic/simple-charts.ts b/viewer/src/logic/simple-charts.ts index 3a37237a..408c4bc6 100644 --- a/viewer/src/logic/simple-charts.ts +++ b/viewer/src/logic/simple-charts.ts @@ -1,4 +1,6 @@ import Engine, { + AdvTechTile, + AdvTechTilePos, BoardAction, Booster, Building, @@ -25,7 +27,7 @@ import { federationData } from "../data/federations"; import { planetNames } from "../data/planets"; import { researchNames } from "../data/research"; import { resourceNames } from "../data/resources"; -import { baseTechTileNames } from "../data/tech-tiles"; +import { advancedTechTileNames, baseTechTileNames } from "../data/tech-tiles"; import { ChartColor, ChartFamily, @@ -184,6 +186,18 @@ const freeActionSources = resourceSources weight: 0, }); +const techTileExtractLog: ExtractLog> = statelessExtractLog((e) => { + if (e.cmd.command == Command.ChooseTechTile) { + const pos = e.cmd.args[0] as TechTilePos | AdvTechTilePos; + const tile = e.data.tiles.techs[pos].tile; + + if (tile == e.source.type) { + return 1; + } + } + return 0; +}); + const factories = [ { name: "Resources", @@ -397,17 +411,7 @@ const factories = [ name: "Base Tech Tiles", showWeightedTotal: false, playerSummaryLineChartTitle: "Base Tech tiles of all players", - extractLog: statelessExtractLog((e) => { - if (e.cmd.command == Command.ChooseTechTile) { - const pos = e.cmd.args[0] as TechTilePos; - const tile = e.data.tiles.techs[pos].tile; - - if (tile == e.source.type) { - return 1; - } - } - return 0; - }), + extractLog: techTileExtractLog, sources: TechTile.values().map((t) => ({ type: t, label: baseTechTileNames[t].name, @@ -415,6 +419,18 @@ const factories = [ weight: 1, })), } as SimpleSourceFactory>, + { + name: "Advanced Tech Tiles", + showWeightedTotal: false, + playerSummaryLineChartTitle: "Advanced Tech tiles of all players", + extractLog: techTileExtractLog, + sources: AdvTechTile.values().map((t) => ({ + type: t, + label: advancedTechTileNames[t], + color: "--tech-tile", + weight: 1, + })), + } as SimpleSourceFactory>, { name: "Final Scoring Conditions", showWeightedTotal: false, diff --git a/viewer/src/self-contained.ts b/viewer/src/self-contained.ts index 5bad89e2..26a30b6e 100644 --- a/viewer/src/self-contained.ts +++ b/viewer/src/self-contained.ts @@ -25,7 +25,7 @@ function launchSelfContained(selector = "#app", debug = true) { const unsub = emitter.store.subscribeAction(({ payload, type }) => { if (type === "gaiaViewer/loadFromJSON") { const egData: Engine = payload; - engine = new Engine(egData.moveHistory, egData.options); + engine = new Engine(egData.moveHistory, egData.options, null, true); engine.generateAvailableCommandsIfNeeded(); emitter.emit("state", JSON.parse(JSON.stringify(engine))); }