Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1519c97

Browse files
committedAug 17, 2021
✨ (all) add batch module for analyzing and replaying games
1 parent 6d78e1a commit 1519c97

9 files changed

+970
-0
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
node_modules
2+
batch/replay/
3+
batch/dist/

‎batch/package.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@gaia-project/batch",
3+
"version": "0.1",
4+
"description": "Extracts statistics from games",
5+
"type": "commonjs",
6+
"contributors": [
7+
"zeitlinger"
8+
],
9+
"repository": "git@github.com:boardgamers-mono/gaia-project.git",
10+
"scripts": {
11+
"build": "tsc -p .",
12+
"stats": "ts-node src/stats.ts",
13+
"replay": "ts-node src/replay.ts"
14+
},
15+
"dependencies": {
16+
"@gaia-project/engine": "workspace:../viewer",
17+
"csv-writer": "^1.6.0",
18+
"lodash": "^4.17.15",
19+
"mongoose": "^5.9.10"
20+
},
21+
"license": "MIT",
22+
"devDependencies": {
23+
"@types/node": "^16.4.13",
24+
"ts-node": "^10.1.0",
25+
"typescript": "^4.3.5"
26+
}
27+
}

‎batch/pnpm-lock.yaml

+341
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎batch/src/game.ts

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import mongoose, { Schema, Types } from "mongoose";
2+
3+
// all in this file copied from boardgamers-mono
4+
5+
export interface PlayerInfo<T = string> {
6+
_id: T;
7+
remainingTime: number;
8+
score: number;
9+
dropped: boolean;
10+
// Not dropped but quit after someone else dropped
11+
quit: boolean;
12+
name: string;
13+
faction?: string;
14+
voteCancel?: boolean;
15+
ranking?: number;
16+
elo?: {
17+
initial?: number;
18+
delta?: number;
19+
};
20+
}
21+
22+
export interface IAbstractGame<T = string, Game = any, GameOptions = any> {
23+
/** Ids of the players in the website */
24+
players: PlayerInfo<T>[];
25+
creator: T;
26+
27+
currentPlayers?: Array<{
28+
_id: T;
29+
timerStart: Date;
30+
deadline: Date;
31+
}>;
32+
33+
/** Game data */
34+
data: Game;
35+
36+
context: {
37+
round: number;
38+
};
39+
40+
options: {
41+
setup: {
42+
seed: string;
43+
nbPlayers: number;
44+
randomPlayerOrder: boolean;
45+
};
46+
timing: {
47+
timePerGame: number;
48+
timePerMove: number;
49+
/* UTC-based time of play, by default all day, during which the timer is active, in seconds */
50+
timer: {
51+
// eg 3600 = start at 1 am
52+
start: number;
53+
// eg 3600*23 = end at 11 pm
54+
end: number;
55+
};
56+
// The game will be cancelled if the game isn't full at this time
57+
scheduledStart: Date;
58+
};
59+
meta: {
60+
unlisted: boolean;
61+
minimumKarma: number;
62+
};
63+
};
64+
65+
game: {
66+
name: string; // e.g. "gaia-project"
67+
version: number; // e.g. 1
68+
expansions: string[]; // e.g. ["spaceships"]
69+
70+
options: GameOptions;
71+
};
72+
73+
status: "open" | "pending" | "active" | "ended";
74+
cancelled: boolean;
75+
76+
updatedAt: Date;
77+
createdAt: Date;
78+
lastMove: Date;
79+
}
80+
81+
const repr = {
82+
_id: {
83+
type: String,
84+
trim: true,
85+
minlength: [2, "A game id must be at least 2 characters"] as [number, string],
86+
maxlength: [25, "A game id must be at most 25 characters"] as [number, string],
87+
},
88+
players: {
89+
type: [
90+
{
91+
_id: {
92+
type: Schema.Types.ObjectId,
93+
ref: "User",
94+
index: true,
95+
},
96+
97+
name: String,
98+
remainingTime: Number,
99+
score: Number,
100+
dropped: Boolean,
101+
quit: Boolean,
102+
faction: String,
103+
voteCancel: Boolean,
104+
ranking: Number,
105+
elo: {
106+
initial: Number,
107+
delta: Number,
108+
},
109+
},
110+
],
111+
default: () => [],
112+
},
113+
creator: {
114+
type: Schema.Types.ObjectId,
115+
index: true,
116+
},
117+
currentPlayers: [
118+
{
119+
_id: {
120+
type: Schema.Types.ObjectId,
121+
ref: "User",
122+
index: true,
123+
},
124+
deadline: {
125+
type: Date,
126+
index: true,
127+
},
128+
timerStart: Date,
129+
},
130+
],
131+
lastMove: {
132+
type: Date,
133+
index: true,
134+
},
135+
createdAt: {
136+
type: Date,
137+
index: true,
138+
},
139+
updatedAt: {
140+
type: Date,
141+
index: true,
142+
},
143+
data: {},
144+
status: {
145+
type: String,
146+
enum: ["open", "pending", "active", "ended"],
147+
default: "open",
148+
},
149+
cancelled: {
150+
type: Boolean,
151+
default: false,
152+
},
153+
options: {
154+
setup: {
155+
randomPlayerOrder: {
156+
type: Boolean,
157+
default: true,
158+
},
159+
nbPlayers: {
160+
type: Number,
161+
default: 2,
162+
},
163+
seed: {
164+
//this is the name
165+
type: String,
166+
trim: true,
167+
minlength: [2, "A game seed must be at least 2 characters"] as [number, string],
168+
maxlength: [25, "A game seed must be at most 25 characters"] as [number, string],
169+
},
170+
},
171+
timing: {
172+
timePerMove: {
173+
type: Number,
174+
default: 15 * 60,
175+
min: 0,
176+
max: 24 * 3600,
177+
},
178+
timePerGame: {
179+
type: Number,
180+
default: 15 * 24 * 3600,
181+
min: 60,
182+
max: 15 * 24 * 3600,
183+
// enum: [1 * 3600, 24 * 3600, 3 * 24 * 3600, 15 * 24 * 3600]
184+
},
185+
timer: {
186+
start: {
187+
type: Number,
188+
min: 0,
189+
max: 24 * 3600 - 1,
190+
},
191+
end: {
192+
type: Number,
193+
min: 0,
194+
max: 24 * 3600 - 1,
195+
},
196+
},
197+
scheduledStart: Date,
198+
},
199+
meta: {
200+
unlisted: Boolean,
201+
minimumKarma: Number,
202+
},
203+
},
204+
205+
context: {
206+
round: Number,
207+
},
208+
209+
game: {
210+
name: String,
211+
version: Number,
212+
expansions: [String],
213+
214+
options: {},
215+
},
216+
};
217+
218+
const schema = new Schema<GameDocument, mongoose.Model<GameDocument>>(repr);
219+
220+
export interface GameDocument extends mongoose.Document, IAbstractGame<Types.ObjectId> {
221+
_id: string;
222+
}
223+
224+
export const Game = mongoose.model("Game", schema);

