From 5aa835651345cd275f07b7a885cf5b71a13f9a43 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 00:10:29 -0400 Subject: [PATCH] Record player stats during the game (#784) ## Description: Record player stats for the analytics worker to import in to to postgres. This changes defines a new Analytics schema version, `v0.0.2`, containing additional metadata about each player. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors --- src/client/ClientGameRunner.ts | 2 +- src/client/LocalServer.ts | 2 +- src/client/graphics/layers/NameLayer.ts | 4 +- src/client/graphics/layers/PlayerPanel.ts | 40 +-- src/core/ApiSchemas.ts | 4 + src/core/ArchiveSchemas.ts | 100 ++++++++ src/core/Schemas.ts | 21 +- src/core/Util.ts | 30 ++- src/core/execution/AttackExecution.ts | 9 +- src/core/execution/MIRVExecution.ts | 11 +- src/core/execution/NukeExecution.ts | 28 ++- src/core/execution/PlayerExecution.ts | 15 +- src/core/execution/SAMMissileExecution.ts | 12 +- src/core/execution/ShellExecution.ts | 2 +- src/core/execution/TradeShipExecution.ts | 14 +- src/core/execution/TransportShipExecution.ts | 13 + src/core/game/Game.ts | 6 +- src/core/game/GameImpl.ts | 6 +- src/core/game/GameUpdates.ts | 3 +- src/core/game/GameView.ts | 5 +- src/core/game/PlayerImpl.ts | 5 +- src/core/game/Stats.ts | 83 ++++++- src/core/game/StatsImpl.ts | 244 +++++++++++++++++-- src/core/game/UnitImpl.ts | 54 +++- src/server/Archive.ts | 1 + src/server/GameServer.ts | 26 +- src/server/Logger.ts | 2 +- 27 files changed, 581 insertions(+), 161 deletions(-) create mode 100644 src/core/ArchiveSchemas.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index cd4233b0d..df9a97a19 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -193,6 +193,7 @@ export class ClientGameRunner { persistentID: getPersistentIDFromCookie(), username: this.lobby.playerName, clientID: this.lobby.clientID, + stats: update.allPlayersStats[this.lobby.clientID], }, ]; let winner: ClientID | Team | null = null; @@ -217,7 +218,6 @@ export class ClientGameRunner { Date.now(), winner, update.winnerType, - update.allPlayersStats, ); endGame(record); } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 5c4f826e4..93f910433 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -180,6 +180,7 @@ export class LocalServer { persistentID: getPersistentIDFromCookie(), username: this.lobbyConfig.playerName, clientID: this.lobbyConfig.clientID, + stats: this.allPlayersStats[this.lobbyConfig.clientID], }, ]; if (this.lobbyConfig.gameStartInfo === undefined) { @@ -194,7 +195,6 @@ export class LocalServer { Date.now(), this.winner?.winner ?? null, this.winner?.winnerType ?? null, - this.allPlayersStats, ); if (!saveFullGame) { // Clear turns because beacon only supports up to 64kb diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 8449c551f..32d924fcc 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -10,7 +10,7 @@ import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { ClientID } from "../../../core/Schemas"; import { Theme } from "../../../core/configuration/Config"; -import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game"; +import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { createCanvas, renderNumber, renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; @@ -450,7 +450,7 @@ export class NameLayer implements Layer { const isSendingNuke = render.player.id() === unit.owner().id(); const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id(); return ( - nukeTypes.includes(unit.type()) && + (nukeTypes as UnitType[]).includes(unit.type()) && isSendingNuke && notMyPlayer && unit.isActive() diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index c37659e78..a8b773b15 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; @@ -9,12 +9,7 @@ import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; import { translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; -import { - AllPlayers, - PlayerActions, - PlayerID, - UnitType, -} from "../../../core/game/Game"; +import { AllPlayers, PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { flattenedEmojiTable } from "../../../core/Util"; @@ -205,28 +200,6 @@ export class PlayerPanel extends LitElement implements Layer { return time.trim(); } - getTotalNukesSent(otherId: PlayerID): number { - const stats = this.g.player(otherId).stats(); - if (!stats) { - return 0; - } - let sum = 0; - const player = this.g.myPlayer(); - if (player === null) { - return 0; - } - const nukes = stats.sentNukes[player.id()]; - if (!nukes) { - return 0; - } - for (const nukeType in nukes) { - if (nukeType !== UnitType.MIRVWarhead) { - sum += nukes[nukeType]; - } - } - return sum; - } - render() { if (!this.isVisible) { return html``; @@ -353,15 +326,6 @@ export class PlayerPanel extends LitElement implements Layer { ` : ""} - -
-
- ${translateText("player_panel.nuke")} -
-
- ${this.getTotalNukesSent(other.id())} -
-
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 81637e8cd..1c594e1a7 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -44,5 +44,9 @@ export const UserMeResponseSchema = z.object({ discriminator: z.string(), locale: z.string(), }), + player: z.object({ + publicId: z.string(), + roles: z.string().array(), + }), }); export type UserMeResponse = z.infer; diff --git a/src/core/ArchiveSchemas.ts b/src/core/ArchiveSchemas.ts new file mode 100644 index 000000000..d7357e315 --- /dev/null +++ b/src/core/ArchiveSchemas.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { UnitType } from "./game/Game"; + +export const BombUnitSchema = z.union([ + z.literal("abomb"), + z.literal("hbomb"), + z.literal("mirv"), + z.literal("mirvw"), +]); +export type BombUnit = z.infer; +export type NukeType = + | UnitType.AtomBomb + | UnitType.HydrogenBomb + | UnitType.MIRV + | UnitType.MIRVWarhead; + +export const unitTypeToBombUnit = { + [UnitType.AtomBomb]: "abomb", + [UnitType.HydrogenBomb]: "hbomb", + [UnitType.MIRV]: "mirv", + [UnitType.MIRVWarhead]: "mirvw", +} as const satisfies Record; + +export const BoatUnitSchema = z.union([z.literal("trade"), z.literal("trans")]); +export type BoatUnit = z.infer; +export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip; + +// export const unitTypeToBoatUnit = { +// [UnitType.TradeShip]: "trade", +// [UnitType.TransportShip]: "trans", +// } as const satisfies Record; + +export const OtherUnitSchema = z.union([ + z.literal("city"), + z.literal("defp"), + z.literal("port"), + z.literal("wshp"), + z.literal("silo"), + z.literal("saml"), +]); +export type OtherUnit = z.infer; +export type OtherUnitType = + | UnitType.City + | UnitType.DefensePost + | UnitType.MissileSilo + | UnitType.Port + | UnitType.SAMLauncher + | UnitType.Warship; + +export const unitTypeToOtherUnit = { + [UnitType.City]: "city", + [UnitType.DefensePost]: "defp", + [UnitType.MissileSilo]: "silo", + [UnitType.Port]: "port", + [UnitType.SAMLauncher]: "saml", + [UnitType.Warship]: "wshp", +} as const satisfies Record; + +// Attacks +export const ATTACK_INDEX_SENT = 0; // Outgoing attack troops +export const ATTACK_INDEX_RECV = 1; // Incmoing attack troops +export const ATTACK_INDEX_CANCEL = 2; // Cancelled attack troops + +// Boats +export const BOAT_INDEX_SENT = 0; // Boats launched +export const BOAT_INDEX_ARRIVE = 1; // Boats arrived +export const BOAT_INDEX_CAPTURE = 2; // Boats captured +export const BOAT_INDEX_DESTROY = 3; // Boats destroyed + +// Bombs +export const BOMB_INDEX_LAUNCH = 0; // Bombs launched +export const BOMB_INDEX_LAND = 1; // Bombs landed +export const BOMB_INDEX_INTERCEPT = 2; // Bombs intercepted + +// Gold +export const GOLD_INDEX_WORK = 0; // Gold earned by workers +export const GOLD_INDEX_WAR = 1; // Gold earned by conquering players +export const GOLD_INDEX_TRADE = 2; // Gold earned by trade ships +export const GOLD_INDEX_STEAL = 3; // Gold earned by capturing trade ships + +// Other Units +export const OTHER_INDEX_BUILT = 0; // Structures and warships built +export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed +export const OTHER_INDEX_CAPTURE = 2; // Structures captured +export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others + +const AtLeastOneNumberSchema = z.number().array().min(1); +export type AtLeastOneNumber = z.infer; + +export const PlayerStatsSchema = z + .object({ + attacks: AtLeastOneNumberSchema.optional(), + betrayals: z.number().positive().optional(), + boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(), + bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(), + gold: AtLeastOneNumberSchema.optional(), + units: z.record(OtherUnitSchema, AtLeastOneNumberSchema).optional(), + }) + .optional(); +export type PlayerStats = z.infer; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c6d46a0b3..1a334b6d0 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import quickChatData from "../../resources/QuickChat.json" with { type: "json" }; +import { PlayerStatsSchema } from "./ArchiveSchemas"; import { AllPlayers, Difficulty, @@ -91,7 +92,6 @@ export type PlayerRecord = z.infer; export type GameRecord = z.infer; export type AllPlayersStats = z.infer; -export type PlayerStats = z.infer; export type Player = z.infer; export type GameStartInfo = z.infer; const PlayerTypeSchema = z.nativeEnum(PlayerType); @@ -176,19 +176,6 @@ const ID = z .regex(/^[a-zA-Z0-9]+$/) .length(8); -const NukesEnum = z.enum([ - "Atom Bomb", - "Hydrogen Bomb", - "MIRV", - "MIRV Warhead", -]); - -const NukeStatsSchema = z.record(NukesEnum, z.number()); - -export const PlayerStatsSchema = z.object({ - sentNukes: z.record(ID, NukeStatsSchema), -}); - export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); // Zod schemas @@ -460,6 +447,7 @@ export const PlayerRecordSchema = z.object({ username: SafeString, ip: SafeString.nullable(), // WARNING: PII persistentID: PersistentIdSchema, // WARNING: PII + stats: PlayerStatsSchema, }); export const GameRecordSchema = z.object({ @@ -474,7 +462,6 @@ export const GameRecordSchema = z.object({ turns: z.array(TurnSchema), winner: z.union([ID, SafeString]).nullable().optional(), winnerType: z.enum(["player", "team"]).nullable().optional(), - allPlayersStats: z.record(ID, PlayerStatsSchema), - version: z.enum(["v0.0.1"]), - gitCommit: z.string().nullable().optional(), + version: z.literal("v0.0.2"), + gitCommit: z.string(), }); diff --git a/src/core/Util.ts b/src/core/Util.ts index fadbd68ea..a793d8742 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -4,7 +4,6 @@ import twemoji from "twemoji"; import { Cell, Team, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { - AllPlayersStats, ClientID, GameID, GameRecord, @@ -186,28 +185,33 @@ export function onlyImages(html: string) { export function createGameRecord( id: GameID, - gameStart: GameStartInfo, + gameStartInfo: GameStartInfo, // username does not need to be set. players: PlayerRecord[], turns: Turn[], - start: number, - end: number, + startTimestampMS: number, + endTimestampMS: number, winner: ClientID | Team | null, winnerType: "player" | "team" | null, - allPlayersStats: AllPlayersStats, ): GameRecord { + const durationSeconds = Math.floor( + (endTimestampMS - startTimestampMS) / 1000, + ); + const date = new Date().toISOString().split("T")[0]; + const version = "v0.0.2"; + const gitCommit = ""; const record: GameRecord = { - id: id, - gameStartInfo: gameStart, + gitCommit, + id, + gameStartInfo, players, - startTimestampMS: start, - endTimestampMS: end, - durationSeconds: Math.floor((end - start) / 1000), - date: new Date().toISOString().split("T")[0], + startTimestampMS, + endTimestampMS, + durationSeconds, + date, num_turns: 0, turns: [], - allPlayersStats, - version: "v0.0.1", + version, winner, winnerType, }; diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 29e456405..d095d1a21 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -116,6 +116,9 @@ export class AttackExecution implements Execution { this.sourceTile, ); + // Record stats + this.mg.stats().attack(this._owner, this.target, this.startTroops); + for (const incoming of this._owner.incomingAttacks()) { if (incoming.attacker() === this.target) { // Target has opposing attack, cancel them out @@ -180,9 +183,13 @@ export class AttackExecution implements Execution { this._owner.id(), ); } - this._owner.addTroops(this.attack.troops() - deaths); + const survivors = this.attack.troops() - deaths; + this._owner.addTroops(survivors); this.attack.delete(); this.active = false; + + // Record stats + this.mg.stats().attackCancel(this._owner, this.target, survivors); } tick(ticks: number) { diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index bdfee3b5b..41e617f8a 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -56,13 +56,8 @@ export class MirvExecution implements Execution { this.targetPlayer = this.mg.owner(this.dst); this.speed = this.mg.config().defaultNukeSpeed(); - this.mg - .stats() - .increaseNukeCount( - this.player.id(), - this.targetPlayer.id(), - UnitType.MIRV, - ); + // Record stats + this.mg.stats().bombLaunch(this.player, this.targetPlayer, UnitType.MIRV); } tick(ticks: number): void { @@ -93,6 +88,8 @@ export class MirvExecution implements Execution { if (result === true) { this.separate(); this.active = false; + // Record stats + this.mg.stats().bombLand(this.player, this.targetPlayer, UnitType.MIRV); return; } else { this.nuke.move(result); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 4910f25bd..a7ed24478 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -1,9 +1,9 @@ +import { NukeType } from "../ArchiveSchemas"; import { consolex } from "../Consolex"; import { Execution, Game, MessageType, - NukeType, Player, PlayerID, TerraNullius, @@ -122,8 +122,10 @@ export class NukeExecution implements Execution { detonationDst: this.dst, }); if (this.mg.hasOwner(this.dst)) { - const target = this.mg.owner(this.dst) as Player; - if (this.type === UnitType.AtomBomb) { + const target = this.mg.owner(this.dst); + if (!target.isPlayer()) { + // Ignore terra nullius + } else if (this.type === UnitType.AtomBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - atom bomb inbound`, @@ -131,8 +133,7 @@ export class NukeExecution implements Execution { target.id(), ); this.breakAlliances(this.tilesToDestroy()); - } - if (this.type === UnitType.HydrogenBomb) { + } else if (this.type === UnitType.HydrogenBomb) { this.mg.displayIncomingUnit( this.nuke.id(), `${this.player.name()} - hydrogen bomb inbound`, @@ -142,13 +143,10 @@ export class NukeExecution implements Execution { this.breakAlliances(this.tilesToDestroy()); } + // Record stats this.mg .stats() - .increaseNukeCount( - this.senderID, - target.id(), - this.nuke.type() as NukeType, - ); + .bombLaunch(this.player, target, this.nuke.type() as NukeType); } // after sending a nuke set the missilesilo on cooldown @@ -184,9 +182,10 @@ export class NukeExecution implements Execution { } private detonate() { - if (this.mg === null || this.nuke === null) { + if (this.mg === null || this.nuke === null || this.player === null) { throw new Error("Not initialized"); } + const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); const toDestroy = this.tilesToDestroy(); this.breakAlliances(toDestroy); @@ -235,12 +234,17 @@ export class NukeExecution implements Execution { unit.type() !== UnitType.MIRV ) { if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) { - unit.delete(); + unit.delete(true, this.player); } } } this.active = false; this.nuke.delete(false); + + // Record stats + this.mg + .stats() + .bombLand(this.player, this.target(), this.nuke.type() as NukeType); } owner(): Player { diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 3ca0ef84f..50854707f 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -48,10 +48,6 @@ export class PlayerExecution implements Execution { this.player.decayRelations(); const hasPort = this.player.units(UnitType.Port).length > 0; this.player.units().forEach((u) => { - if (u.health() <= 0) { - u.delete(); - return; - } if (hasPort && u.type() === UnitType.Warship) { u.modifyHealth(1); } @@ -69,6 +65,7 @@ export class PlayerExecution implements Execution { }); if (!this.player.isAlive()) { + // Player has no tiles, delete any remaining units this.player.units().forEach((u) => { if ( u.type() !== UnitType.AtomBomb && @@ -86,7 +83,12 @@ export class PlayerExecution implements Execution { const popInc = this.config.populationIncreaseRate(this.player); this.player.addWorkers(popInc * (1 - this.player.targetTroopRatio())); this.player.addTroops(popInc * this.player.targetTroopRatio()); - this.player.addGold(this.config.goldAdditionRate(this.player)); + const goldFromWorkers = this.config.goldAdditionRate(this.player); + this.player.addGold(goldFromWorkers); + + // Record stats + this.mg.stats().goldWork(this.player, goldFromWorkers); + const adjustRate = this.config.troopAdjustmentRate(this.player); this.player.addTroops(adjustRate); this.player.removeWorkers(adjustRate); @@ -245,6 +247,9 @@ export class PlayerExecution implements Execution { ); capturing.addGold(gold); this.player.removeGold(gold); + + // Record stats + this.mg.stats().goldWar(capturing, this.player, gold); } for (const tile of tiles) { diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 5035cf309..e33bd9b61 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -1,3 +1,4 @@ +import { NukeType } from "../ArchiveSchemas"; import { Execution, Game, @@ -65,8 +66,17 @@ export class SAMMissileExecution implements Execution { this._owner.id(), ); this.active = false; - this.target.delete(); + this.target.delete(true, this._owner); this.SAMMissile.delete(false); + + // Record stats + this.mg + .stats() + .bombIntercept( + this._owner, + this.target.owner(), + this.target.type() as NukeType, + ); return; } else { this.SAMMissile.move(result); diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index 6d4eab4a2..bd1f7ddb3 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -51,7 +51,7 @@ export class ShellExecution implements Execution { ); if (result === true) { this.active = false; - this.target.modifyHealth(-this.effectOnTarget()); + this.target.modifyHealth(-this.effectOnTarget(), this._owner); this.shell.delete(false); return; } else { diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 5e579aca5..b31d34d3c 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -53,6 +53,9 @@ export class TradeShipExecution implements Execution { dstPort: this._dstPort, lastSetSafeFromPirates: ticks, }); + + // Record stats + this.mg.stats().boatSendTrade(this.origOwner, this._dstPort.owner()); } if (!this.tradeShip.isActive()) { @@ -153,12 +156,16 @@ export class TradeShipExecution implements Execution { const gold = this.mg.config().tradeShipGold(this.tilesTraveled); if (this.wasCaptured) { - this.tradeShip.owner().addGold(gold); + const player = this.tradeShip.owner(); + player.addGold(gold); this.mg.displayMessage( `Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`, MessageType.SUCCESS, this.tradeShip.owner().id(), ); + + // Record stats + this.mg.stats().boatCapturedTrade(player, this.origOwner, gold); } else { this.srcPort.owner().addGold(gold); this._dstPort.owner().addGold(gold); @@ -172,6 +179,11 @@ export class TradeShipExecution implements Execution { MessageType.SUCCESS, this.srcPort.owner().id(), ); + + // Record stats + this.mg + .stats() + .boatArriveTrade(this.srcPort.owner(), this._dstPort.owner(), gold); } return; } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 2040f9ff6..a1223d8be 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -146,6 +146,9 @@ export class TransportShipExecution implements Execution { this.targetID, ); } + + // Record stats + this.mg.stats().boatSendTroops(this.attacker, this.target, this.troops); } tick(ticks: number) { @@ -176,6 +179,11 @@ export class TransportShipExecution implements Execution { this.attacker.addTroops(this.troops); this.boat.delete(false); this.active = false; + + // Record stats + this.mg + .stats() + .boatArriveTroops(this.attacker, this.target, this.troops); return; } this.attacker.conquer(this.dst); @@ -194,6 +202,11 @@ export class TransportShipExecution implements Execution { } this.boat.delete(false); this.active = false; + + // Record stats + this.mg + .stats() + .boatArriveTroops(this.attacker, this.target, this.troops); return; case PathFindResultType.NextTile: this.boat.move(result.tile); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0db20e4c6..39956ac14 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -202,8 +202,6 @@ export const nukeTypes = [ UnitType.MIRV, ] as UnitType[]; -export type NukeType = (typeof nukeTypes)[number]; - export enum Relation { Hostile = 0, Distrustful = 1, @@ -331,7 +329,7 @@ export interface Unit { type(): UnitType; owner(): Player; info(): UnitInfo; - delete(displayerMessage?: boolean): void; + delete(displayMessage?: boolean, destroyer?: Player): void; tile(): TileRef; lastTile(): TileRef; move(tile: TileRef): void; @@ -353,7 +351,7 @@ export interface Unit { retreating(): boolean; orderBoatRetreat(): void; health(): number; - modifyHealth(delta: number): void; + modifyHealth(delta: number, attacker?: Player): void; // Troops setTroops(troops: number): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6cfb316dd..5c38a2911 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -44,7 +44,8 @@ export function createGame( miniGameMap: GameMap, config: Config, ): Game { - return new GameImpl(humans, nations, gameMap, miniGameMap, config); + const stats = new StatsImpl(); + return new GameImpl(humans, nations, gameMap, miniGameMap, config, stats); } export type CellString = string; @@ -71,8 +72,6 @@ export class GameImpl implements Game { private updates: GameUpdates = createGameUpdatesMap(); private unitGrid: UnitGrid; - private _stats: StatsImpl = new StatsImpl(); - private playerTeams: Team[] = [ColoredTeams.Red, ColoredTeams.Blue]; private botTeam: Team = ColoredTeams.Bot; @@ -82,6 +81,7 @@ export class GameImpl implements Game { private _map: GameMap, private miniGameMap: GameMap, private _config: Config, + private _stats: Stats, ) { this._terraNullius = new TerraNulliusImpl(); this._width = _map.width(); diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 426c8c598..8c3df8906 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -1,4 +1,4 @@ -import { AllPlayersStats, ClientID, PlayerStats } from "../Schemas"; +import { AllPlayersStats, ClientID } from "../Schemas"; import { EmojiMessage, GameUpdates, @@ -116,7 +116,6 @@ export interface PlayerUpdate { outgoingAttacks: AttackUpdate[]; incomingAttacks: AttackUpdate[]; outgoingAllianceRequests: PlayerID[]; - stats: PlayerStats; hasSpawned: boolean; } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 71aad4f1f..060142dfe 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1,5 +1,5 @@ import { Config } from "../configuration/Config"; -import { ClientID, GameID, PlayerStats } from "../Schemas"; +import { ClientID, GameID } from "../Schemas"; import { createRandomName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { @@ -279,9 +279,6 @@ export class PlayerView { this.id(), ); } - stats(): PlayerStats { - return this.data.stats; - } hasSpawned(): boolean { return this.data.hasSpawned; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 03d0a3b27..d56f0f205 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -168,7 +168,6 @@ export class PlayerImpl implements Player { }) as AttackUpdate, ), outgoingAllianceRequests: outgoingAllianceRequests, - stats: this.mg.stats().getPlayerStats(this.id()), hasSpawned: this.hasSpawned(), }; } @@ -381,8 +380,12 @@ export class PlayerImpl implements Player { this.mg.config().traitorDuration() ); } + markTraitor(): void { this.markedTraitorTick = this.mg.ticks(); + + // Record stats + this.mg.stats().betray(this); } createAllianceRequest(recipient: Player): AllianceRequest | null { diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index aba2d7d51..8a6058027 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -1,12 +1,81 @@ -import { AllPlayersStats, PlayerStats } from "../Schemas"; -import { NukeType, PlayerID } from "./Game"; +import { NukeType, OtherUnitType, PlayerStats } from "../ArchiveSchemas"; +import { AllPlayersStats } from "../Schemas"; +import { Player, TerraNullius } from "./Game"; export interface Stats { - increaseNukeCount( - sender: PlayerID, - target: PlayerID | null, + getPlayerStats(player: Player): PlayerStats | null; + stats(): AllPlayersStats; + + // Player attacks target + attack(player: Player, target: Player | TerraNullius, troops: number): void; + + // Player cancels attack on target + attackCancel( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void; + + // Player betrays another player + betray(player: Player): void; + + // Player sends a trade ship to target + boatSendTrade(player: Player, target: Player): void; + + // Player's trade ship arrives at target, both players earn gold + boatArriveTrade(player: Player, target: Player, gold: number): void; + + // Player's trade ship, captured from target, arrives. Player earns gold. + boatCapturedTrade(player: Player, target: Player, gold: number): void; + + // Player destroys target's trade ship + boatDestroyTrade(player: Player, target: Player): void; + + // Player sends a transport ship to target with troops + boatSendTroops( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void; + + // Player's transport ship arrives at target with troops + boatArriveTroops( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void; + + // Player destroys target's transport ship with troops + boatDestroyTroops(player: Player, target: Player, troops: number): void; + + // Player launches bomb at target + bombLaunch( + player: Player, + target: Player | TerraNullius, type: NukeType, ): void; - getPlayerStats(player: PlayerID): PlayerStats; - stats(): AllPlayersStats; + + // Player's bomb lands at target + bombLand(player: Player, target: Player | TerraNullius, type: NukeType): void; + + // Player's SAM intercepts a bomb from attacker + bombIntercept(player: Player, attacker: Player, type: NukeType): void; + + // Player earns gold from conquering tiles or trade ships from captured + goldWar(player: Player, captured: Player, gold: number): void; + + // Player earns gold from workers + goldWork(player: Player, gold: number): void; + + // Player builds a unit of type + unitBuild(player: Player, type: OtherUnitType): void; + + // Player captures a unit of type + unitCapture(player: Player, type: OtherUnitType): void; + + // Player destroys a unit of type + unitDestroy(player: Player, type: OtherUnitType): void; + + // Player loses a unit of type + unitLose(player: Player, type: OtherUnitType): void; } diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index c512b6748..c2bc66a64 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -1,34 +1,230 @@ -import { AllPlayersStats, PlayerStats } from "../Schemas"; -import { NukeType, PlayerID, UnitType } from "./Game"; +import { + ATTACK_INDEX_CANCEL, + ATTACK_INDEX_RECV, + ATTACK_INDEX_SENT, + BOAT_INDEX_ARRIVE, + BOAT_INDEX_CAPTURE, + BOAT_INDEX_DESTROY, + BOAT_INDEX_SENT, + BoatUnit, + BOMB_INDEX_INTERCEPT, + BOMB_INDEX_LAND, + BOMB_INDEX_LAUNCH, + GOLD_INDEX_STEAL, + GOLD_INDEX_TRADE, + GOLD_INDEX_WAR, + GOLD_INDEX_WORK, + NukeType, + OTHER_INDEX_BUILT, + OTHER_INDEX_CAPTURE, + OTHER_INDEX_DESTROY, + OTHER_INDEX_LOST, + OtherUnitType, + PlayerStats, + unitTypeToBombUnit, + unitTypeToOtherUnit, +} from "../ArchiveSchemas"; +import { AllPlayersStats } from "../Schemas"; +import { Player, TerraNullius } from "./Game"; import { Stats } from "./Stats"; export class StatsImpl implements Stats { - data: AllPlayersStats = {}; + private readonly data: AllPlayersStats = {}; - _createUserData(sender: PlayerID, target: PlayerID): void { - if (!this.data[sender]) { - this.data[sender] = { sentNukes: {} }; - } - if (!this.data[sender].sentNukes[target]) { - this.data[sender].sentNukes[target] = { - [UnitType.MIRV]: 0, - [UnitType.MIRVWarhead]: 0, - [UnitType.AtomBomb]: 0, - [UnitType.HydrogenBomb]: 0, - }; - } - } - - increaseNukeCount(sender: PlayerID, target: PlayerID, type: NukeType): void { - this._createUserData(sender, target); - this.data[sender].sentNukes[target][type]++; - } - - getPlayerStats(player: PlayerID): PlayerStats { - return this.data[player]; + getPlayerStats(player: Player): PlayerStats { + const clientID = player.clientID(); + if (clientID === null) return undefined; + return this.data[clientID]; } stats() { return this.data; } + + private _makePlayerStats(player: Player): PlayerStats { + const clientID = player.clientID(); + if (clientID === null) return undefined; + if (clientID in this.data) { + return this.data[clientID]; + } + const data = {} satisfies PlayerStats; + this.data[clientID] = data; + return data; + } + + private _addAttack(player: Player, index: number, value: number) { + const p = this._makePlayerStats(player); + if (p === undefined) return; + if (p.attacks === undefined) p.attacks = [0]; + while (p.attacks.length < index) p.attacks.push(0); + p.attacks[index] += value; + } + + private _addBetrayal(player: Player, value: number) { + const data = this._makePlayerStats(player); + if (data === undefined) return; + if (data.betrayals === undefined) { + data.betrayals = value; + } else { + data.betrayals += value; + } + } + + private _addBoat( + player: Player, + type: BoatUnit, + index: number, + value: number, + ) { + const p = this._makePlayerStats(player); + if (p === undefined) return; + if (p.boats === undefined) p.boats = { [type]: [0] }; + if (p.boats[type] === undefined) p.boats[type] = [0]; + while (p.boats[type].length < index) p.boats[type].push(0); + p.boats[type][index] += value; + } + + private _addBomb( + player: Player, + nukeType: NukeType, + index: number, + value: number, + ): void { + const type = unitTypeToBombUnit[nukeType]; + const p = this._makePlayerStats(player); + if (p === undefined) return; + if (p.bombs === undefined) p.bombs = { [type]: [0] }; + if (p.bombs[type] === undefined) p.bombs[type] = [0]; + while (p.bombs[type].length < index) p.bombs[type].push(0); + p.bombs[type][index] += value; + } + + private _addGold(player: Player, index: number, value: number) { + const p = this._makePlayerStats(player); + if (p === undefined) return; + if (p.gold === undefined) p.gold = [0]; + while (p.gold.length < index) p.gold.push(0); + p.gold[index] += value; + } + + private _addOtherUnit( + player: Player, + otherUnitType: OtherUnitType, + index: number, + value: number, + ) { + const type = unitTypeToOtherUnit[otherUnitType]; + const p = this._makePlayerStats(player); + if (p === undefined) return; + if (p.units === undefined) p.units = { [type]: [0] }; + if (p.units[type] === undefined) p.units[type] = [0]; + while (p.units[type].length < index) p.units[type].push(0); + p.units[type][index] += value; + } + + attack(player: Player, target: Player | TerraNullius, troops: number): void { + this._addAttack(player, ATTACK_INDEX_SENT, troops); + if (target.isPlayer()) { + this._addAttack(target, ATTACK_INDEX_RECV, troops); + } + } + + attackCancel( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void { + this._addAttack(player, ATTACK_INDEX_CANCEL, troops); + this._addAttack(player, ATTACK_INDEX_SENT, -troops); + if (target.isPlayer()) { + this._addAttack(target, ATTACK_INDEX_RECV, -troops); + } + } + + betray(player: Player): void { + this._addBetrayal(player, 1); + } + + boatSendTrade(player: Player, target: Player): void { + this._addBoat(player, "trade", BOAT_INDEX_SENT, 1); + } + + boatArriveTrade(player: Player, target: Player, gold: number): void { + this._addBoat(player, "trade", BOAT_INDEX_ARRIVE, 1); + this._addGold(player, GOLD_INDEX_TRADE, gold); + this._addGold(target, GOLD_INDEX_TRADE, gold); + } + + boatCapturedTrade(player: Player, target: Player, gold: number): void { + this._addBoat(player, "trade", BOAT_INDEX_CAPTURE, 1); + this._addGold(player, GOLD_INDEX_STEAL, gold); + } + + boatDestroyTrade(player: Player, target: Player): void { + this._addBoat(player, "trade", BOAT_INDEX_DESTROY, 1); + } + + boatSendTroops( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void { + this._addBoat(player, "trans", BOAT_INDEX_SENT, 1); + } + + boatArriveTroops( + player: Player, + target: Player | TerraNullius, + troops: number, + ): void { + this._addBoat(player, "trans", BOAT_INDEX_ARRIVE, 1); + } + + boatDestroyTroops(player: Player, target: Player, troops: number): void { + this._addBoat(player, "trans", BOAT_INDEX_DESTROY, 1); + } + + bombLaunch( + player: Player, + target: Player | TerraNullius, + type: NukeType, + ): void { + this._addBomb(player, type, BOMB_INDEX_LAUNCH, 1); + } + + bombLand( + player: Player, + target: Player | TerraNullius, + type: NukeType, + ): void { + this._addBomb(player, type, BOMB_INDEX_LAND, 1); + } + + bombIntercept(player: Player, attacker: Player, type: NukeType): void { + this._addBomb(player, type, BOMB_INDEX_INTERCEPT, 1); + } + + goldWork(player: Player, gold: number): void { + this._addGold(player, GOLD_INDEX_WORK, gold); + } + + goldWar(player: Player, captured: Player, gold: number): void { + this._addGold(player, GOLD_INDEX_WAR, gold); + } + + unitBuild(player: Player, type: OtherUnitType): void { + this._addOtherUnit(player, type, OTHER_INDEX_BUILT, 1); + } + + unitCapture(player: Player, type: OtherUnitType): void { + this._addOtherUnit(player, type, OTHER_INDEX_CAPTURE, 1); + } + + unitDestroy(player: Player, type: OtherUnitType): void { + this._addOtherUnit(player, type, OTHER_INDEX_DESTROY, 1); + } + + unitLose(player: Player, type: OtherUnitType): void { + this._addOtherUnit(player, type, OTHER_INDEX_LOST, 1); + } } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 916c62438..44564880c 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -2,6 +2,7 @@ import { simpleHash, toInt, withinInt } from "../Util"; import { AllUnitParams, MessageType, + Player, Tick, Unit, UnitInfo, @@ -43,6 +44,16 @@ export class UnitImpl implements Unit { "lastSetSafeFromPirates" in params ? (params.lastSetSafeFromPirates ?? 0) : 0; + + switch (this._type) { + case UnitType.Warship: + case UnitType.Port: + case UnitType.MissileSilo: + case UnitType.DefensePost: + case UnitType.SAMLauncher: + case UnitType.City: + this.mg.stats().unitBuild(_owner, this._type); + } } touch(): void { this.mg.addUpdate(this.toUpdate()); @@ -128,6 +139,16 @@ export class UnitImpl implements Unit { } setOwner(newOwner: PlayerImpl): void { + switch (this._type) { + case UnitType.Warship: + case UnitType.Port: + case UnitType.MissileSilo: + case UnitType.DefensePost: + case UnitType.SAMLauncher: + case UnitType.City: + this.mg.stats().unitCapture(newOwner, this._type); + this.mg.stats().unitLose(this._owner, this._type); + } this._lastOwner = this._owner; this._lastOwner._units = this._lastOwner._units.filter((u) => u !== this); this._owner = newOwner; @@ -145,15 +166,18 @@ export class UnitImpl implements Unit { ); } - modifyHealth(delta: number): void { + modifyHealth(delta: number, attacker?: Player): void { this._health = withinInt( this._health + toInt(delta), 0n, toInt(this.info().maxHealth ?? 1), ); + if (this._health === 0n) { + this.delete(true, attacker); + } } - delete(displayMessage: boolean = true): void { + delete(displayMessage?: boolean, destroyer?: Player): void { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); } @@ -161,14 +185,36 @@ export class UnitImpl implements Unit { this._active = false; this.mg.addUpdate(this.toUpdate()); this.mg.removeUnit(this); - if (displayMessage && this.type() !== UnitType.MIRVWarhead) { + if (displayMessage !== false && this._type !== UnitType.MIRVWarhead) { this.mg.displayMessage( - `Your ${this.type()} was destroyed`, + `Your ${this._type} was destroyed`, MessageType.ERROR, this.owner().id(), ); } + if (destroyer !== undefined) { + switch (this._type) { + case UnitType.TransportShip: + this.mg + .stats() + .boatDestroyTroops(destroyer, this._owner, this._troops); + break; + case UnitType.TradeShip: + this.mg.stats().boatDestroyTrade(destroyer, this._owner); + break; + case UnitType.City: + case UnitType.DefensePost: + case UnitType.MissileSilo: + case UnitType.Port: + case UnitType.SAMLauncher: + case UnitType.Warship: + this.mg.stats().unitDestroy(destroyer, this._type); + this.mg.stats().unitLose(this.owner(), this._type); + break; + } + } } + isActive(): boolean { return this._active; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 90d650e18..bee248213 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -62,6 +62,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { ip: p.ip, persistentID: p.persistentID, clientID: p.clientID, + stats: p.stats, })), }; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b747bebda..4697c73e3 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -2,7 +2,6 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; import { - AllPlayersStats, ClientID, ClientMessage, ClientMessageSchema, @@ -49,8 +48,6 @@ export class GameServer { private lastPingUpdate = 0; private winner: ClientSendWinnerMessage | null = null; - // This field is currently only filled at victory - private allPlayersStats: AllPlayersStats = {}; private gameStartInfo: GameStartInfo; @@ -204,7 +201,6 @@ export class GameServer { } if (clientMsg.type === "winner") { this.winner = clientMsg; - this.allPlayersStats = clientMsg.allPlayersStats; } } catch (error) { this.log.info( @@ -389,12 +385,21 @@ export class GameServer { if (this.allClients.size > 0) { const playerRecords: PlayerRecord[] = Array.from( this.allClients.values(), - ).map((client) => ({ - ip: ipAnonymize(client.ip), - clientID: client.clientID, - username: client.username, - persistentID: client.persistentID, - })); + ).map((client) => { + const stats = this.winner?.allPlayersStats[client.clientID]; + if (stats === undefined) { + this.log.warn( + `Unable to find stats for clientID ${client.clientID}`, + ); + } + return { + ip: ipAnonymize(client.ip), + clientID: client.clientID, + username: client.username, + persistentID: client.persistentID, + stats, + } satisfies PlayerRecord; + }); archive( createGameRecord( this.id, @@ -405,7 +410,6 @@ export class GameServer { Date.now(), this.winner?.winner ?? null, this.winner?.winnerType ?? null, - this.allPlayersStats, ), ); } else { diff --git a/src/server/Logger.ts b/src/server/Logger.ts index 71e033238..94e508fbb 100644 --- a/src/server/Logger.ts +++ b/src/server/Logger.ts @@ -70,7 +70,7 @@ const logger = winston.createLogger({ ), defaultMeta: { service: "openfront", - environment: process.env.NODE_ENV, + environment: process.env.GAME_ENV ?? "prod", }, transports: [ new winston.transports.Console(),