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(),