‎batch/src/replay.ts

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import * as fs from "fs";
2+
import * as process from "process";
3+
import Engine from "../../engine";
4+
import { replay } from "../../engine/wrapper";
5+
import { Game, GameDocument } from "./game";
6+
import { connectMongo, shouldReplay } from "./util";
7+
8+
const engineVersion = new Engine().version;
9+
10+
async function main() {
11+
let success = 0;
12+
let errors = 0;
13+
let replayed = 0;
14+
let cancelled = 0;
15+
let active = 0;
16+
let expansion = 0;
17+
18+
let progress = 0;
19+
20+
const outcomes = () => ({
21+
success,
22+
errors,
23+
replayed,
24+
cancelled,
25+
active,
26+
expansion,
27+
});
28+
29+
async function process(game: GameDocument) {
30+
progress++;
31+
if (progress % 10 == 0) {
32+
console.log("progress", progress);
33+
}
34+
35+
if (game.cancelled) {
36+
cancelled++;
37+
return;
38+
}
39+
if (game.status !== "ended") {
40+
active++;
41+
return;
42+
}
43+
44+
if (game.game.expansions.length > 0) {
45+
expansion++;
46+
return;
47+
}
48+
49+
if (!shouldReplay(game)) {
50+
success++;
51+
return;
52+
}
53+
let data = game.data as Engine;
54+
55+
if (shouldReplay(game)) {
56+
const file = `replay/${game._id}.json`;
57+
if (!fs.existsSync(file)) {
58+
console.log("replay " + game._id);
59+
data = await replay(data);
60+
data.replayVersion = engineVersion;
61+
const oldPlayers = game.players;
62+
for (let i = 0; i < oldPlayers.length && i < oldPlayers.length; i++) {
63+
data.players[i].name = oldPlayers[i].name;
64+
data.players[i].dropped = oldPlayers[i].dropped;
65+
}
66+
67+
game.data = data;
68+
fs.writeFileSync(file, JSON.stringify(game.toJSON()), { encoding: "utf8" });
69+
}
70+
}
71+
72+
replayed++;
73+
}
74+
75+
connectMongo();
76+
77+
// .where("_id").equals("Costly-amount-263") //for testing
78+
for await (const game of Game.find().where("game.name").equals("gaia-project")) {
79+
try {
80+
await process(game);
81+
} catch (e) {
82+
console.log(game._id);
83+
// console.log(JSON.stringify(game));
84+
console.log(e);
85+
errors++;
86+
}
87+
}
88+
89+
console.log(outcomes());
90+
}
91+
92+
const start = new Date();
93+
main().then(() => {
94+
console.log("done");
95+
console.log(new Date().getTime() - start.getTime());
96+
process.exit(0);
97+
});

