From 228f5b13afcf5c4a6961fe3a2c2d319331be0039 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Tue, 20 May 2025 16:08:02 -0400 Subject: [PATCH 1/8] Landing boats always conquer shore tile (#790) ## Description: Modifies the transport ship landing logic to always conquer the tile. ## 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 Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/execution/TransportShipExecution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 027f022c4..2040f9ff6 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -178,10 +178,10 @@ export class TransportShipExecution implements Execution { this.active = false; return; } + this.attacker.conquer(this.dst); if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) { - this.target.addTroops(this.troops); + this.attacker.addTroops(this.troops); } else { - this.attacker.conquer(this.dst); this.mg.addExecution( new AttackExecution( this.troops, From 5aa835651345cd275f07b7a885cf5b71a13f9a43 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 00:10:29 -0400 Subject: [PATCH 2/8] 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(), From 95c15de2180d2ddd56c0ba2b4c42b502dc089140 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 01:39:23 -0400 Subject: [PATCH 3/8] Fix deployment status environment (#833) ## Description: Fix the deployment status environment to match the new environment names. ## 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 --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0d2210dbd..6f2d4d2de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,7 @@ jobs: # Use different logic based on event type name: Deploy to ${{ inputs.target_domain || 'openfront.dev' }} runs-on: ubuntu-latest - environment: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} + environment: ${{ inputs.target_domain == 'openfront.io' && 'prod' || '' }} env: DOMAIN: ${{ inputs.target_domain || 'openfront.dev' }} SUBDOMAIN: ${{ github.event_name == 'push' && github.ref_name || inputs.target_subdomain || 'main' }} @@ -78,7 +78,7 @@ jobs: with: token: ${{ steps.generate-token.outputs.token }} environment-url: https://${{ env.FQDN }} - environment: ${{ env.FQDN }} + environment: ${{ inputs.target_domain == 'openfront.io' && 'prod' || 'staging' }} - name: 🔗 Log in to Docker Hub uses: docker/login-action@v3 with: From aa6e252ff3ffcc46ae5016ef933299641a1f5896 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 01:47:17 -0400 Subject: [PATCH 4/8] bug: Fix the TTL feature in docker staging (#832) ## Description: Modify the docker run command to allow containers in staging to terminate. ## 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 --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- startup.sh | 2 +- update.sh | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/startup.sh b/startup.sh index 393c85c22..5c7e452af 100644 --- a/startup.sh +++ b/startup.sh @@ -107,7 +107,7 @@ fi # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then - timeout 24h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf + exec timeout 2m /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf else exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi diff --git a/update.sh b/update.sh index 59962bf92..91c516346 100755 --- a/update.sh +++ b/update.sh @@ -16,34 +16,40 @@ echo "======================================================" CONTAINER_NAME="openfront-${ENV}-${SUBDOMAIN}" echo "Pulling ${DOCKER_IMAGE} from Docker Hub..." -docker pull $DOCKER_IMAGE +docker pull "${DOCKER_IMAGE}" echo "Checking for existing container..." # Check for running container -RUNNING_CONTAINER=$(docker ps | grep ${CONTAINER_NAME} | awk '{print $1}') +RUNNING_CONTAINER="$(docker ps | grep ${CONTAINER_NAME} | awk '{print $1}')" if [ -n "$RUNNING_CONTAINER" ]; then echo "Stopping running container $RUNNING_CONTAINER..." - docker stop $RUNNING_CONTAINER + docker stop "$RUNNING_CONTAINER" echo "Waiting for container to fully stop and release resources..." sleep 5 # Add a 5-second delay - docker rm $RUNNING_CONTAINER + docker rm "$RUNNING_CONTAINER" echo "Container $RUNNING_CONTAINER stopped and removed." fi # Also check for stopped containers with the same name -STOPPED_CONTAINER=$(docker ps -a | grep ${CONTAINER_NAME} | awk '{print $1}') +STOPPED_CONTAINER="$(docker ps -a | grep ${CONTAINER_NAME} | awk '{print $1}')" if [ -n "$STOPPED_CONTAINER" ]; then echo "Removing stopped container $STOPPED_CONTAINER..." - docker rm $STOPPED_CONTAINER + docker rm "$STOPPED_CONTAINER" echo "Container $STOPPED_CONTAINER removed." fi +if [ "${SUBDOMAIN}" = main ] || [ "${DOMAIN}" = openfront.io ]; then + RESTART=always +else + RESTART=no +fi + echo "Starting new container for ${HOST} environment..." docker run -d \ - --restart=always \ + --restart="${RESTART}" \ --env-file /home/openfront/.env \ - --name ${CONTAINER_NAME} \ - $DOCKER_IMAGE + --name "${CONTAINER_NAME}" \ + "${DOCKER_IMAGE}" if [ $? -eq 0 ]; then echo "Update complete! New ${CONTAINER_NAME} container is running." From ad045c4a024aa97483a0c0dcc4d5dcf56bb7316d Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 01:48:57 -0400 Subject: [PATCH 5/8] Raise timeout to 18h --- startup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/startup.sh b/startup.sh index 5c7e452af..052c3b200 100644 --- a/startup.sh +++ b/startup.sh @@ -107,7 +107,7 @@ fi # Start supervisord if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then - exec timeout 2m /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf + exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf else exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi From c39ffa91f90112e9dcd764a7b4d0174d2a0fbc20 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Wed, 21 May 2025 01:53:42 -0400 Subject: [PATCH 6/8] Transition flag from null to undefined (#831) ## Description: Replace `null` with `undefined` for player flags. ## 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 --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> --- src/core/Schemas.ts | 2 +- src/server/Client.ts | 2 +- src/server/GameServer.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 1a334b6d0..7e2fff5e2 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -430,7 +430,7 @@ export const ClientJoinMessageSchema = z.object({ gameID: ID, lastTurn: z.number(), // The last turn the client saw. username: SafeString, - flag: SafeString.nullable(), + flag: SafeString.optional(), }); export const ClientMessageSchema = z.union([ diff --git a/src/server/Client.ts b/src/server/Client.ts index a3587c3cd..d5fc46e04 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -18,6 +18,6 @@ export class Client { public readonly ip: string, public readonly username: string, public readonly ws: WebSocket, - public readonly flag: string | null, + public readonly flag: string | undefined, ) {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 4697c73e3..ec9059468 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -296,7 +296,7 @@ export class GameServer { clientID: c.clientID, flag: c.flag, })), - }); + } satisfies GameStartInfo); this.endTurnIntervalID = setInterval( () => this.endTurn(), From 2f1fc5e9982fb0f50b14ebabf35881ac19943651 Mon Sep 17 00:00:00 2001 From: Duwibi <86431918+Duwibi@users.noreply.github.com> Date: Wed, 21 May 2025 10:52:06 +0300 Subject: [PATCH 7/8] Lobby size (#745) ## Description: This PR makes individual max lobby sizes for all of the maps. It also makes the max players on team mode multiply by 1.5 instead of the previous 2. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: Nikola123 --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: Scott Anderson --- src/core/configuration/DefaultConfig.ts | 90 +++++++++---------------- 1 file changed, 31 insertions(+), 59 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 2d9a891cd..e362b8157 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -38,6 +38,33 @@ const JwksSchema = z.object({ .min(1), }); +const numPlayersConfig = { + [GameMapType.GatewayToTheAtlantic]: [80, 60, 40], + [GameMapType.SouthAmerica]: [70, 50, 40], + [GameMapType.NorthAmerica]: [80, 60, 50], + [GameMapType.Africa]: [100, 80, 50], + [GameMapType.Europe]: [80, 50, 30], + [GameMapType.Australia]: [50, 40, 30], + [GameMapType.Iceland]: [50, 40, 30], + [GameMapType.Britannia]: [50, 40, 30], + [GameMapType.Asia]: [60, 50, 30], + [GameMapType.FalklandIslands]: [80, 50, 30], + [GameMapType.Baikal]: [60, 50, 40], + [GameMapType.Mena]: [60, 50, 30], + [GameMapType.Mars]: [50, 40, 30], + [GameMapType.Oceania]: [30, 20, 10], + [GameMapType.Japan]: [50, 40, 30], + [GameMapType.FaroeIslands]: [50, 40, 30], + [GameMapType.DeglaciatedAntarctica]: [50, 40, 30], + [GameMapType.EuropeClassic]: [80, 30, 50], + [GameMapType.BetweenTwoSeas]: [40, 50, 30], + [GameMapType.BlackSea]: [40, 50, 30], + [GameMapType.Pangaea]: [40, 20, 30], + [GameMapType.World]: [150, 80, 50], + [GameMapType.KnownWorld]: [50, 40, 30], + [GameMapType.Halkidiki]: [50, 40, 30], +} as const satisfies Record; + const TERRAIN_EFFECTS = { [TerrainType.Plains]: { mag: 0.85, speed: 0.8 }, [TerrainType.Highland]: { mag: 1, speed: 1 }, @@ -117,65 +144,10 @@ export abstract class DefaultServerConfig implements ServerConfig { } lobbyMaxPlayers(map: GameMapType, mode: GameMode): number { - const numPlayers = () => { - // Maps with ~4 mil pixels - if ( - [ - GameMapType.GatewayToTheAtlantic, - GameMapType.SouthAmerica, - GameMapType.NorthAmerica, - GameMapType.Africa, - GameMapType.Europe, - ].includes(map) - ) { - return Math.random() < 0.2 ? 100 : 50; - } - // Maps with ~2.5 - ~3.5 mil pixels - if ( - [ - GameMapType.Australia, - GameMapType.Iceland, - GameMapType.Britannia, - GameMapType.Asia, - GameMapType.FalklandIslands, - GameMapType.Baikal, - GameMapType.Halkidiki, - ].includes(map) - ) { - return Math.random() < 0.3 ? 50 : 25; - } - // Maps with ~2 mil pixels - if ( - [ - GameMapType.Mena, - GameMapType.Mars, - GameMapType.Oceania, - GameMapType.Japan, // Japan at this level because its 2/3 water - GameMapType.FaroeIslands, - GameMapType.DeglaciatedAntarctica, - GameMapType.EuropeClassic, - ].includes(map) - ) { - return Math.random() < 0.3 ? 50 : 25; - } - // Maps smaller than ~2 mil pixels - if ( - [ - GameMapType.BetweenTwoSeas, - GameMapType.BlackSea, - GameMapType.Pangaea, - ].includes(map) - ) { - return Math.random() < 0.5 ? 30 : 15; - } - // world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended. - if (map === GameMapType.World) { - return Math.random() < 0.2 ? 150 : 50; - } - // default return for non specified map - return Math.random() < 0.2 ? 50 : 20; - }; - return Math.min(150, numPlayers() * (mode === GameMode.Team ? 2 : 1)); + const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20]; + const r = Math.random(); + const base = r < 0.3 ? l : r < 0.6 ? m : s; + return mode === GameMode.Team ? Math.ceil(base * 1.5) : base; } workerIndex(gameID: GameID): number { From 772b72dfef48ad663e18412d6c007124f0733df8 Mon Sep 17 00:00:00 2001 From: PilkeySEK <156579268+PilkeySEK@users.noreply.github.com> Date: Wed, 21 May 2025 10:15:28 +0200 Subject: [PATCH 8/8] Donate the attackratio instead of 1/3 (#534) ## Description: The changes introduce a new public uiState property to the PlayerPanel class and ensure it is assigned from the shared UI state in the createRenderer function. The troop donation logic in PlayerPanel is updated to use the attackRatio from uiState to determine the number of troops to donate, replacing the previous static or null value. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: PilkeySEK --- src/client/graphics/GameRenderer.ts | 1 + src/client/graphics/layers/PlayerPanel.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 9cc5b8282..9b2240732 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -145,6 +145,7 @@ export function createRenderer( playerPanel.g = game; playerPanel.eventBus = eventBus; playerPanel.emojiTable = emojiTable; + playerPanel.uiState = uiState; const chatModal = document.querySelector("chat-modal") as ChatModal; if (!(chatModal instanceof ChatModal)) { diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index a8b773b15..d9afeab4a 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -24,6 +24,7 @@ import { SendTargetPlayerIntentEvent, } from "../../Transport"; import { renderNumber, renderTroops } from "../../Utils"; +import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; @@ -33,6 +34,7 @@ export class PlayerPanel extends LitElement implements Layer { public g: GameView; public eventBus: EventBus; public emojiTable: EmojiTable; + public uiState: UIState; private actions: PlayerActions | null = null; private tile: TileRef | null = null; @@ -86,7 +88,13 @@ export class PlayerPanel extends LitElement implements Layer { other: PlayerView, ) { e.stopPropagation(); - this.eventBus.emit(new SendDonateTroopsIntentEvent(myPlayer, other, null)); + this.eventBus.emit( + new SendDonateTroopsIntentEvent( + myPlayer, + other, + myPlayer.troops() * this.uiState.attackRatio, + ), + ); this.hide(); }