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
This commit is contained in:
Scott Anderson
2025-05-21 00:10:29 -04:00
committed by GitHub
parent 228f5b13af
commit 5aa8356513
27 changed files with 581 additions and 161 deletions
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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()
+2 -38
View File
@@ -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 {
</div>
`
: ""}
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.nuke")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent(other.id())}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
+4
View File
@@ -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<typeof UserMeResponseSchema>;
+100
View File
@@ -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<typeof BombUnitSchema>;
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<NukeType, BombUnit>;
export const BoatUnitSchema = z.union([z.literal("trade"), z.literal("trans")]);
export type BoatUnit = z.infer<typeof BoatUnitSchema>;
export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip;
// export const unitTypeToBoatUnit = {
// [UnitType.TradeShip]: "trade",
// [UnitType.TransportShip]: "trans",
// } as const satisfies Record<BoatUnitType, BoatUnit>;
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<typeof OtherUnitSchema>;
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<OtherUnitType, OtherUnit>;
// 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<typeof AtLeastOneNumberSchema>;
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<typeof PlayerStatsSchema>;
+4 -17
View File
@@ -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<typeof PlayerRecordSchema>;
export type GameRecord = z.infer<typeof GameRecordSchema>;
export type AllPlayersStats = z.infer<typeof AllPlayersStatsSchema>;
export type PlayerStats = z.infer<typeof PlayerStatsSchema>;
export type Player = z.infer<typeof PlayerSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
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(),
});
+17 -13
View File
@@ -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,
};
+8 -1
View File
@@ -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) {
+4 -7
View File
@@ -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);
+16 -12
View File
@@ -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 {
+10 -5
View File
@@ -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) {
+11 -1
View File
@@ -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);
+1 -1
View File
@@ -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 {
+13 -1
View File
@@ -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;
}
@@ -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);
+2 -4
View File
@@ -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;
+3 -3
View File
@@ -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();
+1 -2
View File
@@ -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;
}
+1 -4
View File
@@ -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;
}
+4 -1
View File
@@ -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 {
+76 -7
View File
@@ -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;
}
+220 -24
View File
@@ -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);
}
}
+50 -4
View File
@@ -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;
}
+1
View File
@@ -62,6 +62,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
ip: p.ip,
persistentID: p.persistentID,
clientID: p.clientID,
stats: p.stats,
})),
};
+15 -11
View File
@@ -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 {
+1 -1
View File
@@ -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(),