‎batch/src/stats.ts

+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { createObjectCsvWriter } from "csv-writer";
2+
import * as fs from "fs";
3+
import { sortBy, sumBy } from "lodash";
4+
import * as process from "process";
5+
import Engine, { Booster, Command, Player, roundScorings } from "../../engine";
6+
import { boosterNames } from "../../viewer/src/data/boosters";
7+
import { advancedTechTileNames, baseTechTileNames } from "../../viewer/src/data/tech-tiles";
8+
import { chartFactory, families } from "../../viewer/src/logic/chart-factory";
9+
import { parsedMove } from "../../viewer/src/logic/recent";
10+
import { Game, GameDocument, PlayerInfo } from "./game";
11+
import { connectMongo, shouldReplay } from "./util";
12+
13+
const fam = families().filter((f) => f != "Final Scoring Conditions" && f != "Federations");
14+
15+
function getDetailStats(commonProps: any, data: Engine, pl: Player) {
16+
const newDetailRow = (round: any) => {
17+
const d = {
18+
round: round,
19+
};
20+
Object.assign(d, commonProps);
21+
return d;
22+
};
23+
24+
const rows: any[] = [];
25+
26+
for (let family of fam) {
27+
const f = chartFactory(family);
28+
const sources = f.sources(family);
29+
30+
const details = f.newDetails(data, pl.player, sources, "except-final", family, false);
31+
for (let detail of details) {
32+
const dataPoints = detail.getDataPoints();
33+
if (rows.length == 0) {
34+
for (let round = 0; round < dataPoints.length; round++) {
35+
rows.push(newDetailRow(round));
36+
}
37+
rows.push(newDetailRow("total"));
38+
}
39+
const key = `${family} - ${detail.label}`;
40+
let last = 0;
41+
dataPoints.forEach((value, index) => {
42+
rows[index][key] = value - last;
43+
last = value;
44+
});
45+
rows[dataPoints.length][key] = dataPoints[dataPoints.length - 1];
46+
}
47+
}
48+
return rows;
49+
}
50+
51+
function getGameStats(pl: Player, outerPlayer: PlayerInfo<any>, data: Engine, game: GameDocument, commonProps: any) {
52+
const playerProp = (key: string, def: any) => (key in outerPlayer ? outerPlayer[key] ?? def : def);
53+
const rank = sortBy(data.players, (p: Player) => -p.data.victoryPoints);
54+
const rankWithoutBid = sortBy(data.players, (p: Player) => -(p.data.victoryPoints + (p.data.bid ?? 0))); // bid is positive
55+
56+
const row = {
57+
initialTurnOrder: pl.player + 1,
58+
version: data.version ?? "1.0.0",
59+
players: data.players.length,
60+
started: game.createdAt?.toISOString(),
61+
ended: game.lastMove?.toISOString(),
62+
variant: data.options.factionVariant,
63+
auction: data.options.auction,
64+
layout: data.options.layout,
65+
randomFactions: data.options.randomFactions ?? false,
66+
rotateSectors: !data.options.advancedRules,
67+
rank: rank.indexOf(pl) + 1,
68+
rankWithoutBid: rankWithoutBid.indexOf(pl) + 1,
69+
playerDropped: playerProp("dropped", false),
70+
playerQuit: playerProp("quit", false),
71+
};
72+
73+
Object.assign(row, commonProps);
74+
75+
for (let pos in data.tiles.techs) {
76+
if (pos === "move") {
77+
continue;
78+
}
79+
const tech = data.tiles.techs[pos].tile;
80+
row[`tech-${pos}`] = advancedTechTileNames[tech] ?? baseTechTileNames[tech].name;
81+
}
82+
83+
row["finalA"] = data.tiles.scorings.final[0];
84+
row["finalB"] = data.tiles.scorings.final[1];
85+
86+
data.tiles.scorings.round.forEach((tile, index) => {
87+
row[`roundScoring${index + 1}`] = roundScorings[tile][0];
88+
});
89+
90+
for (let booster of Booster.values()) {
91+
row[`booster ${boosterNames[booster].name}`] = data.tiles.boosters[booster] ? 1 : 0;
92+
}
93+
94+
let i = 1;
95+
for (const move of data.moveHistory) {
96+
const command = parsedMove(move).commands[0];
97+
if (command.command == Command.Build && command.faction === pl.faction) {
98+
const hex = data.map.getS(command.args[1]);
99+
// data.map.distance()
100+
row[`startPosition${i}`] = hex.toString();
101+
row[`startPositionDistance${i}`] = (Math.abs(hex.q) + Math.abs(hex.r) + Math.abs(-hex.q - hex.r)) / 2;
102+
i++;
103+
} else if (command.command == Command.ChooseRoundBooster) {
104+
break;
105+
}
106+
}
107+
108+
for (; i < 4; i++) {
109+
//so that all columns are filled to get the correct headers
110+
row[`startPosition${i}`] = "";
111+
row[`startPositionDistance${i}`] = "";
112+
}
113+
114+
return row;
115+
}
116+
117+
function getStats(game: GameDocument, data: Engine): { game: any[]; detail: any[] } {
118+
const avgElo =
119+
sumBy(game.players, (p: PlayerInfo) => (p.elo?.initial ?? 0) + (p.elo?.delta ?? 0)) / game.players.length;
120+
121+
return data.players
122+
.flatMap((pl) => {
123+
const outerPlayer = game.players.find((p) => p.faction === pl.faction);
124+
125+
const commonProps = {
126+
id: game._id,
127+
player: outerPlayer.name,
128+
faction: pl.faction,
129+
score: outerPlayer.score,
130+
scoreWithoutBid: outerPlayer.score + (pl.data.bid ?? 0), // bid is positive
131+
eloInitial: outerPlayer.elo?.initial,
132+
eloDelta: outerPlayer.elo?.delta,
133+
averageElo: avgElo,
134+
};
135+
const gameRow = getGameStats(pl, outerPlayer, data, game, commonProps);
136+
return { game: [gameRow], detail: getDetailStats(commonProps, data, pl) };
137+
})
138+
.reduce((a, b) => {
139+
a.game.push(...b.game);
140+
a.detail.push(...b.detail);
141+
return a;
142+
});
143+
}
144+
145+
async function main() {
146+
let success = 0;
147+
let errors = 0;
148+
let skipReplay = 0;
149+
let cancelled = 0;
150+
let active = 0;
151+
let expansion = 0;
152+
153+
let progress = 0;
154+
155+
const outcomes = () => ({
156+
success,
157+
errors,
158+
skipReplay,
159+
cancelled,
160+
active,
161+
expansion,
162+
});
163+
164+
let gameWriter = null;
165+
let detailWriter = null;
166+
167+
async function process(game: GameDocument) {
168+
progress++;
169+
if (progress % 10 == 0) {
170+
console.log("progress", progress);
171+
}
172+
173+
if (game.cancelled) {
174+
cancelled++;
175+
return;
176+
}
177+
if (game.status !== "ended") {
178+
active++;
179+
return;
180+
}
181+
182+
if (game.game.expansions.length > 0) {
183+
expansion++;
184+
return;
185+
}
186+
187+
let data: Engine;
188+
189+
if (shouldReplay(game)) {
190+
const file = `replay/${game._id}.json`;
191+
if (fs.existsSync(file)) {
192+
data = Engine.fromData(JSON.parse(fs.readFileSync(file, { encoding: "utf8" })));
193+
} else {
194+
skipReplay++;
195+
return;
196+
}
197+
} else {
198+
data = Engine.fromData(game.data);
199+
}
200+
201+
const stats = getStats(game, data);
202+
203+
if (gameWriter == null) {
204+
gameWriter = createObjectCsvWriter({
205+
path: "gaia-stats-game.csv",
206+
header: Object.keys(stats.game[0]).map((k) => ({ id: k, title: k })),
207+
});
208+
detailWriter = createObjectCsvWriter({
209+
path: "gaia-stats-turns.csv",
210+
header: Object.keys(stats.detail[0]).map((k) => ({ id: k, title: k })),
211+
});
212+
}
213+
214+
await gameWriter.writeRecords(stats.game);
215+
await detailWriter.writeRecords(stats.detail);
216+
success++;
217+
}
218+
219+
connectMongo();
220+
221+
for await (const game of Game.find().where("game.name").equals("gaia-project")) {
222+
try {
223+
await process(game);
224+
} catch (e) {
225+
console.log(game._id);
226+
// console.log(JSON.stringify(game));
227+
console.log(e);
228+
errors++;
229+
}
230+
}
231+
232+
console.log(outcomes());
233+
}
234+
235+
const start = new Date();
236+
main().then(() => {
237+
console.log("done");
238+
console.log(new Date().getTime() - start.getTime());
239+
process.exit(0);
240+
});

