Merge branch 'main' into trade

This commit is contained in:
Ryan
2026-03-01 21:57:44 +00:00
committed by GitHub
30 changed files with 1137 additions and 33 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

@@ -0,0 +1,15 @@
{
"name": "BeringStrait",
"nations": [
{
"coordinates": [1297, 287],
"name": "Alaska",
"flag": "us"
},
{
"coordinates": [186, 427],
"name": "Russia",
"flag": "ru"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

@@ -0,0 +1,65 @@
{
"name": "Bosphorus Straits",
"nations": [
{
"coordinates": [520, 300],
"name": "Istanbul",
"flag": "tr"
},
{
"coordinates": [360, 280],
"name": "Thrace",
"flag": "tr"
},
{
"coordinates": [220, 260],
"name": "Edirne",
"flag": "tr"
},
{
"coordinates": [650, 360],
"name": "Bursa",
"flag": "tr"
},
{
"coordinates": [690, 290],
"name": "Izmit",
"flag": "tr"
},
{
"coordinates": [430, 430],
"name": "Canakkale",
"flag": "tr"
},
{
"coordinates": [320, 330],
"name": "Tekirdag",
"flag": "tr"
},
{
"coordinates": [610, 320],
"name": "Yalova",
"flag": "tr"
},
{
"coordinates": [720, 260],
"name": "Kocaeli",
"flag": "tr"
},
{
"coordinates": [160, 120],
"name": "Varna",
"flag": "bg"
},
{
"coordinates": [220, 150],
"name": "Burgas",
"flag": "bg"
},
{
"coordinates": [820, 470],
"name": "Aegean Isles",
"flag": "gr"
}
]
}
+2
View File
@@ -29,7 +29,9 @@ var maps = []struct {
{Name: "baikal"},
{Name: "baikalnukewars"},
{Name: "betweentwoseas"},
{Name: "beringstrait"},
{Name: "blacksea"},
{Name: "bosphorusstraits"},
{Name: "britannia"},
{Name: "britanniaclassic"},
{Name: "deglaciatedantarctica"},
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" fill="none" stroke="white" stroke-width="2" />
<line x1="9" y1="3" x2="9" y2="21" stroke="white" stroke-width="2" />
<line x1="15" y1="3" x2="15" y2="21" stroke="white" stroke-width="2" />
<line x1="3" y1="9" x2="21" y2="9" stroke="white" stroke-width="2" />
<line x1="3" y1="15" x2="21" y2="15" stroke="white" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

+5
View File
@@ -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",
@@ -327,6 +328,8 @@
"didier": "Didier",
"didierfrance": "Didier (France)",
"amazonriver": "Amazon River",
"bosphorusstraits": "Bosphorus Straits",
"beringstrait": "Bering Strait",
"tradersdream": "Traders Dream",
"hawaii": "Hawaii",
"alps": "Alps"
@@ -512,6 +515,8 @@
"attack_ratio_desc": "What percentage of your troops to send in an attack (1100%)",
"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",
+30
View File
@@ -0,0 +1,30 @@
{
"map": {
"height": 916,
"num_land_tiles": 596318,
"width": 1500
},
"map16x": {
"height": 229,
"num_land_tiles": 35567,
"width": 375
},
"map4x": {
"height": 458,
"num_land_tiles": 146741,
"width": 750
},
"name": "BeringStrait",
"nations": [
{
"coordinates": [1297, 287],
"flag": "us",
"name": "Alaska"
},
{
"coordinates": [186, 427],
"flag": "ru",
"name": "Russia"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -0,0 +1,80 @@
{
"map": {
"height": 612,
"num_land_tiles": 387974,
"width": 1000
},
"map16x": {
"height": 153,
"num_land_tiles": 22991,
"width": 250
},
"map4x": {
"height": 306,
"num_land_tiles": 95321,
"width": 500
},
"name": "Bosphorus Straits",
"nations": [
{
"coordinates": [520, 300],
"flag": "tr",
"name": "Istanbul"
},
{
"coordinates": [360, 280],
"flag": "tr",
"name": "Thrace"
},
{
"coordinates": [220, 260],
"flag": "tr",
"name": "Edirne"
},
{
"coordinates": [650, 360],
"flag": "tr",
"name": "Bursa"
},
{
"coordinates": [690, 290],
"flag": "tr",
"name": "Izmit"
},
{
"coordinates": [430, 430],
"flag": "tr",
"name": "Canakkale"
},
{
"coordinates": [320, 330],
"flag": "tr",
"name": "Tekirdag"
},
{
"coordinates": [610, 320],
"flag": "tr",
"name": "Yalova"
},
{
"coordinates": [720, 260],
"flag": "tr",
"name": "Kocaeli"
},
{
"coordinates": [160, 120],
"flag": "bg",
"name": "Varna"
},
{
"coordinates": [220, 150],
"flag": "bg",
"name": "Burgas"
},
{
"coordinates": [820, 470],
"flag": "gr",
"name": "Aegean Isles"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+9
View File
@@ -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")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.coordinateGrid)}
</td>
<td class="py-3 border-b border-white/5 text-white/70">
${translateText("help_modal.action_coordinate_grid")}
</td>
</tr>
<tr class="hover:bg-white/5 transition-colors">
<td class="py-3 pl-4 border-b border-white/5">
${this.renderKey(keybinds.swapDirection)}
+14
View File
@@ -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<string>();
private keybinds: Record<string, string> = {};
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());
+11
View File
@@ -22,6 +22,7 @@ const isMac =
const DefaultKeybinds: Record<string, string> = {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
@@ -491,6 +492,16 @@ export class UserSettingModal extends BaseModal {
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="coordinateGrid"
label=${translateText("user_setting.coordinate_grid_label")}
description=${translateText("user_setting.coordinate_grid_desc")}
defaultKey=${DefaultKeybinds.coordinateGrid}
.value=${this.getKeyValue("coordinateGrid")}
.display=${this.getKeyChar("coordinateGrid")}
@change=${this.handleKeybindChange}
></setting-keybind>
<h2
class="text-blue-200 text-xl font-bold mt-8 mb-3 border-b border-white/10 pb-2"
>
+2
View File
@@ -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),
@@ -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();
}
}
@@ -28,6 +28,13 @@ export class NationAllianceBehavior {
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
// all requests created during the spawn phase.
if (req.createdAt() <= this.game.config().numSpawnPhaseTurns() + 1) {
req.reject();
continue;
}
if (this.getAllianceDecision(req.requestor(), true)) {
req.accept();
} else {
+4
View File
@@ -128,6 +128,8 @@ export enum GameMapType {
Didier = "Didier",
DidierFrance = "Didier France",
AmazonRiver = "Amazon River",
BosphorusStraits = "Bosphorus Straits",
BeringStrait = "Bering Strait",
Yenisei = "Yenisei",
TradersDream = "Traders Dream",
Hawaii = "Hawaii",
@@ -179,6 +181,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.TwoLakes,
GameMapType.StraitOfHormuz,
GameMapType.AmazonRiver,
GameMapType.BosphorusStraits,
GameMapType.BeringStrait,
GameMapType.Yenisei,
GameMapType.Hawaii,
GameMapType.Alps,
+1 -1
View File
@@ -17,7 +17,7 @@ export class Client {
public readonly roles: string[] | undefined,
public readonly flares: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public username: string,
public readonly uncensoredUsername: string,
public ws: WebSocket,
public readonly cosmetics: PlayerCosmetics | undefined,
+2 -1
View File
@@ -46,10 +46,11 @@ export class GameManager {
persistentID: string,
gameID: GameID,
lastTurn: number = 0,
newUsername?: string,
): boolean {
const game = this.games.get(gameID);
if (!game) return false;
return game.rejoinClient(ws, persistentID, lastTurn);
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
}
createGame(
+8
View File
@@ -257,10 +257,13 @@ export class GameServer {
// Attempt to reconnect a client by persistentID. Returns true if successful.
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
// from the original join to maintain consistency throughout the game session.
// Exception: in the pre-game lobby, the username is updated so players can
// rename between leaving and rejoining.
public rejoinClient(
ws: WebSocket,
persistentID: string,
lastTurn: number = 0,
newUsername?: string,
): boolean {
const clientID = this.getClientIdForPersistentId(persistentID);
if (!clientID) return false;
@@ -283,6 +286,11 @@ export class GameServer {
client.lastPing = Date.now();
this.markClientDisconnected(client.clientID, false);
// Allow username updates in the pre-game lobby
if (!this._hasStarted && newUsername !== undefined) {
client.username = newUsername;
}
client.ws = ws;
this.addListeners(client);
this.startLobbyInfoBroadcast();
+123 -23
View File
@@ -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.
@@ -64,6 +66,8 @@ const frequency: Partial<Record<GameMapName, number>> = {
DidierFrance: 1,
Didier: 1,
AmazonRiver: 3,
BosphorusStraits: 3,
BeringStrait: 4,
Sierpinski: 10,
TheBox: 3,
Yenisei: 6,
@@ -85,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<ModifierKey>(4).fill("isRandomSpawn"),
...Array<ModifierKey>(7).fill("isCompact"),
...Array<ModifierKey>(1).fill("isCrowded"),
...Array<ModifierKey>(6).fill("startingGold"),
];
export class MapPlaylist {
private playlists: Record<PublicGameType, GameMapType[]> = {
ffa: [],
@@ -92,8 +106,6 @@ export class MapPlaylist {
team: [],
};
constructor() {}
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
if (type === "special") {
return this.getSpecialConfig();
@@ -173,27 +185,76 @@ export class MapPlaylist {
} satisfies GameConfig;
}
private getSpecialConfig(): GameConfig {
// TODO: create better special configs.
private async getSpecialConfig(): Promise<GameConfig> {
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;
}
@@ -232,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) {
@@ -286,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;
@@ -319,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<ModifierKey>();
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<boolean> {
+14 -7
View File
@@ -356,8 +356,20 @@ export async function startWorker() {
}
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization
if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) {
// If successful, skip all authorization (but pass updated username
// so players can rename in the pre-game lobby)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
censoredUsername,
)
) {
return;
}
@@ -439,11 +451,6 @@ export async function startWorker() {
}
}
// Censor profane usernames server-side (don't reject, just rename)
const censoredUsername = privilegeRefresher
.get()
.censorUsername(clientMsg.username);
// Create client and add to game
const client = new Client(
generateID(),
+16 -1
View File
@@ -51,6 +51,10 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
player,
new NationEmojiBehavior(random, game, player),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
function setupAllianceRequest({
@@ -59,6 +63,7 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
numTilesPlayer = 10,
numTilesRequestor = 10,
alliancesCount = 0,
createdAtTick = game.ticks() + 1,
} = {}) {
if (isTraitor) requestor.markTraitor();
@@ -82,7 +87,7 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
const mockRequest = {
requestor: () => requestor,
recipient: () => player,
createdAt: () => 0 as unknown as Tick,
createdAt: () => createdAtTick as unknown as Tick,
accept: vi.fn(),
reject: vi.fn(),
} as unknown as AllianceRequest;
@@ -92,6 +97,16 @@ describe("AllianceBehavior.handleAllianceRequests", () => {
return mockRequest;
}
test("should reject alliance created on first post-spawn tick", () => {
const cutoff = game.config().numSpawnPhaseTurns() + 1;
const request = setupAllianceRequest({ createdAtTick: cutoff });
allianceBehavior.handleAllianceRequests();
expect(request.accept).not.toHaveBeenCalled();
expect(request.reject).toHaveBeenCalled();
});
test("should accept alliance when all conditions are met", () => {
const request = setupAllianceRequest({});