From ebe1f76bbf1e7408cef956397f3342a442974248 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 1 Mar 2026 04:24:07 +0000 Subject: [PATCH 1/4] improved configuration handling, including special lobby (#3224) ## Description: Special games now get a random map from a dedicated pool (which includes arcade maps that are excluded from regular FFA/team rotations), a 50/50 chance of FFA or Team mode, and are guaranteed to have at least one modifier active, compact map, random spawn, crowded, or 5m starting gold , with higher roll rates than normal games. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/server/MapPlaylist.ts | 144 ++++++++++++++++++++++++++++++++------ 1 file changed, 121 insertions(+), 23 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index a35434b90..f44762416 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -11,6 +11,7 @@ import { Quads, RankedType, Trios, + mapCategories, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas"; @@ -18,6 +19,7 @@ import { logger } from "./Logger"; import { getMapLandTiles } from "./MapLandTiles"; const log = logger.child({}); +const ARCADE_MAPS = new Set(mapCategories.arcade); // How many times each map should appear in the playlist. // Note: The Partial should eventually be removed for better type safety. @@ -87,6 +89,16 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ { config: HumansVsNations, weight: 20 }, ]; +type ModifierKey = "isRandomSpawn" | "isCompact" | "isCrowded" | "startingGold"; + +// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection. +const SPECIAL_MODIFIER_POOL: ModifierKey[] = [ + ...Array(4).fill("isRandomSpawn"), + ...Array(7).fill("isCompact"), + ...Array(1).fill("isCrowded"), + ...Array(6).fill("startingGold"), +]; + export class MapPlaylist { private playlists: Record = { ffa: [], @@ -94,8 +106,6 @@ export class MapPlaylist { team: [], }; - constructor() {} - public async gameConfig(type: PublicGameType): Promise { if (type === "special") { return this.getSpecialConfig(); @@ -175,27 +185,76 @@ export class MapPlaylist { } satisfies GameConfig; } - private getSpecialConfig(): GameConfig { - // TODO: create better special configs. + private async getSpecialConfig(): Promise { + const mode = Math.random() < 0.5 ? GameMode.FFA : GameMode.Team; const map = this.getNextMap("special"); + const playerTeams = + mode === GameMode.Team ? this.getTeamCount() : undefined; + const supportsCompact = + mode !== GameMode.Team || (await this.supportsCompactMapForTeams(map)); + const excludedModifiers: ModifierKey[] = []; + if (!supportsCompact) { + excludedModifiers.push("isCompact"); + } + if ( + playerTeams === Duos || + playerTeams === Trios || + playerTeams === Quads + ) { + excludedModifiers.push("isRandomSpawn"); + } + + let { isCrowded, startingGold, isCompact, isRandomSpawn } = + this.getRandomSpecialGameModifiers(excludedModifiers); + + let crowdedMaxPlayers: number | undefined; + if (isCrowded) { + crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact); + if (crowdedMaxPlayers !== undefined) { + crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams); + } else { + // Map doesn't support crowded. Drop it and pick one replacement only + // if it was the sole modifier, so the lobby always has at least one. + isCrowded = false; + if (!isRandomSpawn && !isCompact && startingGold === undefined) { + excludedModifiers.push("isCrowded"); + ({ isRandomSpawn, isCompact, startingGold } = + this.getRandomSpecialGameModifiers(excludedModifiers, 1)); + } + } + } + + const maxPlayers = Math.max( + 2, + crowdedMaxPlayers ?? + (await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)), + ); + return { - donateGold: true, - donateTroops: true, + donateGold: mode === GameMode.Team, + donateTroops: mode === GameMode.Team, gameMap: map, - maxPlayers: 2, + maxPlayers, gameType: GameType.Public, - gameMapSize: GameMapSize.Normal, - difficulty: Difficulty.Easy, - rankedType: RankedType.OneVOne, + gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal, + publicGameModifiers: { + isCompact, + isRandomSpawn, + isCrowded, + startingGold, + }, + startingGold, + difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, + maxTimerValue: undefined, instantBuild: false, - randomSpawn: false, - disableNations: true, - gameMode: GameMode.Team, - playerTeams: HumansVsNations, - bots: 100, - spawnImmunityDuration: 5 * 10, + randomSpawn: isRandomSpawn, + disableNations: mode === GameMode.Team && playerTeams !== HumansVsNations, + gameMode: mode, + playerTeams, + bots: isCompact ? 100 : 400, + spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10, disabledUnits: [], } satisfies GameConfig; } @@ -234,21 +293,21 @@ export class MapPlaylist { private getNextMap(type: PublicGameType): GameMapType { const playlist = this.playlists[type]; if (playlist.length === 0) { - playlist.push(...this.generateNewPlaylist()); + playlist.push(...this.generateNewPlaylist(type)); } return playlist.shift()!; } - private generateNewPlaylist(): GameMapType[] { - const maps = this.buildMapsList(); + private generateNewPlaylist(type: PublicGameType): GameMapType[] { + const maps = this.buildMapsList(type); const rand = new PseudoRandom(Date.now()); - const shuffledSource = rand.shuffleArray([...maps]); const playlist: GameMapType[] = []; const numAttempts = 10000; for (let attempt = 0; attempt < numAttempts; attempt++) { playlist.length = 0; - const source = [...shuffledSource]; + // Re-shuffle every attempt so retries can explore different orderings. + const source = rand.shuffleArray([...maps]); let success = true; while (source.length > 0) { @@ -288,11 +347,15 @@ export class MapPlaylist { return false; } - private buildMapsList(): GameMapType[] { + private buildMapsList(type: PublicGameType): GameMapType[] { const maps: GameMapType[] = []; (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { + const map = GameMapType[key]; + if (type !== "special" && ARCADE_MAPS.has(map)) { + return; + } for (let i = 0; i < (frequency[key] ?? 0); i++) { - maps.push(GameMapType[key]); + maps.push(map); } }); return maps; @@ -321,6 +384,41 @@ export class MapPlaylist { }; } + private getRandomSpecialGameModifiers( + excludedModifiers: ModifierKey[] = [], + count?: number, + ): PublicGameModifiers { + // Roll how many modifiers to pick: 30% → 1, 40% → 2, 20% → 3, 10% → 4 + const modifierCountRoll = Math.floor(Math.random() * 10) + 1; + const k = + count ?? + (modifierCountRoll <= 3 + ? 1 + : modifierCountRoll <= 7 + ? 2 + : modifierCountRoll <= 9 + ? 3 + : 4); + + // Shuffle the pool, then pick the first k unique modifier keys. + const pool = SPECIAL_MODIFIER_POOL.filter( + (key) => !excludedModifiers.includes(key), + ).sort(() => Math.random() - 0.5); + + const selected = new Set(); + for (const key of pool) { + if (selected.size >= k) break; + selected.add(key); + } + + return { + isRandomSpawn: selected.has("isRandomSpawn"), + isCompact: selected.has("isCompact"), + isCrowded: selected.has("isCrowded"), + startingGold: selected.has("startingGold") ? 5_000_000 : undefined, + }; + } + // Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games // (not enough players after 75% player reduction for compact maps) private async supportsCompactMapForTeams(map: GameMapType): Promise { From 8754f5291f68b375e11d9d0e62ab1003b64057fa Mon Sep 17 00:00:00 2001 From: bijx Date: Sat, 28 Feb 2026 23:28:47 -0500 Subject: [PATCH 2/4] Feat: Alphanumeric Coordinate Grid on Alternate View (#2938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Adds a coordinate grid to the Alternate View (holding spacebar) using numbers on the X-axis, and letters on the Y-axis. No more "he's attacking you in that—well, the little peninsula thing... next to the island! which island? uhh..." moments when playing with friends. Optimally maps have letters A-J (just like in the Battleships board game) but special maps like Amazon River dynamically resize to only have 2 letters so as to not have too many number columns. This feature overall can be toggled via the settings menu. Also saw it requested on the [official discord](https://discord.com/channels/1359946986937258015/1457037351422263480) a couple times, thought it was a neat idea. ### World Map image ### Scales correctly when zoomed in image ### Amazon River image ### Enable/Disable via settings https://github.com/user-attachments/assets/ec9f4e07-70a1-4f9d-b137-c3c3d2a2540c ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: bijx --------- Co-authored-by: iamlewis --- resources/images/GridIconWhite.svg | 7 + resources/lang/en.json | 3 + src/client/HelpModal.ts | 9 + src/client/InputHandler.ts | 14 + src/client/UserSettingModal.ts | 11 + src/client/graphics/GameRenderer.ts | 2 + .../graphics/layers/CoordinateGridLayer.ts | 319 ++++++++++++++++++ 7 files changed, 365 insertions(+) create mode 100644 resources/images/GridIconWhite.svg create mode 100644 src/client/graphics/layers/CoordinateGridLayer.ts diff --git a/resources/images/GridIconWhite.svg b/resources/images/GridIconWhite.svg new file mode 100644 index 000000000..62f5d1592 --- /dev/null +++ b/resources/images/GridIconWhite.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 66dac140d..30b631518 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -89,6 +89,7 @@ "table_key": "Key", "table_action": "Action", "action_alt_view": "Alternate view (terrain/countries)", + "action_coordinate_grid": "Toggle coordinate grid overlay", "action_attack_altclick": "Attack (when left click is set to open menu)", "action_build": "Open build menu", "action_emote": "Open emote menu", @@ -514,6 +515,8 @@ "attack_ratio_desc": "What percentage of your troops to send in an attack (1–100%)", "territory_patterns_label": "🏳️ Territory Skins", "territory_patterns_desc": "Choose whether to display territory skin designs in game", + "coordinate_grid_label": "Coordinate Grid", + "coordinate_grid_desc": "Toggle the alphanumeric grid overlay", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 95ba88880..21fba5424 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -42,6 +42,7 @@ export class HelpModal extends BaseModal { const isMac = /Mac/.test(navigator.userAgent); return { toggleView: "Space", + coordinateGrid: "KeyM", centerCamera: "KeyC", moveUp: "KeyW", moveDown: "KeyS", @@ -265,6 +266,14 @@ export class HelpModal extends BaseModal { ${translateText("help_modal.action_alt_view")} + + + ${this.renderKey(keybinds.coordinateGrid)} + + + ${translateText("help_modal.action_coordinate_grid")} + + ${this.renderKey(keybinds.swapDirection)} diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 27eb82ede..1d8a21d77 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -129,6 +129,10 @@ export class AutoUpgradeEvent implements GameEvent { ) {} } +export class ToggleCoordinateGridEvent implements GameEvent { + constructor(public readonly enabled: boolean) {} +} + export class TickMetricsEvent implements GameEvent { constructor( public readonly tickExecutionDuration?: number, @@ -154,6 +158,7 @@ export class InputHandler { private moveInterval: NodeJS.Timeout | null = null; private activeKeys = new Set(); private keybinds: Record = {}; + private coordinateGridEnabled = false; private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; @@ -201,6 +206,7 @@ export class InputHandler { this.keybinds = { toggleView: "Space", + coordinateGrid: "KeyM", centerCamera: "KeyC", moveUp: "KeyW", moveDown: "KeyS", @@ -316,6 +322,14 @@ export class InputHandler { } } + if (e.code === this.keybinds.coordinateGrid && !e.repeat) { + e.preventDefault(); + this.coordinateGridEnabled = !this.coordinateGridEnabled; + this.eventBus.emit( + new ToggleCoordinateGridEvent(this.coordinateGridEnabled), + ); + } + if (e.code === "Escape") { e.preventDefault(); this.eventBus.emit(new CloseViewEvent()); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 73c0a14e6..7a6025a1c 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -22,6 +22,7 @@ const isMac = const DefaultKeybinds: Record = { toggleView: "Space", + coordinateGrid: "KeyM", buildCity: "Digit1", buildFactory: "Digit2", buildPort: "Digit3", @@ -491,6 +492,16 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + +

diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 98708b651..8956214d6 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -12,6 +12,7 @@ import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; +import { CoordinateGridLayer } from "./layers/CoordinateGridLayer"; import { DynamicUILayer } from "./layers/DynamicUILayer"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; @@ -282,6 +283,7 @@ export function createRenderer( new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus, transformHandler, userSettings), new RailroadLayer(game, eventBus, transformHandler, uiState), + new CoordinateGridLayer(game, eventBus, transformHandler), structureLayer, samRadiusLayer, new UnitLayer(game, eventBus, transformHandler), diff --git a/src/client/graphics/layers/CoordinateGridLayer.ts b/src/client/graphics/layers/CoordinateGridLayer.ts new file mode 100644 index 000000000..64c211750 --- /dev/null +++ b/src/client/graphics/layers/CoordinateGridLayer.ts @@ -0,0 +1,319 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { + AlternateViewEvent, + ToggleCoordinateGridEvent, +} from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; + +const BASE_CELL_COUNT = 10; +const MAX_COLUMNS = 50; +const MIN_ROWS = 2; +const LABEL_PADDING = 8; + +const toAlphaLabel = (index: number): string => { + let value = index; + let label = ""; + do { + label = String.fromCharCode(65 + (value % 26)) + label; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return label; +}; + +const computeGrid = (width: number, height: number) => { + // Initial square-ish estimate + let cellSize = Math.min(width, height) / BASE_CELL_COUNT; + let rows = Math.max(1, Math.round(height / cellSize)); + let cols = Math.max(1, Math.round(width / cellSize)); + + // Cap columns and adjust rows accordingly + if (cols > MAX_COLUMNS) { + const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width); + rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols)); + cols = MAX_COLUMNS; + } + + cellSize = Math.min(width / cols, height / rows); + const fullCols = Math.max(1, Math.floor(width / cellSize)); + const fullRows = Math.max(1, Math.floor(height / cellSize)); + + const remainderX = Math.max(0, width - fullCols * cellSize); + const remainderY = Math.max(0, height - fullRows * cellSize); + + const hasExtraCol = remainderX > 0.001; + const hasExtraRow = remainderY > 0.001; + + const totalCols = fullCols + (hasExtraCol ? 1 : 0); + const totalRows = fullRows + (hasExtraRow ? 1 : 0); + + const lastColWidth = hasExtraCol ? remainderX : cellSize; + const lastRowHeight = hasExtraRow ? remainderY : cellSize; + + return { + cellSize, + rows: totalRows, + cols: totalCols, + fullCols, + fullRows, + lastColWidth, + lastRowHeight, + hasExtraCol, + hasExtraRow, + gridWidth: width, + gridHeight: height, + }; +}; + +export class CoordinateGridLayer implements Layer { + private isVisible = false; + private alternateView = false; + private cachedGridCanvas: HTMLCanvasElement | null = null; + private cachedGridContext: CanvasRenderingContext2D | null = null; + private cachedGridKey = ""; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + ) {} + + init() { + this.eventBus.on(ToggleCoordinateGridEvent, (event) => { + this.isVisible = event.enabled; + }); + this.eventBus.on(AlternateViewEvent, (event) => { + this.alternateView = event.alternateView; + }); + } + + shouldTransform(): boolean { + return false; + } + + renderLayer(context: CanvasRenderingContext2D) { + if (!this.isVisible && !this.alternateView) return; + + const width = this.game.width(); + const height = this.game.height(); + if (width <= 0 || height <= 0) return; + const canvasWidth = context.canvas.width; + const canvasHeight = context.canvas.height; + + const cacheKey = this.buildCacheKey( + width, + height, + canvasWidth, + canvasHeight, + ); + const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight); + if (cacheContext === null || this.cachedGridCanvas === null) return; + + if (this.cachedGridKey !== cacheKey) { + cacheContext.clearRect(0, 0, canvasWidth, canvasHeight); + this.drawGrid(cacheContext, width, height); + this.cachedGridKey = cacheKey; + } + + context.drawImage(this.cachedGridCanvas, 0, 0); + } + + private ensureCacheContext( + canvasWidth: number, + canvasHeight: number, + ): CanvasRenderingContext2D | null { + this.cachedGridCanvas ??= document.createElement("canvas"); + + if ( + this.cachedGridCanvas.width !== canvasWidth || + this.cachedGridCanvas.height !== canvasHeight + ) { + this.cachedGridCanvas.width = canvasWidth; + this.cachedGridCanvas.height = canvasHeight; + this.cachedGridContext = null; + this.cachedGridKey = ""; + } + + this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d"); + + return this.cachedGridContext; + } + + private buildCacheKey( + width: number, + height: number, + canvasWidth: number, + canvasHeight: number, + ): string { + const topLeft = this.transformHandler.worldToScreenCoordinates( + new Cell(0, 0), + ); + const bottomRight = this.transformHandler.worldToScreenCoordinates( + new Cell(width, height), + ); + return [ + width, + height, + canvasWidth, + canvasHeight, + this.transformHandler.scale.toFixed(4), + topLeft.x.toFixed(2), + topLeft.y.toFixed(2), + bottomRight.x.toFixed(2), + bottomRight.y.toFixed(2), + ].join("|"); + } + + private drawGrid( + context: CanvasRenderingContext2D, + width: number, + height: number, + ) { + const { + cellSize, + rows, + cols, + fullCols, + fullRows, + lastColWidth, + lastRowHeight, + hasExtraCol, + hasExtraRow, + gridWidth, + gridHeight, + } = computeGrid(width, height); + const cellWidth = cellSize; + const cellHeight = cellSize; + const canvasWidth = context.canvas.width; + const canvasHeight = context.canvas.height; + + const mapTopScreenRaw = this.transformHandler.worldToScreenCoordinates( + new Cell(0, 0), + ).y; + const mapBottomScreenRaw = this.transformHandler.worldToScreenCoordinates( + new Cell(0, height), + ).y; + const mapLeftScreenRaw = this.transformHandler.worldToScreenCoordinates( + new Cell(0, 0), + ).x; + const mapRightScreenRaw = this.transformHandler.worldToScreenCoordinates( + new Cell(width, 0), + ).x; + + const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw); + const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw); + const mapTopWorld = 0; + const mapLeftWorld = 0; + + context.save(); + context.strokeStyle = "rgba(255, 255, 255, 0.35)"; + context.lineWidth = 1.25; + context.beginPath(); + + for (let col = 0; col <= fullCols; col++) { + const worldX = col * cellWidth + mapLeftWorld; + const screenX = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, mapTopWorld), + ).x; + if (screenX < -1 || screenX > canvasWidth + 1) continue; + const screenBottom = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, gridHeight), + ).y; + context.moveTo(screenX, mapTopScreen); + context.lineTo(screenX, screenBottom); + } + // Final vertical line at map right edge only if grid fits perfectly + if (!hasExtraCol) { + const mapRightLine = this.transformHandler.worldToScreenCoordinates( + new Cell(gridWidth, mapTopWorld), + ).x; + context.moveTo(mapRightLine, mapTopScreen); + context.lineTo( + mapRightLine, + this.transformHandler.worldToScreenCoordinates( + new Cell(gridWidth, gridHeight), + ).y, + ); + } + + for (let row = 0; row <= fullRows; row++) { + const worldY = row * cellHeight + mapTopWorld; + const screenY = this.transformHandler.worldToScreenCoordinates( + new Cell(mapLeftWorld, worldY), + ).y; + if (screenY < -1 || screenY > canvasHeight + 1) continue; + const screenRight = this.transformHandler.worldToScreenCoordinates( + new Cell(gridWidth, worldY), + ).x; + context.moveTo(mapLeftScreen, screenY); + context.lineTo(screenRight, screenY); + } + // Final horizontal line at map bottom edge only if grid fits perfectly + if (!hasExtraRow) { + const mapBottomLine = this.transformHandler.worldToScreenCoordinates( + new Cell(mapLeftWorld, gridHeight), + ).y; + context.moveTo(mapLeftScreen, mapBottomLine); + context.lineTo( + this.transformHandler.worldToScreenCoordinates( + new Cell(gridWidth, gridHeight), + ).x, + mapBottomLine, + ); + } + + context.stroke(); + + context.font = "12px monospace"; + + const drawLabel = (text: string, x: number, y: number) => { + context.textAlign = "left"; + context.textBaseline = "top"; + context.fillStyle = "rgba(20, 20, 20, 0.9)"; + context.fillText(text, x, y); + }; + + // Render per-cell labels (like A1, B1, etc.) at cell top-left + const fontSize = Math.min( + 16, + Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2), + ); + context.font = `${fontSize}px monospace`; + for (let row = 0; row < rows; row++) { + const rowLabel = toAlphaLabel(row); + const startY = row * cellHeight; + const rowHeight = row < fullRows ? cellHeight : lastRowHeight; + const centerY = startY + rowHeight / 2; + const screenY = this.transformHandler.worldToScreenCoordinates( + new Cell(0, centerY), + ).y; + if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING) + continue; + + for (let col = 0; col < cols; col++) { + const startX = col * cellWidth; + const colWidth = col < fullCols ? cellWidth : lastColWidth; + const centerX = startX + colWidth / 2; + const screenX = this.transformHandler.worldToScreenCoordinates( + new Cell(centerX, centerY), + ).x; + if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING) + continue; + + // Position at cell top-left in screen space + const cellTopLeft = this.transformHandler.worldToScreenCoordinates( + new Cell(startX, startY), + ); + drawLabel( + `${rowLabel}${col + 1}`, + cellTopLeft.x + LABEL_PADDING, + cellTopLeft.y + LABEL_PADDING, + ); + } + } + + context.restore(); + } +} From a9c89e4f15f1cb21051676d78b4cba085bd02455 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Sun, 1 Mar 2026 12:20:19 +0100 Subject: [PATCH 3/4] Fix: Nations reject alliance requests during spawn phase (#3312) ## Description: This PR fixes an exploit that allows the player to request alliances to Nations, mostly in impossible mode, during spawn phase, with high chances for it to be accepted due to troop count parity. Nations now reject alliance requests during the spawn phase. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: deshack_82603 --- .../execution/nation/NationAllianceBehavior.ts | 5 +++++ tests/NationAllianceBehavior.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 186a3b556..410e1a63e 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -76,6 +76,11 @@ export class NationAllianceBehavior { otherPlayer: Player, isResponse: boolean, ): boolean { + // Reject alliance requests during the spawn phase + if (this.game.inSpawnPhase()) { + return false; + } + // Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do) if (this.isConfused()) { return this.random.chance(2); diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts index ea2c74077..2849ac75b 100644 --- a/tests/NationAllianceBehavior.test.ts +++ b/tests/NationAllianceBehavior.test.ts @@ -51,6 +51,10 @@ describe("AllianceBehavior.handleAllianceRequests", () => { player, new NationEmojiBehavior(random, game, player), ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } }); function setupAllianceRequest({ @@ -92,6 +96,16 @@ describe("AllianceBehavior.handleAllianceRequests", () => { return mockRequest; } + test("should reject alliance during spawn phase", () => { + vi.spyOn(game, "inSpawnPhase").mockReturnValue(true); + const request = setupAllianceRequest({}); + + allianceBehavior.handleAllianceRequests(); + + expect(request.accept).not.toHaveBeenCalled(); + expect(request.reject).toHaveBeenCalled(); + }); + test("should accept alliance when all conditions are met", () => { const request = setupAllianceRequest({}); From 802cc7f16da73d9c48486f4899b9c227d191397f Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:11:00 +0000 Subject: [PATCH 4/4] Revert "Fix: Nations reject alliance requests during spawn phase" (#3313) ## Description: Reverts openfrontio/OpenFrontIO#3312 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- .../execution/nation/NationAllianceBehavior.ts | 5 ----- tests/NationAllianceBehavior.test.ts | 14 -------------- 2 files changed, 19 deletions(-) diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 410e1a63e..186a3b556 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -76,11 +76,6 @@ export class NationAllianceBehavior { otherPlayer: Player, isResponse: boolean, ): boolean { - // Reject alliance requests during the spawn phase - if (this.game.inSpawnPhase()) { - return false; - } - // Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do) if (this.isConfused()) { return this.random.chance(2); diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts index 2849ac75b..ea2c74077 100644 --- a/tests/NationAllianceBehavior.test.ts +++ b/tests/NationAllianceBehavior.test.ts @@ -51,10 +51,6 @@ describe("AllianceBehavior.handleAllianceRequests", () => { player, new NationEmojiBehavior(random, game, player), ); - - while (game.inSpawnPhase()) { - game.executeNextTick(); - } }); function setupAllianceRequest({ @@ -96,16 +92,6 @@ describe("AllianceBehavior.handleAllianceRequests", () => { return mockRequest; } - test("should reject alliance during spawn phase", () => { - vi.spyOn(game, "inSpawnPhase").mockReturnValue(true); - const request = setupAllianceRequest({}); - - allianceBehavior.handleAllianceRequests(); - - expect(request.accept).not.toHaveBeenCalled(); - expect(request.reject).toHaveBeenCalled(); - }); - test("should accept alliance when all conditions are met", () => { const request = setupAllianceRequest({});