{
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
- renderPlayerFlag(player.flag()!, el);
+ renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
>
`
: html` e.preventDefault()}
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index b9804d2d1..ef8819a76 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -176,11 +176,9 @@ export class PlayerPanel extends LitElement implements Layer {
if (myPlayer !== null && myPlayer.isAlive()) {
this.actions = await myPlayer.actions(this.tile);
- if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) {
- const createdAt = this.actions.interaction.allianceCreatedAtTick;
- const durationTicks = this.g.config().allianceDuration();
- const expiryTick = createdAt + durationTicks;
- const remainingTicks = expiryTick - this.g.ticks();
+ if (this.actions?.interaction?.allianceExpiresAt !== undefined) {
+ const expiresAt = this.actions.interaction.allianceExpiresAt;
+ const remainingTicks = expiresAt - this.g.ticks();
if (remainingTicks > 0) {
const remainingSeconds = Math.max(
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts
index 199f28cf6..fa4a35b83 100644
--- a/src/client/graphics/layers/RadialMenu.ts
+++ b/src/client/graphics/layers/RadialMenu.ts
@@ -381,36 +381,6 @@ export class RadialMenu implements Layer {
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
- const color = disabled
- ? this.config.disabledColor
- : d.data.color || "#333333";
- path.attr("fill", color);
-
- const subMenu =
- this.params !== null ? d.data.subMenu?.(this.params) : null;
- if (
- subMenu &&
- subMenu.length > 0 &&
- !disabled &&
- !(
- this.currentLevel > 0 &&
- d.data.id === this.selectedItemId &&
- level === 0
- )
- ) {
- if (this.submenuHoverTimeout !== null) {
- window.clearTimeout(this.submenuHoverTimeout);
- }
-
- // Set a small delay before opening submenu to prevent accidental triggers
- this.submenuHoverTimeout = window.setTimeout(() => {
- if (this.navigationInProgress) return;
- this.navigationInProgress = true;
- this.selectedItemId = d.data.id;
- this.navigateToSubMenu(subMenu);
- this.updateCenterButtonState("back");
- }, 200);
- }
};
const onMouseOut = (d: d3.PieArcDatum
, path: any) => {
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
index 76e7a6978..aafd42fad 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -1,3 +1,4 @@
+import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
Cell,
@@ -25,6 +26,7 @@ import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
+import { EventBus } from "../../../core/EventBus";
export interface MenuElementParams {
myPlayer: PlayerView;
@@ -37,6 +39,7 @@ export interface MenuElementParams {
playerActionHandler: PlayerActionHandler;
playerPanel: PlayerPanel;
chatIntegration: ChatIntegration;
+ eventBus: EventBus;
closeMenu: () => void;
}
@@ -322,6 +325,32 @@ export const infoMenuElement: MenuElement = {
},
};
+function getAllEnabledUnits(myPlayer: boolean, config: Config): Set {
+ const Units: Set = new Set();
+
+ const addStructureIfEnabled = (unitType: UnitType) => {
+ if (!config.isUnitDisabled(unitType)) {
+ Units.add(unitType);
+ }
+ };
+
+ if (myPlayer) {
+ addStructureIfEnabled(UnitType.City);
+ addStructureIfEnabled(UnitType.DefensePost);
+ addStructureIfEnabled(UnitType.Port);
+ addStructureIfEnabled(UnitType.MissileSilo);
+ addStructureIfEnabled(UnitType.SAMLauncher);
+ addStructureIfEnabled(UnitType.Factory);
+ } else {
+ addStructureIfEnabled(UnitType.Warship);
+ addStructureIfEnabled(UnitType.HydrogenBomb);
+ addStructureIfEnabled(UnitType.MIRV);
+ addStructureIfEnabled(UnitType.AtomBomb);
+ }
+
+ return Units;
+}
+
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
@@ -332,21 +361,10 @@ export const buildMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
- const unitTypes: Set = new Set();
- if (params.selected === params.myPlayer) {
- unitTypes.add(UnitType.City);
- unitTypes.add(UnitType.DefensePost);
- unitTypes.add(UnitType.Port);
- unitTypes.add(UnitType.MissileSilo);
- unitTypes.add(UnitType.SAMLauncher);
- unitTypes.add(UnitType.Factory);
- } else {
- unitTypes.add(UnitType.Warship);
- unitTypes.add(UnitType.HydrogenBomb);
- unitTypes.add(UnitType.MIRV);
- unitTypes.add(UnitType.AtomBomb);
- }
-
+ const unitTypes: Set = getAllEnabledUnits(
+ params.selected === params.myPlayer,
+ params.game.config(),
+ );
const buildElements: MenuElement[] = flattenedBuildTable
.filter((item) => unitTypes.has(item.unitType))
.map((item: BuildItemDisplay) => ({
@@ -355,8 +373,10 @@ export const buildMenuElement: MenuElement = {
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
- !params.buildMenu.canBuild(item),
- color: params.buildMenu.canBuild(item) ? COLORS.building : undefined,
+ !params.buildMenu.canBuildOrUpgrade(item),
+ color: params.buildMenu.canBuildOrUpgrade(item)
+ ? COLORS.building
+ : undefined,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key || ""), className: "title" },
@@ -373,11 +393,15 @@ export const buildMenuElement: MenuElement = {
: null,
].filter((item): item is TooltipItem => item !== null),
action: (params: MenuElementParams) => {
- params.playerActionHandler.handleBuildUnit(
- item.unitType,
- params.game.x(params.tile),
- params.game.y(params.tile),
+ const buildableUnit = params.playerActions.buildableUnits.find(
+ (bu) => bu.type === item.unitType,
);
+ if (buildableUnit === undefined) {
+ return;
+ }
+ if (params.buildMenu.canBuildOrUpgrade(item)) {
+ params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
+ }
params.closeMenu();
},
}));
diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts
index 622b233ff..0303485c9 100644
--- a/src/client/graphics/layers/ReplayPanel.ts
+++ b/src/client/graphics/layers/ReplayPanel.ts
@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
-import { customElement, state } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
-import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
@@ -16,27 +15,26 @@ export class ReplayPanel extends LitElement implements Layer {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
+ @property({ type: Boolean })
+ visible: boolean = false;
+
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
- private _isSinglePlayer: boolean = false;
- @state()
- private _isVisible = false;
+ @property({ type: Boolean })
+ isSingleplayer = false;
- init() {
- this._isSinglePlayer =
- this.game?.config().gameConfig().gameType === GameType.Singleplayer;
- if (this._isSinglePlayer) {
- this.setVisible(true);
- }
+ createRenderRoot() {
+ return this; // Enable Tailwind CSS
}
- tick() {
- if (!this._isVisible && this.game?.config().isReplay()) {
- this.setVisible(true);
- }
+ init() {}
- this.requestUpdate();
+ tick() {
+ if (!this.visible) return;
+ if (this.game!.ticks() % 10 === 0) {
+ this.requestUpdate();
+ }
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
@@ -44,85 +42,45 @@ export class ReplayPanel extends LitElement implements Layer {
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
- renderLayer(context: CanvasRenderingContext2D) {
- // Render any necessary canvas elements
- }
-
- shouldTransform(): boolean {
+ renderLayer(_ctx: CanvasRenderingContext2D) {}
+ shouldTransform() {
return false;
}
- setVisible(visible: boolean) {
- this._isVisible = visible;
- this.requestUpdate();
- }
-
render() {
- if (!this._isVisible) {
- return html``;
- }
+ if (!this.visible) return html``;
return html`
e.preventDefault()}
+ @contextmenu=${(e: Event) => e.preventDefault()}
>
-
-
-
-
+ ${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")}
+ ${this.renderSpeedButton(ReplaySpeedMultiplier.normal, "×1")}
+ ${this.renderSpeedButton(ReplaySpeedMultiplier.fast, "×2")}
+ ${this.renderSpeedButton(ReplaySpeedMultiplier.fastest, "max")}
`;
}
- createRenderRoot() {
- return this; // Disable shadow DOM to allow Tailwind styles
+ private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
+ const isActive = this._replaySpeedMultiplier === value;
+ return html`
+
+ `;
}
}
diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts
index 081dbcb0b..c3a5f97cc 100644
--- a/src/client/graphics/layers/SpawnTimer.ts
+++ b/src/client/graphics/layers/SpawnTimer.ts
@@ -16,8 +16,11 @@ export class SpawnTimer implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
- this.ratios[0] =
- this.game.ticks() / this.game.config().numSpawnPhaseTurns();
+ // During spawn phase, only one segment filling full width
+ this.ratios = [
+ this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
+ ];
+ this.colors = ["rgba(0, 128, 255, 0.7)"];
return;
}
@@ -33,18 +36,17 @@ export class SpawnTimer implements Layer {
const team = player.team();
if (team === null) throw new Error("Team is null");
const tiles = teamTiles.get(team) ?? 0;
- const sum = tiles + player.numTilesOwned();
- teamTiles.set(team, sum);
+ teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
+
for (const [team, count] of teamTiles) {
const ratio = count / total;
- const color = theme.teamColor(team).toRgbString();
this.ratios.push(ratio);
- this.colors.push(color);
+ this.colors.push(theme.teamColor(team).toRgbString());
}
}
@@ -53,12 +55,23 @@ export class SpawnTimer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
- if (this.ratios === null) return;
- if (this.ratios.length === 0) return;
- if (this.colors.length === 0) return;
+ if (this.ratios.length === 0 || this.colors.length === 0) return;
const barHeight = 10;
const barWidth = this.transformHandler.width();
+ let yOffset: number;
+
+ if (this.game.inSpawnPhase()) {
+ // At spawn time, draw at top
+ yOffset = 0;
+ } else if (this.game.config().gameConfig().gameMode === GameMode.Team) {
+ // After spawn, only in team mode, offset based on screen width
+ const screenW = window.innerWidth;
+ yOffset = screenW > 1024 ? 80 : 58;
+ } else {
+ // Not spawn and not team mode: no bar
+ return;
+ }
let x = 0;
let filledRatio = 0;
@@ -67,7 +80,7 @@ export class SpawnTimer implements Layer {
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
- context.fillRect(x, 0, segmentWidth, barHeight);
+ context.fillRect(x, yOffset, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
@@ -76,8 +89,6 @@ export class SpawnTimer implements Layer {
}
function sumIterator(values: MapIterator) {
- // To use reduce, we'd need to allocate an array:
- // return Array.from(values).reduce((sum, v) => sum + v, 0);
let total = 0;
for (const value of values) {
total += value;
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
index ffdf680a5..708ced3b7 100644
--- a/src/client/graphics/layers/StructureIconsLayer.ts
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -1,4 +1,5 @@
import * as PIXI from "pixi.js";
+import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import factoryIcon from "../../../../resources/images/FactoryUnit.png";
@@ -17,16 +18,20 @@ class StructureRenderInfo {
constructor(
public unit: UnitView,
public owner: PlayerID,
- public pixiSprite: PIXI.Sprite,
+ public iconContainer: PIXI.Container,
+ public levelContainer: PIXI.Container,
+ public level: number = 0,
+ public underConstruction: boolean = true,
) {}
}
-const ZOOM_THRESHOLD = 2.8; // below this zoom level, structures are not rendered
+const ZOOM_THRESHOLD = 2.5;
const ICON_SIZE = 24;
-const OFFSET_ZOOM_Y = 15; // offset for the y position of the icon to avoid hiding the structure beneath
+const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath
export class StructureIconsLayer implements Layer {
private pixicanvas: HTMLCanvasElement;
- private stage: PIXI.Container;
+ private iconsStage: PIXI.Container;
+ private levelsStage: PIXI.Container;
private shouldRedraw: boolean = true;
private textureCache: Map = new Map();
private theme: Theme;
@@ -54,14 +59,26 @@ export class StructureIconsLayer implements Layer {
}
async setupRenderer() {
+ try {
+ await PIXI.Assets.load(bitmapFont);
+ } catch (error) {
+ console.error("Failed to load bitmap font:", error);
+ }
this.renderer = new PIXI.WebGLRenderer();
this.pixicanvas = document.createElement("canvas");
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
- this.stage = new PIXI.Container();
- this.stage.position.set(0, 0);
- this.stage.width = this.pixicanvas.width;
- this.stage.height = this.pixicanvas.height;
+
+ this.iconsStage = new PIXI.Container();
+ this.iconsStage.position.set(0, 0);
+ this.iconsStage.width = this.pixicanvas.width;
+ this.iconsStage.height = this.pixicanvas.height;
+
+ this.levelsStage = new PIXI.Container();
+ this.levelsStage.position.set(0, 0);
+ this.levelsStage.width = this.pixicanvas.width;
+ this.levelsStage.height = this.pixicanvas.height;
+
await this.renderer.init({
canvas: this.pixicanvas,
resolution: 1,
@@ -103,7 +120,7 @@ export class StructureIconsLayer implements Layer {
}
resizeCanvas() {
- if (this.renderer.view) {
+ if (this.renderer) {
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer.resize(innerWidth, innerHeight, 1);
@@ -119,47 +136,84 @@ export class StructureIconsLayer implements Layer {
if (unitView === undefined) return;
if (unitView.isActive()) {
- if (this.seenUnits.has(unitView)) {
- // check if owner has changed
- const render = this.renders.find(
- (r) => r.unit.id() === unitView.id(),
- );
- if (render) {
- this.ownerChangeCheck(render, unitView);
- }
- } else if (this.structures.has(unitView.type())) {
- // new unit, create render info
- this.seenUnits.add(unitView);
- const render = new StructureRenderInfo(
- unitView,
- unitView.owner().id(),
- this.createPixiSprite(unitView),
- );
- this.renders.push(render);
- this.computeNewLocation(render);
- this.shouldRedraw = true;
- }
- }
-
- if (!unitView.isActive() && this.seenUnits.has(unitView)) {
- const render = this.renders.find(
- (r) => r.unit.id() === unitView.id(),
- );
- if (render) {
- this.deleteStructure(render);
- }
- this.shouldRedraw = true;
- return;
+ this.handleActiveUnit(unitView);
+ } else if (this.seenUnits.has(unitView)) {
+ this.handleInactiveUnit(unitView);
}
});
}
+ private findRenderByUnit(
+ unitView: UnitView,
+ ): StructureRenderInfo | undefined {
+ return this.renders.find((render) => render.unit.id() === unitView.id());
+ }
+
+ private handleActiveUnit(unitView: UnitView) {
+ if (this.seenUnits.has(unitView)) {
+ const render = this.findRenderByUnit(unitView);
+ if (render) {
+ this.checkForConstructionState(render, unitView);
+ this.checkForOwnershipChange(render, unitView);
+ this.checkForLevelChange(render, unitView);
+ }
+ } else if (
+ this.structures.has(unitView.type()) ||
+ unitView.type() === UnitType.Construction
+ ) {
+ this.addNewStructure(unitView);
+ }
+ }
+
+ private handleInactiveUnit(unitView: UnitView) {
+ const render = this.findRenderByUnit(unitView);
+ if (render) {
+ this.deleteStructure(render);
+ this.shouldRedraw = true;
+ }
+ }
+
+ private checkForConstructionState(
+ render: StructureRenderInfo,
+ unit: UnitView,
+ ) {
+ if (
+ render.underConstruction &&
+ render.unit.type() !== UnitType.Construction
+ ) {
+ render.underConstruction = false;
+ render.iconContainer?.destroy();
+ render.iconContainer = this.createIconSprite(unit);
+ this.shouldRedraw = true;
+ }
+ }
+
+ private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) {
+ if (render.owner !== unit.owner().id()) {
+ render.owner = unit.owner().id();
+ render.iconContainer?.destroy();
+ render.iconContainer = this.createIconSprite(unit);
+ this.shouldRedraw = true;
+ }
+ }
+
+ private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) {
+ if (render.level !== unit.level()) {
+ render.level = unit.level();
+ render.iconContainer?.destroy();
+ render.levelContainer?.destroy();
+ render.iconContainer = this.createIconSprite(unit);
+ render.levelContainer = this.createLevelSprite(unit);
+ this.shouldRedraw = true;
+ }
+ }
+
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
- if (!this.renderer || this.transformHandler.scale > ZOOM_THRESHOLD) {
+ if (!this.renderer) {
return;
}
@@ -170,38 +224,52 @@ export class StructureIconsLayer implements Layer {
}
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
- this.renderer.render(this.stage);
+ if (this.transformHandler.scale > ZOOM_THRESHOLD) {
+ this.renderer.render(this.levelsStage);
+ } else {
+ this.renderer.render(this.iconsStage);
+ }
this.shouldRedraw = false;
}
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
- private ownerChangeCheck(render: StructureRenderInfo, unit: UnitView) {
- if (render.owner !== unit.owner().id()) {
- render.owner = unit.owner().id();
- render.pixiSprite?.destroy();
- render.pixiSprite = this.createPixiSprite(unit);
- this.shouldRedraw = true;
- }
- }
-
private createTexture(unit: UnitView): PIXI.Texture {
- const cacheKey = `${unit.owner().id()}-${unit.type()}`;
+ const isConstruction = unit.type() === UnitType.Construction;
+ const constructionType = unit.constructionType();
+ if (isConstruction && constructionType === undefined) {
+ console.warn(
+ `Unit ${unit.id()} is a construction but has no construction type.`,
+ );
+ return PIXI.Texture.EMPTY;
+ }
+ const structureType = isConstruction ? constructionType! : unit.type();
+ const cacheKey = isConstruction
+ ? `construction-${structureType}`
+ : `${unit.owner().id()}-${structureType}`;
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
+
const structureCanvas = document.createElement("canvas");
structureCanvas.width = ICON_SIZE;
structureCanvas.height = ICON_SIZE;
const context = structureCanvas.getContext("2d")!;
- context.fillStyle = this.theme
- .territoryColor(unit.owner())
- .lighten(0.1)
- .toRgbString();
- const borderColor = this.theme
- .borderColor(unit.owner())
- .darken(0.2)
- .toRgbString();
+
+ let borderColor: string;
+ if (isConstruction) {
+ context.fillStyle = "rgb(198, 198, 198)";
+ borderColor = "rgb(128, 127, 127)";
+ } else {
+ context.fillStyle = this.theme
+ .territoryColor(unit.owner())
+ .lighten(0.06)
+ .toRgbString();
+ borderColor = this.theme
+ .borderColor(unit.owner())
+ .darken(0.08)
+ .toRgbString();
+ }
context.strokeStyle = borderColor;
context.beginPath();
context.arc(
@@ -214,9 +282,9 @@ export class StructureIconsLayer implements Layer {
context.fill();
context.lineWidth = 1;
context.stroke();
- const structureInfo = this.structures.get(unit.type());
+ const structureInfo = this.structures.get(structureType);
if (!structureInfo?.image) {
- console.warn(`Image not loaded for unit type: ${unit.type()}`);
+ console.warn(`Image not loaded for unit type: ${structureType}`);
return PIXI.Texture.from(structureCanvas);
}
context.drawImage(
@@ -229,20 +297,65 @@ export class StructureIconsLayer implements Layer {
return texture;
}
- private createPixiSprite(unit: UnitView): PIXI.Sprite {
- const sprite = new PIXI.Sprite(this.createTexture(unit));
- sprite.anchor.set(0.5, 0.5);
+ private createLevelSprite(unit: UnitView): PIXI.Container {
+ return this.createUnitContainer(unit, {
+ addIcon: false,
+ stage: this.levelsStage,
+ });
+ }
+
+ private createIconSprite(unit: UnitView): PIXI.Container {
+ return this.createUnitContainer(unit, {
+ addIcon: true,
+ stage: this.iconsStage,
+ });
+ }
+
+ private createUnitContainer(
+ unit: UnitView,
+ options: { addIcon?: boolean; stage: PIXI.Container },
+ ): PIXI.Container {
+ const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldX = this.game.x(tile);
const worldY = this.game.y(tile);
const screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, worldY),
);
- sprite.x = screenPos.x;
- sprite.y = screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y;
- sprite.scale.set(Math.min(1, this.transformHandler.scale));
- this.stage.addChild(sprite);
- return sprite;
+
+ if (options.addIcon) {
+ const sprite = new PIXI.Sprite(this.createTexture(unit));
+ sprite.anchor.set(0.5, 0.5);
+ parentContainer.addChild(sprite);
+ }
+
+ if (unit.level() > 1) {
+ const text = new PIXI.BitmapText({
+ text: unit.level().toString(),
+ style: {
+ fontFamily: "round_6x6_modified",
+ fontSize: 12,
+ },
+ });
+ text.anchor.set(0.5, 0.5);
+ text.position.y = -ICON_SIZE / 2 - 2;
+ parentContainer.addChild(text);
+ }
+
+ const posX = Math.round(screenPos.x);
+ let posY = Math.round(screenPos.y);
+
+ if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
+ posY = Math.round(
+ screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
+ );
+ }
+
+ parentContainer.position.set(posX, posY);
+ parentContainer.scale.set(Math.min(1, this.transformHandler.scale));
+
+ options.stage.addChild(parentContainer);
+ return parentContainer;
}
private getImageColored(
@@ -268,9 +381,14 @@ export class StructureIconsLayer implements Layer {
new Cell(worldX, worldY),
);
screenPos.x = Math.round(screenPos.x);
- screenPos.y = Math.round(
- screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
- );
+ if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
+ // Adjust the y position based on zoom level to avoid hiding the structure beneath
+ screenPos.y = Math.round(
+ screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
+ );
+ } else {
+ screenPos.y = Math.round(screenPos.y);
+ }
// Check if the sprite is on screen (with margin for partial visibility)
const margin = ICON_SIZE;
@@ -281,19 +399,49 @@ export class StructureIconsLayer implements Layer {
screenPos.y - margin < this.pixicanvas.height;
if (onScreen) {
- render.pixiSprite.x = screenPos.x;
- render.pixiSprite.y = screenPos.y;
- render.pixiSprite.scale.set(Math.min(1, this.transformHandler.scale));
+ if (this.transformHandler.scale > ZOOM_THRESHOLD) {
+ render.levelContainer.x = screenPos.x;
+ render.levelContainer.y = screenPos.y;
+ } else {
+ render.iconContainer.x = screenPos.x;
+ render.iconContainer.y = screenPos.y;
+ render.iconContainer.scale.set(
+ Math.min(1, this.transformHandler.scale),
+ );
+ }
}
if (render.isOnScreen !== onScreen) {
// prevent unnecessary updates
render.isOnScreen = onScreen;
- render.pixiSprite.visible = onScreen;
+ render.iconContainer.visible = onScreen;
+ render.levelContainer.visible = onScreen;
}
}
+ private addNewStructure(unitView: UnitView) {
+ this.seenUnits.add(unitView);
+ const render = new StructureRenderInfo(
+ unitView,
+ unitView.owner().id(),
+ this.createUnitContainer(unitView, {
+ addIcon: true,
+ stage: this.iconsStage,
+ }),
+ this.createUnitContainer(unitView, {
+ addIcon: false,
+ stage: this.levelsStage,
+ }),
+ unitView.level(),
+ unitView.type() === UnitType.Construction,
+ );
+ this.renders.push(render);
+ this.computeNewLocation(render);
+ this.shouldRedraw = true;
+ }
+
private deleteStructure(render: StructureRenderInfo) {
- render.pixiSprite?.destroy();
+ render.iconContainer?.destroy();
+ render.levelContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
}
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index 4c68f222b..ee576cc67 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -1,10 +1,8 @@
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
-import { MouseUpEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
-import { UnitInfoModal } from "./UnitInfoModal";
import cityIcon from "../../../../resources/non-commercial/images/buildings/cityAlt1.png";
import factoryIcon from "../../../../resources/non-commercial/images/buildings/factoryAlt1.png";
@@ -18,12 +16,12 @@ import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
-const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
// Base radius values and scaling factor for unit borders and territories
const BASE_BORDER_RADIUS = 16.5;
const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
+const ZOOM_THRESHOLD = 2.5; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
icon: string;
@@ -36,8 +34,6 @@ export class StructureLayer implements Layer {
private context: CanvasRenderingContext2D;
private unitIcons: Map = new Map();
private theme: Theme;
- private selectedStructureUnit: UnitView | null = null;
- private previouslySelected: UnitView | null = null;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
@@ -79,14 +75,7 @@ export class StructureLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
- private unitInfoModal: UnitInfoModal | null,
) {
- if (!unitInfoModal) {
- throw new Error(
- "UnitInfoModal instance must be provided to StructureLayer.",
- );
- }
- this.unitInfoModal = unitInfoModal;
this.theme = game.config().theme();
this.tempCanvas = document.createElement("canvas");
const tempContext = this.tempCanvas.getContext("2d");
@@ -131,7 +120,6 @@ export class StructureLayer implements Layer {
init() {
this.redraw();
- this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
}
redraw() {
@@ -151,6 +139,9 @@ export class StructureLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
+ if (this.transformHandler.scale <= ZOOM_THRESHOLD) {
+ return;
+ }
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -224,9 +215,6 @@ export class StructureLayer implements Layer {
if (!unit.isActive()) return;
- if (this.selectedStructureUnit === unit) {
- borderColor = selectedUnitColor;
- }
this.drawBorder(unit, borderColor, config);
// Render icon at 1/2 scale for better quality
@@ -279,84 +267,4 @@ export class StructureLayer implements Layer {
clearCell(cell: Cell) {
this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2);
}
-
- private findStructureUnitAtCell(
- cell: { x: number; y: number },
- maxDistance: number = 10,
- ): UnitView | null {
- const targetRef = this.game.ref(cell.x, cell.y);
-
- const allUnitTypes = Object.values(UnitType);
-
- const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes);
-
- for (const { unit } of nearby) {
- if (unit.isActive() && this.isUnitTypeSupported(unit.type())) {
- return unit;
- }
- }
-
- return null;
- }
-
- private onMouseUp(event: MouseUpEvent) {
- const cell = this.transformHandler.screenToWorldCoordinates(
- event.x,
- event.y,
- );
- if (!this.game.isValidCoord(cell.x, cell.y)) {
- return;
- }
-
- const clickedUnit = this.findStructureUnitAtCell(cell);
- this.previouslySelected = this.selectedStructureUnit;
-
- if (clickedUnit) {
- if (clickedUnit.owner() !== this.game.myPlayer()) {
- return;
- }
- const wasSelected = this.previouslySelected === clickedUnit;
- if (wasSelected) {
- this.selectedStructureUnit = null;
- if (this.previouslySelected) {
- this.handleUnitRendering(this.previouslySelected);
- }
- this.unitInfoModal?.onCloseStructureModal();
- } else {
- this.selectedStructureUnit = clickedUnit;
- if (
- this.previouslySelected &&
- this.previouslySelected !== clickedUnit
- ) {
- this.handleUnitRendering(this.previouslySelected);
- }
- this.handleUnitRendering(clickedUnit);
-
- const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
- const unitTile = clickedUnit.tile();
- this.unitInfoModal?.onOpenStructureModal({
- eventBus: this.eventBus,
- unit: clickedUnit,
- x: screenPos.x,
- y: screenPos.y,
- tileX: this.game.x(unitTile),
- tileY: this.game.y(unitTile),
- });
- }
- } else {
- this.selectedStructureUnit = null;
- if (this.previouslySelected) {
- this.handleUnitRendering(this.previouslySelected);
- }
- this.unitInfoModal?.onCloseStructureModal();
- }
- }
-
- public unSelectStructureUnit() {
- if (this.selectedStructureUnit) {
- this.previouslySelected = this.selectedStructureUnit;
- this.selectedStructureUnit = null;
- this.handleUnitRendering(this.previouslySelected);
- }
- }
}
diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts
index 459b2ab1d..872feb42c 100644
--- a/src/client/graphics/layers/TeamStats.ts
+++ b/src/client/graphics/layers/TeamStats.ts
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
-import { renderNumber } from "../../Utils";
+import { renderNumber, translateText } from "../../Utils";
import { Layer } from "./Layer";
interface TeamEntry {
@@ -113,22 +113,22 @@ export class TeamStats extends LitElement implements Layer {
- Team
+ ${translateText("leaderboard.team")}
- Owned
+ ${translateText("leaderboard.owned")}
- Gold
+ ${translateText("leaderboard.gold")}
- Troops
+ ${translateText("leaderboard.troops")}
${this.teams.map(
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 28d5def51..59cc8a79e 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -307,7 +307,7 @@ export class TerritoryLayer implements Layer {
this.paintTile(tile, useBorderColor, 255);
}
} else {
- const pattern = owner.pattern();
+ const pattern = owner.cosmetics.pattern;
const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false;
if (pattern === undefined || patternsEnabled === false) {
this.paintTile(tile, this.theme.territoryColor(owner), 150);
@@ -317,13 +317,10 @@ export class TerritoryLayer implements Layer {
const baseColor = this.theme.territoryColor(owner);
const decoder = owner.patternDecoder();
- if (decoder !== undefined) {
- const bit = decoder.isSet(x, y) ? 1 : 0;
- const colorToUse = bit ? baseColor.darken(0.2) : baseColor;
- this.paintTile(tile, colorToUse, 150);
- } else {
- this.paintTile(tile, baseColor, 150);
- }
+ const color = decoder?.isSet(x, y)
+ ? baseColor.darken(0.125)
+ : baseColor;
+ this.paintTile(tile, color, 150);
}
}
}
diff --git a/src/client/graphics/layers/TopBar.ts b/src/client/graphics/layers/TopBar.ts
deleted file mode 100644
index c6d58536a..000000000
--- a/src/client/graphics/layers/TopBar.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { LitElement, html } from "lit";
-import { customElement } from "lit/decorators.js";
-import { translateText } from "../../../client/Utils";
-import { GameView } from "../../../core/game/GameView";
-import { renderNumber, renderTroops } from "../../Utils";
-import { Layer } from "./Layer";
-
-@customElement("top-bar")
-export class TopBar extends LitElement implements Layer {
- public game: GameView;
- private isVisible = false;
- private _population = 0;
- private _lastPopulationIncreaseRate = 0;
- private _popRateIsIncreasing = false;
-
- createRenderRoot() {
- return this;
- }
-
- init() {
- this.isVisible = true;
- this.requestUpdate();
- }
-
- tick() {
- this.updatePopulationIncrease();
- this.requestUpdate();
- }
-
- private updatePopulationIncrease() {
- const player = this.game?.myPlayer();
- if (player === null) return;
- const popIncreaseRate = player.population() - this._population;
- if (this.game.ticks() % 5 === 0) {
- this._popRateIsIncreasing =
- popIncreaseRate >= this._lastPopulationIncreaseRate;
- this._lastPopulationIncreaseRate = popIncreaseRate;
- }
- }
-
- render() {
- if (!this.isVisible) {
- return html``;
- }
-
- const myPlayer = this.game?.myPlayer();
- if (!myPlayer?.isAlive() || this.game?.inSpawnPhase()) {
- return html``;
- }
-
- const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10;
- const maxPop = this.game.config().maxPopulation(myPlayer);
- const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n;
-
- return html`
-
-
-
- ${translateText("control_panel.pop")}:
- ${renderTroops(myPlayer.population())} /
- ${renderTroops(maxPop)}
- (+${renderTroops(popRate)})
-
-
-
- ${translateText("control_panel.gold")}:
- ${renderNumber(myPlayer.gold())}
- (+${renderNumber(goldPerSecond)})
-
-
- `;
- }
-}
diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts
index a743c5ae7..f3ba9711f 100644
--- a/src/client/graphics/layers/UILayer.ts
+++ b/src/client/graphics/layers/UILayer.ts
@@ -340,7 +340,11 @@ export class UILayer implements Layer {
// full hp/dead warships dont need a hp bar
this.allHealthBars.get(unit.id())?.clear();
this.allHealthBars.delete(unit.id());
- } else if (unit.health() < maxHealth && unit.health() > 0) {
+ } else if (
+ unit.isActive() &&
+ unit.health() < maxHealth &&
+ unit.health() > 0
+ ) {
this.allHealthBars.get(unit.id())?.clear();
const healthBar = new ProgressBar(
COLOR_PROGRESSION,
diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts
deleted file mode 100644
index a8d34be30..000000000
--- a/src/client/graphics/layers/UnitInfoModal.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import { LitElement, css, html } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { translateText } from "../../../client/Utils";
-import { EventBus } from "../../../core/EventBus";
-import { UnitType } from "../../../core/game/Game";
-import { GameView, UnitView } from "../../../core/game/GameView";
-import {
- SendCreateTrainStationIntentEvent,
- SendUpgradeStructureIntentEvent,
-} from "../../Transport";
-import { Layer } from "./Layer";
-import { StructureLayer } from "./StructureLayer";
-
-@customElement("unit-info-modal")
-export class UnitInfoModal extends LitElement implements Layer {
- @property({ type: Boolean }) open = false;
- @property({ type: Number }) x = 0;
- @property({ type: Number }) y = 0;
- @property({ type: Object }) unit: UnitView | null = null;
-
- public game: GameView;
- public structureLayer: StructureLayer | null = null;
- private eventBus: EventBus;
-
- constructor() {
- super();
- }
-
- init() {}
-
- tick() {
- if (this.unit) {
- this.requestUpdate();
- }
- }
-
- public onOpenStructureModal = ({
- eventBus,
- unit,
- x,
- y,
- tileX,
- tileY,
- }: {
- eventBus: EventBus;
- unit: UnitView;
- x: number;
- y: number;
- tileX: number;
- tileY: number;
- }) => {
- if (!this.game) return;
- this.x = x;
- this.y = y;
- this.eventBus = eventBus;
- const targetRef = this.game.ref(tileX, tileY);
-
- const allUnitTypes = Object.values(UnitType);
- const matchingUnits = this.game.nearbyUnits(
- targetRef,
- 10,
- allUnitTypes,
- ({ unit }) => unit.isActive(),
- );
-
- if (matchingUnits.length > 0) {
- matchingUnits.sort((a, b) => a.distSquared - b.distSquared);
- this.unit = matchingUnits[0].unit;
- } else {
- this.unit = null;
- }
- this.open = this.unit !== null;
- };
-
- public onCloseStructureModal = () => {
- this.open = false;
- this.unit = null;
- };
-
- connectedCallback() {
- super.connectedCallback();
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- }
-
- private buildUnitTypeTranslationString(): string {
- if (!this.unit) return "unit_type.unknown"; // fallback stays the same
- const unitType = this.unit.type().toLowerCase().replace(/\s+/g, "_");
- return `unit_type.${unitType}`;
- }
-
- static styles = css`
- :host {
- position: fixed;
- pointer-events: none;
- z-index: 1000;
- }
-
- .modal {
- pointer-events: auto;
- background: rgba(30, 30, 30, 0.95);
- color: #f8f8f8;
- border: 1px solid #555;
- padding: 12px 18px;
- border-radius: 8px;
- min-width: 220px;
- max-width: 300px;
- box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
- font-family: "Segoe UI", sans-serif;
- font-size: 15px;
- line-height: 1.6;
- backdrop-filter: blur(6px);
- position: relative;
- }
-
- .modal strong {
- color: #e0e0e0;
- }
-
- .close-button {
- background: #d00;
- color: #fff;
- border: none;
- border-radius: 4px;
- font-size: 14px;
- font-weight: bold;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- padding: 6px 12px;
- }
-
- .close-button:hover {
- background: #a00;
- }
-
- .upgrade-button {
- background: #3a0;
- color: #fff;
- border: none;
- border-radius: 4px;
- font-size: 14px;
- font-weight: bold;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- padding: 6px 12px;
- }
-
- .upgrade-button:hover {
- background: #0a0;
- }
- `;
-
- render() {
- if (!this.unit) return null;
-
- const ticksLeftInCooldown = this.unit.ticksLeftInCooldown();
- let configTimer;
- switch (this.unit.type()) {
- case UnitType.MissileSilo:
- configTimer = this.game.config().SiloCooldown();
- break;
- case UnitType.SAMLauncher:
- configTimer = this.game.config().SAMCooldown();
- break;
- }
- let cooldown = 0;
- if (ticksLeftInCooldown !== undefined && configTimer !== undefined) {
- cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown);
- }
- const secondsLeft = Math.ceil(cooldown / 10);
-
- return html`
-
-
- ${translateText("unit_info_modal.structure_info")}
-
-
- ${translateText("unit_info_modal.type")}:
- ${translateText(this.buildUnitTypeTranslationString()) ??
- translateText("unit_info_modal.unit_type_unknown")}
- ${translateText("unit_info_modal.level")}:
- ${this.game.unitInfo(this.unit.type()).upgradable &&
- this.unit.level?.()
- ? this.unit.level?.()
- : ""}
-
- ${secondsLeft > 0
- ? html`
- ${translateText("unit_info_modal.cooldown")}
- ${secondsLeft}s
-
`
- : ""}
-
-
-
-
-
-
- `;
- }
-}
diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts
index c22dea5f9..2e3b4667b 100644
--- a/src/client/graphics/layers/WinModal.ts
+++ b/src/client/graphics/layers/WinModal.ts
@@ -249,7 +249,9 @@ export class WinModal extends LitElement implements Layer {
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
- if (wu.winner[0] === "team") {
+ if (wu.winner === undefined) {
+ // ...
+ } else if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
@@ -260,8 +262,8 @@ export class WinModal extends LitElement implements Layer {
}
this.show();
} else {
- const winner = this.game.playerBySmallID(wu.winner[1]);
- if (!winner.isPlayer()) return;
+ const winner = this.game.playerByClientID(wu.winner[1]);
+ if (!winner?.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
diff --git a/src/client/index.html b/src/client/index.html
index 9a8ee7255..c903ea953 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -262,7 +262,6 @@
-
@@ -360,7 +356,8 @@
-
+
+
@@ -369,7 +366,6 @@
-
diff --git a/src/client/jwt.ts b/src/client/jwt.ts
index 684a737dc..da5af1818 100644
--- a/src/client/jwt.ts
+++ b/src/client/jwt.ts
@@ -14,7 +14,7 @@ function getAudience() {
return domainname;
}
-function getApiBase() {
+export function getApiBase() {
const domainname = getAudience();
return domainname === "localhost"
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
@@ -47,6 +47,12 @@ export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
+export function getAuthHeader(): string {
+ const token = getToken();
+ if (!token) return "";
+ return `Bearer ${token}`;
+}
+
export async function logOut(allSessions: boolean = false) {
const token = localStorage.getItem("token");
if (token === null) return;
diff --git a/src/client/styles.css b/src/client/styles.css
index dba13d5d7..05950fe70 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -37,6 +37,14 @@
background: rgba(255, 255, 255, 0.3);
}
+.hide-scrollbar {
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* IE/Edge */
+}
+.hide-scrollbar::-webkit-scrollbar {
+ display: none; /* Chrome, Safari */
+}
+
.start-game-button {
width: 100%;
max-width: 300px;
@@ -483,6 +491,7 @@ label.option-card:hover {
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
+ max-height: 400px;
}
#error-modal button.copy-btn {
diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts
index c74aaf7ef..028f196fa 100644
--- a/src/client/utilities/RenderUnitTypeOptions.ts
+++ b/src/client/utilities/RenderUnitTypeOptions.ts
@@ -18,6 +18,8 @@ const unitOptions: { type: UnitType; translationKey: string }[] = [
{ type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
{ type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
{ type: UnitType.MIRV, translationKey: "unit_type.mirv" },
+ { type: UnitType.Train, translationKey: "unit_type.train" },
+ { type: UnitType.Factory, translationKey: "unit_type.factory" },
];
export function renderUnitTypeOptions({
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index 07d97b126..1586b208e 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -17,6 +17,8 @@ export const CosmeticsSchema = z.object({
z.string(),
z.object({
name: z.string(),
+ role_group: z.string().optional(),
+ flares: z.array(z.string()).optional(),
}),
),
color: z.record(
@@ -24,6 +26,8 @@ export const CosmeticsSchema = z.object({
z.object({
color: z.string(),
name: z.string(),
+ role_group: z.string().optional(),
+ flares: z.array(z.string()).optional(),
}),
),
}),
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index f2740c3db..c2567aeb3 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -42,8 +42,6 @@ export async function createGameRunner(
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
- p.pattern,
- p.flag,
p.clientID === clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
@@ -60,14 +58,7 @@ export async function createGameRunner(
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
- new PlayerInfo(
- undefined,
- n.flag || "",
- n.name,
- PlayerType.FakeHuman,
- null,
- random.nextID(),
- ),
+ new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()),
),
);
@@ -205,12 +196,13 @@ export class GameRunner {
};
const alliance = player.allianceWith(other as Player);
if (alliance) {
- actions.interaction.allianceCreatedAtTick = alliance.createdAt();
+ actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
}
return actions;
}
+
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
diff --git a/src/core/PatternDecoder.ts b/src/core/PatternDecoder.ts
index e489bceef..1311aec44 100644
--- a/src/core/PatternDecoder.ts
+++ b/src/core/PatternDecoder.ts
@@ -1,13 +1,15 @@
-import { base64url } from "jose";
-
export class PatternDecoder {
private bytes: Uint8Array;
- private tileWidth: number;
- private tileHeight: number;
- private scale: number;
- constructor(base64: string) {
- this.bytes = base64url.decode(base64);
+ readonly height: number;
+ readonly width: number;
+ readonly scale: number;
+
+ constructor(
+ base64: string,
+ base64urlDecode: (input: Uint8Array | string) => Uint8Array,
+ ) {
+ this.bytes = base64urlDecode(base64);
if (this.bytes.length < 3) {
throw new Error(
@@ -24,10 +26,10 @@ export class PatternDecoder {
const byte2 = this.bytes[2];
this.scale = byte1 & 0x07;
- this.tileWidth = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
- this.tileHeight = ((byte2 >> 2) & 0x3f) + 2;
+ this.width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
+ this.height = ((byte2 >> 2) & 0x3f) + 2;
- const expectedBits = this.tileWidth * this.tileHeight;
+ const expectedBits = this.width * this.height;
const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8);
if (this.bytes.length - 3 < expectedBytes) {
throw new Error(
@@ -36,26 +38,22 @@ export class PatternDecoder {
}
}
- getTileWidth(): number {
- return this.tileWidth;
- }
-
- getTileHeight(): number {
- return this.tileHeight;
- }
-
- getScale(): number {
- return this.scale;
- }
-
isSet(x: number, y: number): boolean {
- const px = (x >> this.scale) % this.tileWidth;
- const py = (y >> this.scale) % this.tileHeight;
- const idx = py * this.tileWidth + px;
+ const px = (x >> this.scale) % this.width;
+ const py = (y >> this.scale) % this.height;
+ const idx = py * this.width + px;
const byteIndex = idx >> 3;
const bitIndex = idx & 7;
const byte = this.bytes[3 + byteIndex];
if (byte === undefined) throw new Error("Invalid pattern");
return (byte & (1 << bitIndex)) !== 0;
}
+
+ scaledHeight(): number {
+ return this.height << this.scale;
+ }
+
+ scaledWidth(): number {
+ return this.width << this.scale;
+ }
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 9e5efca5b..8bdf09c5e 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -1,3 +1,4 @@
+import { base64url } from "jose";
import { z } from "zod/v4";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
import {
@@ -25,6 +26,7 @@ export type Intent =
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
+ | AllianceExtensionIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
@@ -67,6 +69,9 @@ export type QuickChatIntent = z.infer
;
export type MarkDisconnectedIntent = z.infer<
typeof MarkDisconnectedIntentSchema
>;
+export type AllianceExtensionIntent = z.infer<
+ typeof AllianceExtensionIntentSchema
+>;
export type Turn = z.infer;
export type GameConfig = z.infer;
@@ -170,12 +175,12 @@ export const UsernameSchema = SafeString;
export const FlagSchema = z.string().max(128).optional();
export const RequiredPatternSchema = z
.string()
- .max(128)
+ .max(1403)
.base64url()
.refine(
(val) => {
try {
- new PatternDecoder(val);
+ new PatternDecoder(val, base64url.decode);
return true;
} catch (e) {
console.error(JSON.stringify(e.message, null, 2));
@@ -217,6 +222,11 @@ const BaseIntentSchema = z.object({
clientID: ID,
});
+export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("allianceExtension"),
+ recipient: ID,
+});
+
export const AttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("attack"),
targetID: ID.nullable(),
@@ -358,6 +368,7 @@ const IntentSchema = z.discriminatedUnion("type", [
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
+ AllianceExtensionIntentSchema,
]);
//
@@ -386,8 +397,8 @@ export const GameStartInfoSchema = z.object({
export const WinnerSchema = z
.union([
- z.tuple([z.literal("player"), ID]),
- z.tuple([z.literal("team"), SafeString]),
+ z.tuple([z.literal("player"), ID]).rest(ID),
+ z.tuple([z.literal("team"), SafeString]).rest(ID),
])
.optional();
export type Winner = z.infer;
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index e2c953bc5..461d99c55 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -62,6 +62,7 @@ export interface ServerConfig {
cloudflareApiToken(): string;
cloudflareConfigPath(): string;
cloudflareCredsPath(): string;
+ stripePublishableKey(): string;
}
export interface NukeMagnitude {
@@ -158,6 +159,7 @@ export interface Config {
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
+ allianceExtensionPromptOffset(): number;
}
export interface Theme {
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 6ad3075fd..69fb22df0 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -66,6 +66,9 @@ const numPlayersConfig = {
} as const satisfies Record;
export abstract class DefaultServerConfig implements ServerConfig {
+ stripePublishableKey(): string {
+ return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
+ }
domain(): string {
return process.env.DOMAIN ?? "";
}
@@ -199,6 +202,11 @@ export class DefaultConfig implements Config {
private _userSettings: UserSettings | null,
private _isReplay: boolean,
) {}
+
+ stripePublishableKey(): string {
+ return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
+ }
+
isReplay(): boolean {
return this._isReplay;
}
@@ -422,7 +430,6 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
- upgradable: true,
};
case UnitType.SAMLauncher:
return {
@@ -469,6 +476,8 @@ export class DefaultConfig implements Config {
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
canBuildTrainStation: true,
+ experimental: true,
+ upgradable: true,
};
case UnitType.Construction:
return {
@@ -479,6 +488,7 @@ export class DefaultConfig implements Config {
return {
cost: () => 0n,
territoryBound: false,
+ experimental: true,
};
default:
assertNever(type);
@@ -837,4 +847,8 @@ export class DefaultConfig implements Config {
defensePostTargettingRange(): number {
return 75;
}
+
+ allianceExtensionPromptOffset(): number {
+ return 300; // 30 seconds before expiration
+ }
}
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 51c07c212..1926d9991 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -122,6 +122,20 @@ export class AttackExecution implements Execution {
// 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
+ if (incoming.troops() > this.attack.troops()) {
+ incoming.setTroops(incoming.troops() - this.attack.troops());
+ this.attack.delete();
+ this.active = false;
+ return;
+ } else {
+ this.attack.setTroops(this.attack.troops() - incoming.troops());
+ incoming.delete();
+ }
+ }
+ }
for (const outgoing of this._owner.outgoingAttacks()) {
if (
outgoing !== this.attack &&
diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts
index 9b9a00bb2..134a7c666 100644
--- a/src/core/execution/BotSpawner.ts
+++ b/src/core/execution/BotSpawner.ts
@@ -46,14 +46,7 @@ export class BotSpawner {
}
}
return new SpawnExecution(
- new PlayerInfo(
- undefined,
- "",
- botName,
- PlayerType.Bot,
- null,
- this.random.nextID(),
- ),
+ new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()),
tile,
);
}
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index a7284da81..eddd3f85c 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -1,4 +1,5 @@
import {
+ Cell,
Execution,
Game,
Gold,
@@ -26,15 +27,37 @@ export class ConstructionExecution implements Execution {
private ticksUntilComplete: Tick;
private cost: Gold;
+ private tile: TileRef;
constructor(
private player: Player,
- private tile: TileRef,
private constructionType: UnitType,
+ private tileOrCell: TileRef | Cell,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
+
+ if (this.mg.config().isUnitDisabled(this.constructionType)) {
+ console.warn(
+ `cannot build construction ${this.constructionType} because it is disabled`,
+ );
+ this.active = false;
+ return;
+ }
+
+ if (this.tileOrCell instanceof Cell) {
+ if (!this.mg.isValidCoord(this.tileOrCell.x, this.tileOrCell.y)) {
+ console.warn(
+ `cannot build construction invalid coordinates ${this.tileOrCell.x}, ${this.tileOrCell.y}`,
+ );
+ this.active = false;
+ return;
+ }
+ this.tile = this.mg.ref(this.tileOrCell.x, this.tileOrCell.y);
+ } else {
+ this.tile = this.tileOrCell;
+ }
}
tick(ticks: number): void {
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index 27888e41a..72e552cb9 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -1,7 +1,8 @@
-import { Execution, Game } from "../game/Game";
+import { Cell, Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
+import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
@@ -108,9 +109,13 @@ export class Executor {
case "build_unit":
return new ConstructionExecution(
player,
- this.mg.ref(intent.x, intent.y),
intent.unit,
+ new Cell(intent.x, intent.y),
);
+ case "allianceExtension": {
+ return new AllianceExtensionExecution(player, intent.recipient);
+ }
+
case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId);
case "create_station":
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index ad10a8c70..eb9467c8d 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -444,6 +444,9 @@ export class FakeHumanExecution implements Execution {
}
private maybeSpawnTrainStation(): boolean {
+ if (this.mg.config().isUnitDisabled(UnitType.Train)) {
+ return false;
+ }
if (this.player === null) throw new Error("not initialized");
const citiesWithoutStations = this.player.units().filter((unit) => {
switch (unit.type()) {
@@ -480,7 +483,7 @@ export class FakeHumanExecution implements Execution {
if (canBuild === false) {
return false;
}
- this.mg.addExecution(new ConstructionExecution(this.player, tile, type));
+ this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
return true;
}
@@ -519,7 +522,7 @@ export class FakeHumanExecution implements Execution {
return false;
}
this.mg.addExecution(
- new ConstructionExecution(this.player, targetTile, UnitType.Warship),
+ new ConstructionExecution(this.player, UnitType.Warship, targetTile),
);
return true;
}
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index 4c81d4bb7..c93bcf44f 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -72,10 +72,7 @@ export class PlayerExecution implements Execution {
const alliances = Array.from(this.player.alliances());
for (const alliance of alliances) {
- if (
- this.mg.ticks() - alliance.createdAt() >
- this.mg.config().allianceDuration()
- ) {
+ if (alliance.expiresAt() <= this.mg.ticks()) {
alliance.expire();
}
}
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index 343063bb0..b885b68d6 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -129,7 +129,7 @@ export class TradeShipExecution implements Execution {
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
if (this.wasCaptured) {
- this.tradeShip!.owner().addGold(gold);
+ this.tradeShip!.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.CAPTURED_ENEMY_UNIT,
@@ -138,7 +138,7 @@ export class TradeShipExecution implements Execution {
);
} else {
this.srcPort.owner().addGold(gold);
- this._dstPort.owner().addGold(gold);
+ this._dstPort.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.RECEIVED_GOLD_FROM_TRADE,
diff --git a/src/core/execution/TrainStationExecution.ts b/src/core/execution/TrainStationExecution.ts
index 81008e3ef..358899ca7 100644
--- a/src/core/execution/TrainStationExecution.ts
+++ b/src/core/execution/TrainStationExecution.ts
@@ -1,4 +1,4 @@
-import { Execution, Game, Player, Unit } from "../game/Game";
+import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TrainStation } from "../game/TrainStation";
import { PseudoRandom } from "../PseudoRandom";
import { TrainExecution } from "./TrainExecution";
@@ -21,6 +21,13 @@ export class TrainStationExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
+
+ if (this.mg.config().isUnitDisabled(UnitType.Train)) {
+ this.active = false;
+ console.warn(`train station is disabled`);
+ return;
+ }
+
this.random = new PseudoRandom(mg.ticks());
this.unit = this.player.units().find((unit) => unit.id() === this.unitId);
diff --git a/src/core/execution/alliance/AllianceExtensionExecution.ts b/src/core/execution/alliance/AllianceExtensionExecution.ts
new file mode 100644
index 000000000..e43693f89
--- /dev/null
+++ b/src/core/execution/alliance/AllianceExtensionExecution.ts
@@ -0,0 +1,61 @@
+import {
+ Execution,
+ Game,
+ MessageType,
+ Player,
+ PlayerID,
+} from "../../game/Game";
+
+export class AllianceExtensionExecution implements Execution {
+ constructor(
+ private readonly from: Player,
+ private readonly toID: PlayerID,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ if (!mg.hasPlayer(this.toID)) {
+ console.warn(
+ `[AllianceExtensionExecution] Player ${this.toID} not found`,
+ );
+ return;
+ }
+ const to = mg.player(this.toID);
+ const alliance = this.from.allianceWith(to);
+ if (!alliance) {
+ console.warn(
+ `[AllianceExtensionExecution] No alliance to extend between ${this.from.id()} and ${this.toID}`,
+ );
+ return;
+ }
+
+ // Mark this player's intent to extend
+ alliance.addExtensionRequest(this.from);
+
+ if (alliance.canExtend()) {
+ alliance.extend();
+
+ mg.displayMessage(
+ "alliance.renewed",
+ MessageType.ALLIANCE_ACCEPTED,
+ this.from.id(),
+ );
+ mg.displayMessage(
+ "alliance.renewed",
+ MessageType.ALLIANCE_ACCEPTED,
+ this.toID,
+ );
+ }
+ }
+
+ tick(ticks: number): void {
+ // No-op
+ }
+
+ isActive(): boolean {
+ return false;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/game/AllianceImpl.ts b/src/core/game/AllianceImpl.ts
index b5c2c5836..6d2782595 100644
--- a/src/core/game/AllianceImpl.ts
+++ b/src/core/game/AllianceImpl.ts
@@ -1,12 +1,20 @@
import { Game, MutableAlliance, Player, Tick } from "./Game";
export class AllianceImpl implements MutableAlliance {
+ private extensionRequestedRequestor_: boolean = false;
+ private extensionRequestedRecipient_: boolean = false;
+
+ private expiresAt_: Tick;
+
constructor(
private readonly mg: Game,
readonly requestor_: Player,
readonly recipient_: Player,
- readonly createdAtTick_: Tick,
- ) {}
+ private readonly createdAt_: Tick,
+ private readonly id_: number,
+ ) {
+ this.expiresAt_ = createdAt_ + mg.config().allianceDuration();
+ }
other(player: Player): Player {
if (this.requestor_ === player) {
@@ -24,10 +32,38 @@ export class AllianceImpl implements MutableAlliance {
}
createdAt(): Tick {
- return this.createdAtTick_;
+ return this.createdAt_;
}
expire(): void {
this.mg.expireAlliance(this);
}
+
+ addExtensionRequest(player: Player): void {
+ if (this.requestor_ === player) {
+ this.extensionRequestedRequestor_ = true;
+ } else if (this.recipient_ === player) {
+ this.extensionRequestedRecipient_ = true;
+ }
+ }
+
+ canExtend(): boolean {
+ return (
+ this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_
+ );
+ }
+
+ public id(): number {
+ return this.id_;
+ }
+
+ extend(): void {
+ this.extensionRequestedRequestor_ = false;
+ this.extensionRequestedRecipient_ = false;
+ this.expiresAt_ = this.mg.ticks() + this.mg.config().allianceDuration();
+ }
+
+ expiresAt(): Tick {
+ return this.expiresAt_;
+ }
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index d2bbade2f..330179000 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -133,6 +133,7 @@ export interface UnitInfo {
constructionDuration?: number;
upgradable?: boolean;
canBuildTrainStation?: boolean;
+ experimental?: boolean;
}
export enum UnitType {
@@ -338,20 +339,23 @@ export interface Alliance {
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
+ expiresAt(): Tick;
other(player: Player): Player;
}
export interface MutableAlliance extends Alliance {
expire(): void;
other(player: Player): Player;
+ canExtend(): boolean;
+ addExtensionRequest(player: Player): void;
+ id(): number;
+ extend(): void;
}
export class PlayerInfo {
public readonly clan: string | null;
constructor(
- public readonly pattern: string | undefined,
- public readonly flag: string | undefined,
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
@@ -498,7 +502,7 @@ export interface Player {
workers(): number;
troops(): number;
targetTroopRatio(): number;
- addGold(toAdd: Gold): void;
+ addGold(toAdd: Gold, tile?: TileRef): void;
removeGold(toRemove: Gold): Gold;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
@@ -518,6 +522,7 @@ export interface Player {
spawnTile: TileRef,
params: UnitParams,
): Unit;
+
upgradeUnit(unit: Unit): void;
captureUnit(unit: Unit): void;
@@ -537,6 +542,7 @@ export interface Player {
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
+ expiredAlliances(): Alliance[];
allies(): Player[];
isAlliedWith(other: Player): boolean;
allianceWith(other: Player): MutableAlliance | null;
@@ -592,7 +598,6 @@ export interface Player {
}
export interface Game extends GameMap {
- expireAlliance(alliance: Alliance);
// Map & Dimensions
isOnMap(cell: Cell): boolean;
width(): number;
@@ -614,6 +619,10 @@ export interface Game extends GameMap {
teams(): Team[];
+ // Alliances
+ alliances(): MutableAlliance[];
+ expireAlliance(alliance: Alliance): void;
+
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
@@ -674,6 +683,8 @@ export interface PlayerActions {
export interface BuildableUnit {
canBuild: TileRef | false;
+ // unit id of the existing unit that can be upgraded, or false if it cannot be upgraded.
+ canUpgrade: number | false;
type: UnitType;
cost: Gold;
}
@@ -695,7 +706,7 @@ export interface PlayerInteraction {
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
- allianceCreatedAtTick?: Tick;
+ allianceExpiresAt?: Tick;
}
export interface EmojiMessage {
@@ -730,6 +741,7 @@ export enum MessageType {
SENT_TROOPS_TO_PLAYER,
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
+ RENEW_ALLIANCE,
}
// Message categories used for filtering events in the EventsDisplay
@@ -760,6 +772,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
+ [MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index e8cef0a77..151ea84a7 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
-import { AllPlayersStats, ClientID } from "../Schemas";
+import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
import { AllianceRequestImpl } from "./AllianceRequestImpl";
@@ -15,6 +15,7 @@ import {
GameMode,
GameUpdates,
MessageType,
+ MutableAlliance,
Nation,
Player,
PlayerID,
@@ -77,6 +78,9 @@ export class GameImpl implements Game {
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
+ // Used to assign unique IDs to each new alliance
+ private nextAllianceID: number = 0;
+
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
@@ -146,6 +150,11 @@ export class GameImpl implements Game {
owner(ref: TileRef): Player | TerraNullius {
return this.playerBySmallID(this.ownerID(ref));
}
+
+ alliances(): MutableAlliance[] {
+ return this.alliances_;
+ }
+
playerBySmallID(id: number): Player | TerraNullius {
if (id === 0) {
return this.terraNullius();
@@ -231,11 +240,20 @@ export class GameImpl implements Game {
const requestor = request.requestor();
const recipient = request.recipient();
+ const existing = requestor.allianceWith(recipient);
+ if (existing) {
+ throw new Error(
+ `cannot accept alliance request, already allied with ${recipient.name()}`,
+ );
+ }
+
+ // Create and register the new alliance
const alliance = new AllianceImpl(
this,
requestor as PlayerImpl,
recipient as PlayerImpl,
this._ticks,
+ this.nextAllianceID++,
);
this.alliances_.push(alliance);
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
@@ -596,14 +614,31 @@ export class GameImpl implements Game {
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this.addUpdate({
type: GameUpdateType.Win,
- winner:
- typeof winner === "string"
- ? ["team", winner]
- : ["player", winner.smallID()],
+ winner: this.makeWinner(winner),
allPlayersStats,
});
}
+ private makeWinner(winner: string | Player): Winner | undefined {
+ if (typeof winner === "string") {
+ return [
+ "team",
+ winner,
+ ...this.players()
+ .filter((p) => p.team() === winner && p.clientID() !== null)
+ .map((p) => p.clientID()!),
+ ];
+ } else {
+ const clientId = winner.clientID();
+ if (clientId === null) return;
+ return [
+ "player",
+ clientId,
+ // TODO: Assists (vote for peace)
+ ];
+ }
+ }
+
teams(): Team[] {
if (this._config.gameConfig().gameMode !== GameMode.Team) {
return [];
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 0c76f3a95..c3150fa8b 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -1,4 +1,4 @@
-import { AllPlayersStats, ClientID } from "../Schemas";
+import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import {
EmojiMessage,
GameUpdates,
@@ -36,6 +36,7 @@ export enum GameUpdateType {
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
+ AllianceExtension,
TargetPlayer,
Emoji,
Win,
@@ -60,6 +61,7 @@ export type GameUpdate =
| WinUpdate
| HashUpdate
| UnitIncomingUpdate
+ | AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate;
@@ -133,8 +135,6 @@ export interface PlayerUpdate {
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID | null;
- pattern: string | undefined;
- flag: string | undefined;
name: string;
displayName: string;
id: PlayerID;
@@ -157,10 +157,18 @@ export interface PlayerUpdate {
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
+ alliances: AllianceView[];
hasSpawned: boolean;
betrayals?: bigint;
}
+export interface AllianceView {
+ id: number;
+ other: PlayerID;
+ createdAt: Tick;
+ expiresAt: Tick;
+}
+
export interface AllianceRequestUpdate {
type: GameUpdateType.AllianceRequest;
requestorID: number;
@@ -186,6 +194,12 @@ export interface AllianceExpiredUpdate {
player2ID: number;
}
+export interface AllianceExtensionUpdate {
+ type: GameUpdateType.AllianceExtension;
+ playerID: number;
+ allianceID: number;
+}
+
export interface TargetPlayerUpdate {
type: GameUpdateType.TargetPlayer;
playerID: number;
@@ -218,8 +232,7 @@ export type DisplayChatMessageUpdate = {
export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
- // Player id or team name.
- winner: ["player", number] | ["team", Team];
+ winner: Winner;
}
export interface HashUpdate {
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 47f0e7128..6063acd81 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -1,6 +1,7 @@
+import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { PatternDecoder } from "../PatternDecoder";
-import { ClientID, GameID } from "../Schemas";
+import { ClientID, GameID, Player } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
@@ -9,11 +10,9 @@ import {
GameUpdates,
Gold,
NameViewData,
- Player,
PlayerActions,
PlayerBorderTiles,
PlayerID,
- PlayerInfo,
PlayerProfile,
PlayerType,
Team,
@@ -26,18 +25,25 @@ import {
} from "./Game";
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import {
+ AllianceView,
AttackUpdate,
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
+import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
+interface PlayerCosmetics {
+ pattern?: string | undefined;
+ flag?: string | undefined;
+}
+
export class UnitView {
public _wasUpdated = true;
public lastPos: TileRef[] = [];
@@ -145,6 +151,7 @@ export class PlayerView {
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
+ public cosmetics: PlayerCosmetics,
) {
if (data.clientID === game.myClientID()) {
this.anonymousName = this.data.name;
@@ -155,7 +162,9 @@ export class PlayerView {
);
}
this.decoder =
- data.pattern === undefined ? undefined : new PatternDecoder(data.pattern);
+ this.cosmetics.pattern === undefined
+ ? undefined
+ : new PatternDecoder(this.cosmetics.pattern, base64url.decode);
}
patternDecoder(): PatternDecoder | undefined {
@@ -202,13 +211,6 @@ export class PlayerView {
smallID(): number {
return this.data.smallID;
}
- flag(): string | undefined {
- return this.data.flag;
- }
-
- pattern(): string | undefined {
- return this.data.pattern;
- }
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
@@ -236,7 +238,7 @@ export class PlayerView {
isAlive(): boolean {
return this.data.isAlive;
}
- isPlayer(): this is Player {
+ isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
@@ -264,10 +266,17 @@ export class PlayerView {
targetTroopRatio(): number {
return this.data.targetTroopRatio;
}
+
troops(): number {
return this.data.troops;
}
+ totalUnitLevels(type: UnitType): number {
+ return this.units(type)
+ .map((unit) => unit.level())
+ .reduce((a, b) => a + b, 0);
+ }
+
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
@@ -284,6 +293,10 @@ export class PlayerView {
return this.data.outgoingAllianceRequests.some((id) => other.id() === id);
}
+ alliances(): AllianceView[] {
+ return this.data.alliances;
+ }
+
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
@@ -306,16 +319,7 @@ export class PlayerView {
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
- info(): PlayerInfo {
- return new PlayerInfo(
- this.pattern(),
- this.flag(),
- this.name(),
- this.type(),
- this.clientID(),
- this.id(),
- );
- }
+
hasSpawned(): boolean {
return this.data.hasSpawned;
}
@@ -338,16 +342,35 @@ export class GameView implements GameMap {
private toDelete = new Set();
+ private _cosmetics: Map = new Map();
+
+ private _map: GameMap;
+
constructor(
public worker: WorkerClient,
private _config: Config,
- private _map: GameMap,
+ private _mapData: TerrainMapData,
private _myClientID: ClientID,
private _gameID: GameID,
+ private _hunans: Player[],
) {
+ this._map = this._mapData.gameMap;
this.lastUpdate = null;
- this.unitGrid = new UnitGrid(_map);
+ this.unitGrid = new UnitGrid(this._map);
+ this._cosmetics = new Map(
+ this._hunans.map((h) => [
+ h.clientID,
+ { flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics,
+ ]),
+ );
+ for (const nation of this._mapData.manifest.nations) {
+ // Nations don't have client ids, so we use their name as the key instead.
+ this._cosmetics.set(nation.name, {
+ flag: nation.flag,
+ });
+ }
}
+
isOnEdgeOfMap(ref: TileRef): boolean {
return this._map.isOnEdgeOfMap(ref);
}
@@ -379,7 +402,15 @@ export class GameView implements GameMap {
} else {
this._players.set(
pu.id,
- new PlayerView(this, pu, gu.playerNameViewData[pu.id]),
+ new PlayerView(
+ this,
+ pu,
+ gu.playerNameViewData[pu.id],
+ // First check human by clientID, then check nation by name.
+ this._cosmetics.get(pu.clientID ?? "") ??
+ this._cosmetics.get(pu.name) ??
+ {},
+ ),
);
}
});
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index b9f045d00..c9b86e374 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -42,7 +42,12 @@ import {
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
-import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
+import {
+ AllianceView,
+ AttackUpdate,
+ GameUpdateType,
+ PlayerUpdate,
+} from "./GameUpdates";
import {
bestShoreDeploymentSource,
canBuildTransportShip,
@@ -85,6 +90,7 @@ export class PlayerImpl implements Player {
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
+ private _expiredAlliances: Alliance[] = [];
private targets_: Target[] = [];
@@ -128,8 +134,6 @@ export class PlayerImpl implements Player {
return {
type: GameUpdateType.Player,
clientID: this.clientID(),
- pattern: this.pattern(),
- flag: this.flag(),
name: this.name(),
displayName: this.displayName(),
id: this.id(),
@@ -168,6 +172,15 @@ export class PlayerImpl implements Player {
} satisfies AttackUpdate;
}),
outgoingAllianceRequests: outgoingAllianceRequests,
+ alliances: this.alliances().map(
+ (a) =>
+ ({
+ id: a.id(),
+ other: a.other(this).id(),
+ createdAt: a.createdAt(),
+ expiresAt: a.expiresAt(),
+ }) satisfies AllianceView,
+ ),
hasSpawned: this.hasSpawned(),
betrayals: stats?.betrayals,
};
@@ -177,14 +190,6 @@ export class PlayerImpl implements Player {
return this._smallID;
}
- pattern(): string | undefined {
- return this.playerInfo.pattern;
- }
-
- flag(): string | undefined {
- return this.playerInfo.flag;
- }
-
name(): string {
return this._name;
}
@@ -352,6 +357,10 @@ export class PlayerImpl implements Player {
);
}
+ expiredAlliances(): Alliance[] {
+ return [...this._expiredAlliances];
+ }
+
allies(): Player[] {
return this.alliances().map((a) => a.other(this));
}
@@ -689,8 +698,17 @@ export class PlayerImpl implements Player {
return this._gold;
}
- addGold(toAdd: Gold): void {
+ addGold(toAdd: Gold, tile?: TileRef): void {
this._gold += toAdd;
+ if (tile) {
+ this.mg.addUpdate({
+ type: GameUpdateType.BonusEvent,
+ tile,
+ gold: Number(toAdd),
+ workers: 0,
+ troops: 0,
+ });
+ }
}
removeGold(toRemove: Gold): Gold {
@@ -785,6 +803,27 @@ export class PlayerImpl implements Player {
return b;
}
+ // Returns the existing unit that can be upgraded,
+ // or false if it cannot be upgraded.
+ // New units of the same type can upgrade existing units.
+ // e.g. if a place a new city here, can it upgrade an existing city?
+ private canUpgradeExistingUnit(
+ type: UnitType,
+ targetTile: TileRef,
+ ): Unit | false {
+ if (!this.mg.config().unitInfo(type).upgradable) {
+ return false;
+ }
+ const range = this.mg.config().structureMinDist();
+ const existing = this.mg
+ .nearbyUnits(targetTile, range, type)
+ .sort((a, b) => a.distSquared - b.distSquared);
+ if (existing.length > 0) {
+ return existing[0].unit;
+ }
+ return false;
+ }
+
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this);
this.removeGold(cost);
@@ -794,11 +833,19 @@ export class PlayerImpl implements Player {
public buildableUnits(tile: TileRef): BuildableUnit[] {
const validTiles = this.validStructureSpawnTiles(tile);
return Object.values(UnitType).map((u) => {
+ let canUpgrade: number | false = false;
+ if (!this.mg.inSpawnPhase()) {
+ const existingUnit = this.canUpgradeExistingUnit(u, tile);
+ if (existingUnit !== false) {
+ canUpgrade = existingUnit.id();
+ }
+ }
return {
type: u,
canBuild: this.mg.inSpawnPhase()
? false
: this.canBuild(u, tile, validTiles),
+ canUpgrade: canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts
index 248fff0df..a86f819fc 100644
--- a/src/core/game/TrainStation.ts
+++ b/src/core/game/TrainStation.ts
@@ -21,14 +21,7 @@ class CityStopHandler implements TrainStopHandler {
trainExecution: TrainExecution,
): void {
const goldBonus = mg.config().trainGold();
- station.unit.owner().addGold(goldBonus);
- mg.addUpdate({
- type: GameUpdateType.BonusEvent,
- tile: station.tile(),
- gold: Number(goldBonus),
- workers: 0,
- troops: 0,
- });
+ station.unit.owner().addGold(goldBonus, station.tile());
}
}
diff --git a/src/global.d.ts b/src/global.d.ts
index e100dde8e..5c3aadc48 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -36,3 +36,7 @@ declare module "*.html" {
const content: string;
export default content;
}
+declare module "*.xml" {
+ const value: string;
+ export default value;
+}
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index b29e97fc2..6ffe8ffcc 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -21,7 +21,7 @@ import {
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
-import { GameType } from "../core/game/Game";
+import { GameType, UnitType } from "../core/game/Game";
import { archive } from "./Archive";
import { Client } from "./Client";
import { gatekeeper } from "./Gatekeeper";
@@ -222,6 +222,15 @@ export class GameServer {
);
return;
}
+ if (
+ clientMsg.intent.type === "create_station" &&
+ this.gameConfig.disabledUnits?.includes(UnitType.Train)
+ ) {
+ this.log.warn(
+ `create_station is disabled, client: ${client.clientID}`,
+ );
+ return;
+ }
this.addIntent(clientMsg.intent);
}
if (clientMsg.type === "ping") {
diff --git a/src/server/Logger.ts b/src/server/Logger.ts
index 94e508fbb..19832f50e 100644
--- a/src/server/Logger.ts
+++ b/src/server/Logger.ts
@@ -7,7 +7,6 @@ import {
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
-import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
dotenv.config();
@@ -21,7 +20,7 @@ const loggerProvider = new LoggerProvider({
resource,
});
-if (config.env() === GameEnv.Prod && config.otelEnabled()) {
+if (config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers = {};
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index c6c07c0ce..6c5c85f3f 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -1,5 +1,11 @@
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
-import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
+import {
+ Difficulty,
+ GameMapType,
+ GameMode,
+ GameType,
+ UnitType,
+} from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig } from "../core/Schemas";
import { logger } from "./Logger";
@@ -61,6 +67,7 @@ export class MapPlaylist {
gameMode: mode,
playerTeams: numPlayerTeams,
bots: 400,
+ disabledUnits: [UnitType.Train, UnitType.Factory],
} satisfies GameConfig;
}
diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts
index 3f84a5f53..f59aff7e6 100644
--- a/src/server/Privilege.ts
+++ b/src/server/Privilege.ts
@@ -2,7 +2,10 @@ import { Cosmetics } from "../core/CosmeticSchemas";
import { PatternDecoder } from "../core/PatternDecoder";
export class PrivilegeChecker {
- constructor(private cosmetics: Cosmetics) {}
+ constructor(
+ private cosmetics: Cosmetics,
+ private b64urlDecode: (base64: string) => Uint8Array,
+ ) {}
isPatternAllowed(
base64: string,
@@ -14,7 +17,7 @@ export class PrivilegeChecker {
if (found === undefined) {
try {
// Ensure that the pattern will not throw for clients
- new PatternDecoder(base64);
+ new PatternDecoder(base64, this.b64urlDecode);
} catch (e) {
// Pattern is invalid
return "invalid";
@@ -37,7 +40,7 @@ export class PrivilegeChecker {
if (
roles !== undefined &&
roles.some((role) =>
- this.cosmetics.role_groups[groupName].includes(role),
+ this.cosmetics.role_groups[groupName]?.includes(role),
)
) {
// Player is in a role group for this pattern
@@ -55,4 +58,94 @@ export class PrivilegeChecker {
return "restricted";
}
+
+ isCustomFlagAllowed(
+ flag: string,
+ roles: readonly string[] | undefined,
+ flares: readonly string[] | undefined,
+ ): true | "restricted" | "invalid" {
+ if (!flag.startsWith("!")) return "invalid";
+ const code = flag.slice(1);
+ if (!code) return "invalid";
+ const segments = code.split("_");
+ if (segments.length === 0) return "invalid";
+
+ const MAX_LAYERS = 6; // Maximum number of layers allowed
+ if (segments.length > MAX_LAYERS) return "invalid";
+
+ const superFlare = flares?.includes("flag:*") ?? false;
+
+ for (const segment of segments) {
+ const [layerKey, colorKey] = segment.split("-");
+ if (!layerKey || !colorKey) return "invalid";
+ const layer = this.cosmetics.flag.layers[layerKey];
+ const color = this.cosmetics.flag.color[colorKey];
+ if (!layer || !color) return "invalid";
+
+ // Super-flare bypasses all restrictions
+ if (superFlare) {
+ continue;
+ }
+
+ // Check layer restrictions
+ const layerSpec = layer;
+ let layerAllowed = false;
+ if (!layerSpec.role_group && !layerSpec.flares) {
+ layerAllowed = true;
+ } else {
+ // By role
+ if (layerSpec.role_group) {
+ const allowedRoles =
+ this.cosmetics.role_groups[layerSpec.role_group] || [];
+ if (roles?.some((r) => allowedRoles.includes(r))) {
+ layerAllowed = true;
+ }
+ }
+ // By flare
+ if (
+ layerSpec.flares &&
+ flares?.some((f) => layerSpec.flares?.includes(f))
+ ) {
+ layerAllowed = true;
+ }
+ // By named flag:layer:{name}
+ if (flares?.includes(`flag:layer:${layerSpec.name}`)) {
+ layerAllowed = true;
+ }
+ }
+
+ // Check color restrictions
+ const colorSpec = color;
+ let colorAllowed = false;
+ if (!colorSpec.role_group && !colorSpec.flares) {
+ colorAllowed = true;
+ } else {
+ // By role
+ if (colorSpec.role_group) {
+ const allowedRoles =
+ this.cosmetics.role_groups[colorSpec.role_group] || [];
+ if (roles?.some((r) => allowedRoles.includes(r))) {
+ colorAllowed = true;
+ }
+ }
+ // By flare
+ if (
+ colorSpec.flares &&
+ flares?.some((f) => colorSpec.flares?.includes(f))
+ ) {
+ colorAllowed = true;
+ }
+ // By named flag:color:{name}
+ if (flares?.includes(`flag:color:${colorSpec.name}`)) {
+ colorAllowed = true;
+ }
+ }
+
+ // If either part is restricted, block
+ if (!(layerAllowed && colorAllowed)) {
+ return "restricted";
+ }
+ }
+ return true;
+ }
}
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index 1e499af54..a93f68412 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
+import { base64url } from "jose";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -11,7 +12,7 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { COSMETICS } from "../core/CosmeticSchemas";
import { GameType } from "../core/game/Game";
import {
- ClientJoinMessageSchema,
+ ClientMessageSchema,
GameRecord,
GameRecordSchema,
ServerErrorMessage,
@@ -44,9 +45,9 @@ export function startWorker() {
const gm = new GameManager(config, log);
- const privilegeChecker = new PrivilegeChecker(COSMETICS);
+ const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
- if (config.env() === GameEnv.Prod && config.otelEnabled()) {
+ if (config.otelEnabled()) {
initWorkerMetrics(gm);
}
@@ -301,12 +302,12 @@ export function startWorker() {
try {
// Parse and handle client messages
- const parsed = ClientJoinMessageSchema.safeParse(
+ const parsed = ClientMessageSchema.safeParse(
JSON.parse(message.toString()),
);
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
- log.warn("Error parsing join message client", error);
+ log.warn("Error parsing client message", error);
ws.send(
JSON.stringify({
type: "error",
@@ -318,6 +319,22 @@ export function startWorker() {
}
const clientMsg = parsed.data;
+ if (clientMsg.type === "ping") {
+ // Ignore ping
+ return;
+ } else if (clientMsg.type !== "join") {
+ const error = `Invalid message before join: ${JSON.stringify(clientMsg)}`;
+ log.warn(error);
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ error,
+ } satisfies ServerErrorMessage),
+ );
+ ws.close(1002, "ClientJoinMessageSchema");
+ return;
+ }
+
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
@@ -355,7 +372,18 @@ export function startWorker() {
// Check if the flag is allowed
if (clientMsg.flag !== undefined) {
- // TODO: Implement custom flag validation
+ if (clientMsg.flag.startsWith("!")) {
+ const allowed = privilegeChecker.isCustomFlagAllowed(
+ clientMsg.flag,
+ roles,
+ flares,
+ );
+ if (allowed !== true) {
+ log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
+ ws.close(1002, `Custom flag ${allowed}`);
+ return;
+ }
+ }
}
// Check if the pattern is allowed
diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts
new file mode 100644
index 000000000..39289c0fe
--- /dev/null
+++ b/tests/AllianceExtensionExecution.test.ts
@@ -0,0 +1,72 @@
+import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution";
+import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution";
+import { AllianceRequestReplyExecution } from "../src/core/execution/alliance/AllianceRequestReplyExecution";
+import { Game, Player, PlayerType } from "../src/core/game/Game";
+import { playerInfo, setup } from "./util/Setup";
+
+let game: Game;
+let player1: Player;
+let player2: Player;
+
+describe("AllianceExtensionExecution", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "ocean_and_land",
+ {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ },
+ [
+ playerInfo("player1", PlayerType.Human),
+ playerInfo("player2", PlayerType.Human),
+ ],
+ );
+
+ player1 = game.player("player1");
+ player2 = game.player("player2");
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+ });
+
+ test("Successfully extends existing alliance", () => {
+ game.addExecution(new AllianceRequestExecution(player1, player2.id()));
+ game.executeNextTick();
+ game.executeNextTick();
+
+ game.addExecution(
+ new AllianceRequestReplyExecution(player1.id(), player2, true),
+ );
+ game.executeNextTick();
+ game.executeNextTick();
+
+ expect(player1.allianceWith(player2)).toBeTruthy();
+ expect(player2.allianceWith(player1)).toBeTruthy();
+
+ const allianceBefore = player1.allianceWith(player2)!;
+ const expirationBefore =
+ allianceBefore.createdAt() + game.config().allianceDuration();
+
+ game.addExecution(new AllianceExtensionExecution(player1, player2.id()));
+ game.executeNextTick();
+
+ const allianceAfter = player1.allianceWith(player2)!;
+
+ expect(allianceAfter.id()).toBe(allianceBefore.id());
+
+ const expirationAfter =
+ allianceAfter.createdAt() + game.config().allianceDuration();
+
+ expect(expirationAfter).toBeGreaterThanOrEqual(expirationBefore);
+ });
+
+ test("Fails gracefully if no alliance exists", () => {
+ game.addExecution(new AllianceExtensionExecution(player1, player2.id()));
+ game.executeNextTick();
+
+ expect(player1.allianceWith(player2)).toBeFalsy();
+ expect(player2.allianceWith(player1)).toBeFalsy();
+ });
+});
diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts
index 71beae6b5..8974f6250 100644
--- a/tests/Attack.test.ts
+++ b/tests/Attack.test.ts
@@ -33,8 +33,6 @@ describe("Attack", () => {
infiniteTroops: true,
});
const attackerInfo = new PlayerInfo(
- undefined,
- "us",
"attacker dude",
PlayerType.Human,
null,
@@ -42,8 +40,6 @@ describe("Attack", () => {
);
game.addPlayer(attackerInfo);
const defenderInfo = new PlayerInfo(
- undefined,
- "us",
"defender dude",
PlayerType.Human,
null,
diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts
index e296892bb..9710770ac 100644
--- a/tests/BotBehavior.test.ts
+++ b/tests/BotBehavior.test.ts
@@ -23,16 +23,12 @@ describe("BotBehavior.handleAllianceRequests", () => {
});
const playerInfo = new PlayerInfo(
- undefined,
- "us",
"player_id",
PlayerType.Bot,
null,
"player_id",
);
const requestorInfo = new PlayerInfo(
- undefined,
- "fr",
"requestor_id",
PlayerType.Human,
null,
diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts
index 709c8104a..e03138efa 100644
--- a/tests/Disconnected.test.ts
+++ b/tests/Disconnected.test.ts
@@ -16,8 +16,6 @@ describe("Disconnected", () => {
});
const player1Info = new PlayerInfo(
- undefined,
- "us",
"Active Player",
PlayerType.Human,
null,
@@ -25,8 +23,6 @@ describe("Disconnected", () => {
);
const player2Info = new PlayerInfo(
- undefined,
- "fr",
"Disconnected Player",
PlayerType.Human,
null,
diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts
index 6e58ba2c5..c3a1f5ab1 100644
--- a/tests/MissileSilo.test.ts
+++ b/tests/MissileSilo.test.ts
@@ -33,8 +33,6 @@ describe("MissileSilo", () => {
beforeEach(async () => {
game = await setup("plains", { infiniteGold: true, instantBuild: true });
const attacker_info = new PlayerInfo(
- undefined,
- "fr",
"attacker_id",
PlayerType.Human,
null,
diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts
new file mode 100644
index 000000000..64aecd954
--- /dev/null
+++ b/tests/PlayerImpl.test.ts
@@ -0,0 +1,48 @@
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../src/core/game/Game";
+import { setup } from "./util/Setup";
+
+let game: Game;
+let player: Player;
+
+describe("PlayerImpl", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "plains",
+ {
+ infiniteGold: true,
+ instantBuild: true,
+ },
+ [new PlayerInfo("player", PlayerType.Human, null, "player_id")],
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ player = game.player("player_id");
+ });
+
+ test("City can be upgraded", () => {
+ const city = player.buildUnit(UnitType.City, game.ref(0, 0), {});
+ const buCity = player
+ .buildableUnits(game.ref(0, 0))
+ .find((bu) => bu.type === UnitType.City);
+ expect(buCity).toBeDefined();
+ expect(buCity!.canUpgrade).toBe(city.id());
+ });
+
+ test("DefensePost cannot be upgraded", () => {
+ player.buildUnit(UnitType.DefensePost, game.ref(0, 0), {});
+ const buDefensePost = player
+ .buildableUnits(game.ref(0, 0))
+ .find((bu) => bu.type === UnitType.DefensePost);
+ expect(buDefensePost).toBeDefined();
+ expect(buDefensePost!.canUpgrade).toBeFalsy();
+ });
+});
diff --git a/tests/PlayerInfo.test.ts b/tests/PlayerInfo.test.ts
index eca14e173..72ca1cceb 100644
--- a/tests/PlayerInfo.test.ts
+++ b/tests/PlayerInfo.test.ts
@@ -4,8 +4,6 @@ describe("PlayerInfo", () => {
describe("clan", () => {
test("should extract clan from name when format is [XX]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[CL]PlayerName",
PlayerType.Human,
null,
@@ -16,8 +14,6 @@ describe("PlayerInfo", () => {
test("should extract clan from name when format is [XXX]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[ABC]PlayerName",
PlayerType.Human,
null,
@@ -28,8 +24,6 @@ describe("PlayerInfo", () => {
test("should extract clan from name when format is [XXXX]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[ABCD]PlayerName",
PlayerType.Human,
null,
@@ -40,8 +34,6 @@ describe("PlayerInfo", () => {
test("should extract clan from name when format is [XXXXX]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[ABCDE]PlayerName",
PlayerType.Human,
null,
@@ -52,8 +44,6 @@ describe("PlayerInfo", () => {
test("should extract clan from name when format is [xxxxx]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[abcde]PlayerName",
PlayerType.Human,
null,
@@ -64,8 +54,6 @@ describe("PlayerInfo", () => {
test("should extract clan from name when format is [XxXxX]Name", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[AbCdE]PlayerName",
PlayerType.Human,
null,
@@ -76,8 +64,6 @@ describe("PlayerInfo", () => {
test("should return null when name doesn't start with [", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"PlayerName",
PlayerType.Human,
null,
@@ -88,8 +74,6 @@ describe("PlayerInfo", () => {
test("should return null when name doesn't contain ]", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[ABCPlayerName",
PlayerType.Human,
null,
@@ -100,8 +84,6 @@ describe("PlayerInfo", () => {
test("should return null when clan tag is not 2-5 uppercase letters", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[A]PlayerName",
PlayerType.Human,
null,
@@ -112,8 +94,6 @@ describe("PlayerInfo", () => {
test("should return null when clan tag contains non alphanumeric characters", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[A1c]PlayerName",
PlayerType.Human,
null,
@@ -124,8 +104,6 @@ describe("PlayerInfo", () => {
test("should return null when clan tag is too long", () => {
const playerInfo = new PlayerInfo(
- undefined,
- "fr",
"[ABCDEF]PlayerName",
PlayerType.Human,
null,
diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts
index b988210bf..c53804b4f 100644
--- a/tests/Stats.test.ts
+++ b/tests/Stats.test.ts
@@ -19,22 +19,8 @@ describe("Stats", () => {
beforeEach(async () => {
stats = new StatsImpl();
game = await setup("half_land_half_ocean", {}, [
- new PlayerInfo(
- undefined,
- "us",
- "boat dude",
- PlayerType.Human,
- "client1",
- "player_1_id",
- ),
- new PlayerInfo(
- undefined,
- "us",
- "boat dude",
- PlayerType.Human,
- "client2",
- "player_2_id",
- ),
+ new PlayerInfo("boat dude", PlayerType.Human, "client1", "player_1_id"),
+ new PlayerInfo("boat dude", PlayerType.Human, "client2", "player_2_id"),
]);
while (game.inSpawnPhase()) {
diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts
index bea1ea74f..5d9f5bcab 100644
--- a/tests/TeamAssignment.test.ts
+++ b/tests/TeamAssignment.test.ts
@@ -7,8 +7,6 @@ describe("assignTeams", () => {
const createPlayer = (id: string, clan?: string): PlayerInfo => {
const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`;
return new PlayerInfo(
- undefined,
- "🏳️", // flag
name,
PlayerType.Human,
null, // clientID (null for testing)
diff --git a/tests/TerritoryCapture.test.ts b/tests/TerritoryCapture.test.ts
index d547010ff..e46678c08 100644
--- a/tests/TerritoryCapture.test.ts
+++ b/tests/TerritoryCapture.test.ts
@@ -6,14 +6,7 @@ describe("Territory management", () => {
test("player owns the tile it spawns on", async () => {
const game = await setup("plains");
game.addPlayer(
- new PlayerInfo(
- undefined,
- "us",
- "test_player",
- PlayerType.Human,
- null,
- "test_id",
- ),
+ new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const spawnTile = game.map().ref(50, 50);
game.addExecution(
diff --git a/tests/UnitGrid.test.ts b/tests/UnitGrid.test.ts
index ffd4ff9f6..9a0869ace 100644
--- a/tests/UnitGrid.test.ts
+++ b/tests/UnitGrid.test.ts
@@ -11,14 +11,7 @@ async function checkRange(
const game = await setup(mapName, { infiniteGold: true, instantBuild: true });
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
- new PlayerInfo(
- undefined,
- "us",
- "test_player",
- PlayerType.Human,
- null,
- "test_id",
- ),
+ new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(unitPosX, 0);
grid.addUnit(player.buildUnit(UnitType.DefensePost, unitTile, {}));
@@ -41,14 +34,7 @@ async function nearbyUnits(
const game = await setup(mapName, { infiniteGold: true, instantBuild: true });
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
- new PlayerInfo(
- undefined,
- "us",
- "test_player",
- PlayerType.Human,
- null,
- "test_id",
- ),
+ new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(unitPosX, 0);
for (const unitType of unitTypes) {
@@ -122,14 +108,7 @@ describe("Unit Grid range tests", () => {
});
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
- new PlayerInfo(
- undefined,
- "us",
- "test_player",
- PlayerType.Human,
- null,
- "test_id",
- ),
+ new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(0, 0);
grid.addUnit(player.buildUnit(UnitType.City, unitTile, {}));
@@ -146,14 +125,7 @@ describe("Unit Grid range tests", () => {
});
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
- new PlayerInfo(
- undefined,
- "us",
- "test_player",
- PlayerType.Human,
- null,
- "test_id",
- ),
+ new PlayerInfo("test_player", PlayerType.Human, null, "test_id"),
);
const unitType = UnitType.City;
const unitTile = game.map().ref(0, 0);
diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts
index 16a72e397..ee6c556de 100644
--- a/tests/Warship.test.ts
+++ b/tests/Warship.test.ts
@@ -24,22 +24,8 @@ describe("Warship", () => {
instantBuild: true,
},
[
- new PlayerInfo(
- undefined,
- "us",
- "boat dude",
- PlayerType.Human,
- null,
- "player_1_id",
- ),
- new PlayerInfo(
- undefined,
- "us",
- "boat dude",
- PlayerType.Human,
- null,
- "player_2_id",
- ),
+ new PlayerInfo("boat dude", PlayerType.Human, null, "player_1_id"),
+ new PlayerInfo("boat dude", PlayerType.Human, null, "player_2_id"),
],
);
diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts
index dbc131cec..0a31d69be 100644
--- a/tests/client/graphics/UILayer.test.ts
+++ b/tests/client/graphics/UILayer.test.ts
@@ -83,6 +83,26 @@ describe("UILayer", () => {
expect(ui["allHealthBars"].has(1)).toBe(false);
});
+ it("should remove health bars for inactive units", () => {
+ const ui = new UILayer(game, eventBus, transformHandler);
+ ui.redraw();
+ const unit = {
+ id: () => 1,
+ type: () => "Warship",
+ health: () => 5,
+ tile: () => ({}),
+ owner: () => ({}),
+ isActive: () => true,
+ } as unknown as UnitView;
+ ui.drawHealthBar(unit);
+ expect(ui["allHealthBars"].has(1)).toBe(true);
+
+ // an inactive unit doesnt have a health bar
+ unit.isActive = () => false;
+ ui.drawHealthBar(unit);
+ expect(ui["allHealthBars"].has(1)).toBe(false);
+ });
+
it("should add loading bar for unit", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts
index 435d388b4..5a614ebea 100644
--- a/tests/core/executions/NukeExecution.test.ts
+++ b/tests/core/executions/NukeExecution.test.ts
@@ -25,8 +25,6 @@ describe("NukeExecution", () => {
outer: 10,
}));
const player_info = new PlayerInfo(
- undefined,
- "us",
"player_id",
PlayerType.Human,
null,
diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts
index 7ceb08e1a..993488399 100644
--- a/tests/core/executions/SAMLauncherExecution.test.ts
+++ b/tests/core/executions/SAMLauncherExecution.test.ts
@@ -25,32 +25,24 @@ describe("SAM", () => {
instantBuild: true,
});
const defender_info = new PlayerInfo(
- undefined,
- "us",
"defender_id",
PlayerType.Human,
null,
"defender_id",
);
const middle_defender_info = new PlayerInfo(
- undefined,
- "us",
"middle_defender_id",
PlayerType.Human,
null,
"middle_defender_id",
);
const far_defender_info = new PlayerInfo(
- undefined,
- "us",
"far_defender_id",
PlayerType.Human,
null,
"far_defender_id",
);
const attacker_info = new PlayerInfo(
- undefined,
- "fr",
"attacker_id",
PlayerType.Human,
null,
diff --git a/tests/core/game/TrainStation.test.ts b/tests/core/game/TrainStation.test.ts
index c5c7ae5c9..316556f1f 100644
--- a/tests/core/game/TrainStation.test.ts
+++ b/tests/core/game/TrainStation.test.ts
@@ -44,13 +44,7 @@ describe("TrainStation", () => {
station.onTrainStop(trainExecution);
- expect(unit.owner().addGold).toHaveBeenCalledWith(10);
- expect(game.addUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- type: expect.any(Number),
- gold: 10,
- }),
- );
+ expect(unit.owner().addGold).toHaveBeenCalledWith(10, unit.tile());
});
it("handles Port stop", () => {
diff --git a/tests/server/Privilege.customFlag.test.ts b/tests/server/Privilege.customFlag.test.ts
new file mode 100644
index 000000000..330f00f30
--- /dev/null
+++ b/tests/server/Privilege.customFlag.test.ts
@@ -0,0 +1,188 @@
+import type { Cosmetics } from "../../src/core/CosmeticSchemas";
+import { PrivilegeChecker } from "../../src/server/Privilege";
+
+describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
+ const dummyPatternDecoder = (_base64: string) => {
+ throw new Error("Method not implemented");
+ };
+
+ const mockCosmetics: Cosmetics = {
+ role_groups: {
+ donor: ["role_donor"],
+ admin: ["role_admin"],
+ },
+ patterns: {},
+ flag: {
+ layers: {
+ a: {
+ name: "chocolate",
+ role_group: "donor",
+ flares: ["cosmetic:flags"],
+ },
+ b: { name: "center_hline" },
+ c: { name: "admin_layer", role_group: "admin" },
+ },
+ color: {
+ a: { color: "#ff0000", name: "red", role_group: "admin" },
+ b: { color: "#00ff00", name: "green" },
+ c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] },
+ },
+ },
+ };
+
+ const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder);
+
+ it("allowed: unrestricted layer/color", () => {
+ expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true);
+ });
+
+ it("restricted: donor layer without role", () => {
+ expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted");
+ });
+
+ it("allowed: donor layer with donor role", () => {
+ expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true);
+ });
+
+ it("allowed: donor layer with correct flare", () => {
+ expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe(
+ true,
+ );
+ });
+
+ it("restricted: admin color without role", () => {
+ expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted");
+ });
+
+ it("allowed: admin color with admin role", () => {
+ expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true);
+ });
+
+ it("allowed: color with correct flare", () => {
+ expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe(
+ true,
+ );
+ });
+
+ it("invalid: non-existent layer", () => {
+ expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe(
+ "invalid",
+ );
+ });
+
+ it("invalid: non-existent color", () => {
+ expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe(
+ "invalid",
+ );
+ });
+
+ it("allowed: superFlare allows all listed", () => {
+ expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true);
+ expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true);
+ expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true);
+ expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true);
+ });
+
+ it("invalid: superFlare does not allow non-existent", () => {
+ expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe(
+ "invalid",
+ );
+ });
+ it("allowed: flare flag:layer:chocolate allows chocolate layer", () => {
+ expect(
+ checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]),
+ ).toBe(true);
+ });
+ it("allowed: flare flag:color:blue allows blue color", () => {
+ expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe(
+ true,
+ );
+ });
+ it("restricted: only color flare, layer still restricted", () => {
+ expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
+ "restricted",
+ );
+ });
+ it("restricted: only layer flare, color still restricted", () => {
+ expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe(
+ "restricted",
+ );
+ });
+ it("allowed: layer by role, color by flare", () => {
+ // layer a: role_group donor, color c: flares ["cosmetic:blue"]
+ expect(
+ checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]),
+ ).toBe(true);
+ });
+ it("restricted: layer by role, color by flare (missing flare)", () => {
+ expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe(
+ "restricted",
+ );
+ });
+ it("restricted: layer by role, color by flare (missing role)", () => {
+ expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
+ "restricted",
+ );
+ });
+ it("allowed: layer by flare, color by role", () => {
+ // layer a: flares ["cosmetic:flags"], color a: role_group admin
+ expect(
+ checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]),
+ ).toBe(true);
+ });
+ it("restricted: layer by flare, color by role (missing flare)", () => {
+ expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe(
+ "restricted",
+ );
+ });
+ it("restricted: layer by flare, color by role (missing role)", () => {
+ expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe(
+ "restricted",
+ );
+ });
+ it("allowed: two segments, both unrestricted", () => {
+ expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true);
+ });
+ it("restricted: two segments, one restricted by layer role", () => {
+ expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted");
+ expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe(
+ true,
+ );
+ });
+ it("restricted: two segments, one restricted by color role", () => {
+ expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted");
+ expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe(
+ true,
+ );
+ });
+ it("allowed: two segments, one by role, one by flare", () => {
+ expect(
+ checker.isCustomFlagAllowed(
+ "!a-c_b-b",
+ ["role_donor"],
+ ["cosmetic:blue"],
+ ),
+ ).toBe(true);
+ expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe(
+ "restricted",
+ );
+ expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe(
+ "restricted",
+ );
+ });
+ it("allowed: two segments, both by flare", () => {
+ expect(
+ checker.isCustomFlagAllowed(
+ "!a-c_a-c",
+ [],
+ ["cosmetic:flags", "cosmetic:blue"],
+ ),
+ ).toBe(true);
+ expect(
+ checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]),
+ ).toBe("restricted");
+ expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe(
+ "restricted",
+ );
+ });
+});
diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts
index 7c6e86daa..c552d7d4f 100644
--- a/tests/util/Setup.ts
+++ b/tests/util/Setup.ts
@@ -83,5 +83,5 @@ export async function setup(
}
export function playerInfo(name: string, type: PlayerType): PlayerInfo {
- return new PlayerInfo(undefined, "fr", name, type, null, name);
+ return new PlayerInfo(name, type, null, name);
}
diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts
index be5155d5a..4858ddafb 100644
--- a/tests/util/TestServerConfig.ts
+++ b/tests/util/TestServerConfig.ts
@@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
+ stripePublishableKey(): string {
+ throw new Error("Method not implemented.");
+ }
cloudflareConfigPath(): string {
throw new Error("Method not implemented.");
}
diff --git a/tests/util/utils.ts b/tests/util/utils.ts
index 921e5d102..dbdfb3d1d 100644
--- a/tests/util/utils.ts
+++ b/tests/util/utils.ts
@@ -15,7 +15,7 @@ export function constructionExecution(
unit: UnitType,
ticks = 4,
) {
- game.addExecution(new ConstructionExecution(_owner, game.ref(x, y), unit));
+ game.addExecution(new ConstructionExecution(_owner, unit, game.ref(x, y)));
// 4 ticks by default as it usually goes like this
// Init of construction execution
diff --git a/webpack.config.js b/webpack.config.js
index 78721528f..0820e8cd0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -87,7 +87,7 @@ export default async (env, argv) => {
},
},
{
- test: /\.(woff|woff2|eot|ttf|otf)$/,
+ test: /\.(woff|woff2|eot|ttf|otf|xml)$/,
type: "asset/resource", // Changed from file-loader
generator: {
filename: "fonts/[name].[contenthash][ext]", // Added content hash and fixed path
@@ -126,6 +126,9 @@ export default async (env, argv) => {
),
"process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"),
"process.env.GIT_COMMIT": JSON.stringify(gitCommit),
+ "process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify(
+ process.env.STRIPE_PUBLISHABLE_KEY,
+ ),
}),
new CopyPlugin({
patterns: [