‎batch/src/util.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import mongoose from "mongoose";
2+
import Engine from "../../engine";
3+
import { GameDocument } from "./game";
4+
5+
export function connectMongo() {
6+
mongoose.connect("mongodb://127.0.0.1:27017", { dbName: "test", useNewUrlParser: true });
7+
8+
mongoose.connection.on("error", (err) => {
9+
console.error(err);
10+
});
11+
12+
mongoose.connection.on("open", async () => {
13+
console.log("connected to database!");
14+
});
15+
}
16+
17+
export function shouldReplay(game: GameDocument) {
18+
const data = game.data as Engine;
19+
return game.options.setup.nbPlayers != data.players.length || !data.advancedLog?.length;
20+
}

‎batch/tsconfig.json

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"compilerOptions": {
3+
/* Basic Options */
4+
"target": "es2019",
5+
"module": "CommonJS",
6+
"moduleResolution": "node",
7+
"sourceMap": true,
8+
"outDir": "./dist",
9+
"baseUrl": ".",
10+
"experimentalDecorators": true,
11+
"emitDecoratorMetadata": true,
12+
"allowSyntheticDefaultImports": true,
13+
"esModuleInterop": true,
14+
"resolveJsonModule": true
15+
},
16+
"include": ["src/**/*.ts", "src/*.ts"],
17+
"exclude": ["node_modules"]
18+
}

‎pnpm-workspace.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ packages:
22
- "viewer"
33
- "engine"
44
- "old-ui"
5+
- "batch"

0 commit comments

Comments
 (0)
Please sign in to comment.