From cbd1389863d08367c667a9796fbd8e0606eb26db Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Tue, 4 Nov 2025 23:22:24 +0000 Subject: [PATCH 1/5] lobby fill time added to stats --- src/client/ClientGameRunner.ts | 1 + src/client/LocalServer.ts | 7 ++++++- src/client/SinglePlayerModal.ts | 1 + src/core/Schemas.ts | 3 +++ src/core/Util.ts | 12 ++++++++++++ src/core/game/Stats.ts | 3 +++ src/core/game/StatsImpl.ts | 2 ++ src/server/GameServer.ts | 3 +++ 8 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 320b8e3d89..8502a45b7e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -242,6 +242,7 @@ export class ClientGameRunner { startTime(), Date.now(), update.winner, + this.lobby.gameStartInfo.lobbyCreatedAt, ); endGame(record); } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 24eed2bff9..8de43bde94 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -84,6 +84,7 @@ export class LocalServer { type: "start", gameStartInfo: this.lobbyConfig.gameStartInfo, turns: [], + lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt, } satisfies ServerStartGameMessage); } @@ -219,13 +220,17 @@ export class LocalServer { compress(jsonString) .then((compressedData) => { + // Create a blob from the ArrayBuffer + const blob = new Blob([compressedData.buffer as ArrayBuffer], { + type: "application/json", + }); return fetch(`/${workerPath}/api/archive_singleplayer_game`, { method: "POST", headers: { "Content-Type": "application/json", "Content-Encoding": "gzip", }, - body: compressedData, + body: blob, keepalive: true, // Ensures request completes even if page unloads }); }) diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index eebf1ac0fb..376572fdf2 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -575,6 +575,7 @@ export class SinglePlayerModal extends LitElement { disableNPCs: this.disableNPCs, }), }, + lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, } satisfies JoinLobbyEvent, bubbles: true, diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a390e9912a..5ddb85c61e 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -427,6 +427,7 @@ export const PlayerSchema = z.object({ export const GameStartInfoSchema = z.object({ gameID: ID, + lobbyCreatedAt: z.number(), config: GameConfigSchema, players: PlayerSchema.array(), }); @@ -463,6 +464,7 @@ export const ServerStartGameMessageSchema = z.object({ // Turns the client missed if they are late to the game. turns: TurnSchema.array(), gameStartInfo: GameStartInfoSchema, + lobbyCreatedAt: z.number(), }); export const ServerDesyncSchema = z.object({ @@ -559,6 +561,7 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({ duration: z.number().nonnegative(), num_turns: z.number(), winner: WinnerSchema, + lobbyFillTime: z.number().nonnegative(), }); export type GameEndInfo = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index d90516af60..f508904295 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -194,15 +194,27 @@ export function createPartialGameRecord( start: number, end: number, winner: Winner, + // lobby creation time (ms). Defaults to start time for singleplayer. + lobbyCreatedAt?: number, ): PartialGameRecord { const duration = Math.floor((end - start) / 1000); const num_turns = allTurns.length; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, ); + + // Use start time as lobby creation time for singleplayer + const actualLobbyCreatedAt = lobbyCreatedAt ?? start; + const lobbyFillTime = Math.max( + 0, + start - Math.min(actualLobbyCreatedAt, start), + ); + const record: PartialGameRecord = { info: { gameID, + lobbyCreatedAt: actualLobbyCreatedAt, + lobbyFillTime, config, players, start, diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 06c41af9c5..39400087d1 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -23,6 +23,9 @@ export interface Stats { // Player betrays another player betray(player: Player): void; + // Time between lobby creation and game start (ms) + lobbyFillTime(fillTimeMs: number): void; + // Player sends a trade ship to target boatSendTrade(player: Player, target: Player): void; diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index f6a574848c..aa48b1b7f6 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -264,4 +264,6 @@ export class StatsImpl implements Stats { playerKilled(player: Player, tick: number): void { this._addPlayerKilled(player, tick); } + + lobbyFillTime(fillTimeMs: number): void {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 9a9e97333d..a39603db4a 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -398,6 +398,7 @@ export class GameServer { const result = GameStartInfoSchema.safeParse({ gameID: this.id, + lobbyCreatedAt: this.createdAt, config: this.gameConfig, players: this.activeClients.map((c) => ({ username: c.username, @@ -436,6 +437,7 @@ export class GameServer { type: "start", turns: this.turns.slice(lastTurn), gameStartInfo: this.gameStartInfo, + lobbyCreatedAt: this.createdAt, } satisfies ServerStartGameMessage), ); } catch (error) { @@ -703,6 +705,7 @@ export class GameServer { this._startTime ?? 0, Date.now(), this.winner?.winner, + this.createdAt, ), ), ); From e562a91a0cb8bba91f201285fbfe42f86cb4ab20 Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Sun, 9 Nov 2025 16:07:30 +0000 Subject: [PATCH 2/5] removed changes to compress in localserver --- src/client/LocalServer.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index f81e41de11..c21114911e 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -220,17 +220,13 @@ export class LocalServer { compress(jsonString) .then((compressedData) => { - // Create a blob from the ArrayBuffer - const blob = new Blob([compressedData.buffer as ArrayBuffer], { - type: "application/json", - }); return fetch(`/${workerPath}/api/archive_singleplayer_game`, { method: "POST", headers: { "Content-Type": "application/json", "Content-Encoding": "gzip", }, - body: blob, + body: compressedData, keepalive: true, // Ensures request completes even if page unloads }); }) From 146342b0813741f4c03d58ff775df2d171995321 Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Tue, 11 Nov 2025 20:24:01 +0000 Subject: [PATCH 3/5] Directional Bombs - Press "U" --- src/client/InputHandler.ts | 9 ++++++++ src/client/Transport.ts | 2 ++ src/client/graphics/GameRenderer.ts | 9 ++++++-- src/client/graphics/UIState.ts | 1 + src/client/graphics/layers/BuildMenu.ts | 11 +++++++++- .../layers/NukeTrajectoryPreviewLayer.ts | 15 ++++++++++++- .../graphics/layers/StructureIconsLayer.ts | 8 ++++++- src/core/Schemas.ts | 1 + src/core/execution/ConstructionExecution.ts | 11 +++++++++- src/core/execution/ExecutionManager.ts | 7 ++++++- src/core/execution/NukeExecution.ts | 2 ++ src/core/pathfinding/PathFinding.ts | 21 ++++++++++++++++--- tests/InputHandler.test.ts | 2 +- 13 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 26d8f6c27d..bae4975927 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -89,6 +89,8 @@ export class GhostStructureChangedEvent implements GameEvent { constructor(public readonly ghostStructure: UnitType | null) {} } +export class SwapRocketDirectionEvent implements GameEvent {} + export class ShowBuildMenuEvent implements GameEvent { constructor( public readonly x: number, @@ -200,6 +202,7 @@ export class InputHandler { attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", + swapDirection: "KeyU", modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", buildCity: "Digit1", @@ -417,6 +420,12 @@ export class InputHandler { this.setGhostStructure(UnitType.MIRV); } + // Tab to swap rocket direction + if (e.code === this.keybinds.swapDirection) { + e.preventDefault(); + this.eventBus.emit(new SwapRocketDirectionEvent()); + } + // Shift-D to toggle performance overlay console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 98b8bde167..b421af539f 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -89,6 +89,7 @@ export class BuildUnitIntentEvent implements GameEvent { constructor( public readonly unit: UnitType, public readonly tile: TileRef, + public readonly rocketDirectionUp?: boolean, ) {} } @@ -549,6 +550,7 @@ export class Transport { clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, + rocketDirectionUp: event.rocketDirectionUp, }); } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index f398dccba7..a9f0ce54c6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -49,7 +49,11 @@ export function createRenderer( const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); - const uiState = { attackRatio: 20, ghostStructure: null } as UIState; + const uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + } as UIState; //hide when the game renders const startingModal = document.querySelector( @@ -72,6 +76,7 @@ export function createRenderer( } buildMenu.game = game; buildMenu.eventBus = eventBus; + buildMenu.uiState = uiState; buildMenu.transformHandler = transformHandler; const leaderboard = document.querySelector("leader-board") as Leaderboard; @@ -244,7 +249,7 @@ export function createRenderer( new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), new UILayer(game, eventBus, transformHandler), - new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler), + new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index 01c4a60cb9..f47acef885 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -3,4 +3,5 @@ import { UnitType } from "../../core/game/Game"; export interface UIState { attackRatio: number; ghostStructure: UnitType | null; + rocketDirectionUp: boolean; } diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 46adc33677..39fd604a11 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -33,6 +33,7 @@ import { } from "../../Transport"; import { renderNumber } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; export interface BuildItemDisplay { @@ -125,6 +126,7 @@ export const flattenedBuildTable = buildTable.flat(); export class BuildMenu extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; + public uiState: UIState; private clickedTile: TileRef; public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; @@ -395,7 +397,14 @@ export class BuildMenu extends LitElement implements Layer { ), ); } else if (buildableUnit.canBuild) { - this.eventBus.emit(new BuildUnitIntentEvent(buildableUnit.type, tile)); + const rocketDirectionUp = + buildableUnit.type === UnitType.AtomBomb || + buildableUnit.type === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; + this.eventBus.emit( + new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp), + ); } this.hideMenu(); } diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 996e18209e..97f051730f 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -3,8 +3,13 @@ import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; -import { GhostStructureChangedEvent, MouseMoveEvent } from "../../InputHandler"; +import { + GhostStructureChangedEvent, + MouseMoveEvent, + SwapRocketDirectionEvent, +} from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; /** @@ -24,6 +29,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, + private uiState: UIState, ) {} shouldTransform(): boolean { @@ -47,6 +53,12 @@ export class NukeTrajectoryPreviewLayer implements Layer { this.cachedSpawnTile = null; } }); + this.eventBus.on(SwapRocketDirectionEvent, () => { + // Toggle rocket direction + this.uiState.rocketDirectionUp = !this.uiState.rocketDirectionUp; + // Force trajectory recalculation + this.lastTargetTile = null; + }); } tick() { @@ -207,6 +219,7 @@ export class NukeTrajectoryPreviewLayer implements Layer { targetTile, speed, distanceBasedHeight, + this.uiState.rocketDirectionUp, ); this.trajectoryPoints = pathFinder.allTiles(); diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 3504eed052..f2cca7e19d 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -334,10 +334,16 @@ export class StructureIconsLayer implements Layer { ), ); } else if (this.ghostUnit.buildableUnit.canBuild) { + const unitType = this.ghostUnit.buildableUnit.type; + const rocketDirectionUp = + unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; this.eventBus.emit( new BuildUnitIntentEvent( - this.ghostUnit.buildableUnit.type, + unitType, this.game.ref(tile.x, tile.y), + rocketDirectionUp, ), ); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index acedd062a9..47d7a75d17 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -302,6 +302,7 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ type: z.literal("build_unit"), unit: z.enum(UnitType), tile: z.number(), + rocketDirectionUp: z.boolean().optional(), }); export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 8217f497da..7ac54a42f3 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -31,6 +31,7 @@ export class ConstructionExecution implements Execution { private player: Player, private constructionType: UnitType, private tile: TileRef, + private rocketDirectionUp?: boolean, ) {} init(mg: Game, ticks: number): void { @@ -104,7 +105,15 @@ export class ConstructionExecution implements Execution { case UnitType.AtomBomb: case UnitType.HydrogenBomb: this.mg.addExecution( - new NukeExecution(this.constructionType, player, this.tile), + new NukeExecution( + this.constructionType, + player, + this.tile, + null, + -1, + 0, + this.rocketDirectionUp, + ), ); break; case UnitType.MIRV: diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 4a8e5df916..af97167adb 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -105,7 +105,12 @@ export class Executor { case "embargo_all": return new EmbargoAllExecution(player, intent.action); case "build_unit": - return new ConstructionExecution(player, intent.unit, intent.tile); + return new ConstructionExecution( + player, + intent.unit, + intent.tile, + intent.rocketDirectionUp, + ); case "allianceExtension": { return new AllianceExtensionExecution(player, intent.recipient); } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 1fd9222bb0..37cd87d738 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -30,6 +30,7 @@ export class NukeExecution implements Execution { private src?: TileRef | null, private speed: number = -1, private waitTicks = 0, + private rocketDirectionUp: boolean = true, ) {} init(mg: Game, ticks: number): void { @@ -115,6 +116,7 @@ export class NukeExecution implements Execution { this.dst, this.speed, this.nukeType !== UnitType.MIRVWarhead, + this.rocketDirectionUp, ); this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 2050cc162d..d62487f0b0 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -16,6 +16,7 @@ export class ParabolaPathFinder { dst: TileRef, increment: number = 3, distanceBasedHeight = true, + directionUp = true, ) { const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) }; const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) }; @@ -25,14 +26,28 @@ export class ParabolaPathFinder { const maxHeight = distanceBasedHeight ? Math.max(distance / 3, parabolaMinHeight) : 0; - // Use a bezier curve always pointing up + // Use a bezier curve pointing up or down based on directionUp parameter + const heightMultiplier = directionUp ? -1 : 1; + const mapHeight = this.mg.height(); const p1 = { x: p0.x + (p3.x - p0.x) / 4, - y: Math.max(p0.y + (p3.y - p0.y) / 4 - maxHeight, 0), + y: Math.max( + 0, + Math.min( + p0.y + (p3.y - p0.y) / 4 + heightMultiplier * maxHeight, + mapHeight - 1, + ), + ), }; const p2 = { x: p0.x + ((p3.x - p0.x) * 3) / 4, - y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0), + y: Math.max( + 0, + Math.min( + p0.y + ((p3.y - p0.y) * 3) / 4 + heightMultiplier * maxHeight, + mapHeight - 1, + ), + ), }; this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 210d65739e..16cadc97ff 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -37,7 +37,7 @@ describe("InputHandler AutoUpgrade", () => { eventBus = new EventBus(); inputHandler = new InputHandler( - { attackRatio: 20, ghostStructure: null }, + { attackRatio: 20, ghostStructure: null, rocketDirectionUp: true }, mockCanvas, eventBus, ); From 28b8069dde11399f7dea95805537dc2d444084cf Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Tue, 11 Nov 2025 20:42:13 +0000 Subject: [PATCH 4/5] remove incorrect comment --- src/client/InputHandler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index bae4975927..7d4fb5e48c 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -420,7 +420,6 @@ export class InputHandler { this.setGhostStructure(UnitType.MIRV); } - // Tab to swap rocket direction if (e.code === this.keybinds.swapDirection) { e.preventDefault(); this.eventBus.emit(new SwapRocketDirectionEvent()); From 2a07538c4e2b127ef71c66f1da900e9bb0b24327 Mon Sep 17 00:00:00 2001 From: Ryan Barlow Date: Fri, 14 Nov 2025 16:50:18 +0000 Subject: [PATCH 5/5] added instructions for new mechanic to hotkeys list --- resources/lang/en.json | 3 ++- src/client/HelpModal.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 189d54202a..880082de95 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -131,7 +131,8 @@ "icon_embargo": "Dollar stop sign - Embargo. This player has stopped trading with you automatically or manually.", "icon_request": "Envelope - Alliance request. This player has sent you an alliance request.", "info_enemy_panel": "Enemy info panel", - "exit_confirmation": "Are you sure you want to exit the game?" + "exit_confirmation": "Are you sure you want to exit the game?", + "bomb_direction": "Atom / Hydrogen bomb arc direction" }, "single_modal": { "title": "Single Player", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 1b840c27ba..dac511056a 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -55,6 +55,10 @@ export class HelpModal extends LitElement { Space ${translateText("help_modal.action_alt_view")} + + U + ${translateText("help_modal.bomb_direction")} +