@@ -236,7 +225,6 @@ export class ChatModal extends LitElement {
this.eventBus.emit(
new SendQuickChatEvent(
- this.sender,
this.recipient,
this.selectedQuickChatKey,
variables,
@@ -307,4 +295,35 @@ export class ChatModal extends LitElement {
public setSender(value: PlayerView) {
this.sender = value;
}
+
+ public openWithSelection(
+ categoryId: string,
+ phraseKey: string,
+ sender?: PlayerView,
+ recipient?: PlayerView,
+ ) {
+ if (sender && recipient) {
+ const alivePlayerNames = this.g
+ .players()
+ .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
+ .map((p) => p.data.name);
+
+ this.players = alivePlayerNames;
+ this.recipient = recipient;
+ this.sender = sender;
+ }
+
+ this.selectCategory(categoryId);
+
+ const phrase = this.getPhrasesForCategory(categoryId).find(
+ (p) => p.key === phraseKey,
+ );
+
+ if (phrase) {
+ this.selectPhrase(phrase);
+ }
+
+ this.requestUpdate();
+ this.modalEl?.open();
+ }
}
diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts
index 0ec35f9af..c01ae4bc9 100644
--- a/src/client/graphics/layers/ControlPanel.ts
+++ b/src/client/graphics/layers/ControlPanel.ts
@@ -2,8 +2,8 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
+import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
-import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
@@ -13,7 +13,6 @@ import { Layer } from "./Layer";
@customElement("control-panel")
export class ControlPanel extends LitElement implements Layer {
public game: GameView;
- public clientID: ClientID;
public eventBus: EventBus;
public uiState: UIState;
@@ -48,10 +47,10 @@ export class ControlPanel extends LitElement implements Layer {
private _manpower: number = 0;
@state()
- private _gold: number;
+ private _gold: Gold;
@state()
- private _goldPerSecond: number;
+ private _goldPerSecond: Gold;
private _lastPopulationIncreaseRate: number;
@@ -126,7 +125,7 @@ export class ControlPanel extends LitElement implements Layer {
this._troops = player.troops();
this._workers = player.workers();
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
- this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10;
+ this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
this.currentTroopRatio = player.troops() / player.population();
this.requestUpdate();
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index f95f4d84b..ef236985e 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -23,7 +23,6 @@ import {
TargetPlayerUpdate,
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
-import { ClientID } from "../../../core/Schemas";
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
@@ -66,7 +65,6 @@ interface Event {
export class EventsDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
- public clientID: ClientID;
private active: boolean = false;
private events: Event[] = [];
@@ -184,7 +182,7 @@ export class EventsDisplay extends LitElement implements Layer {
renderLayer(): void {}
onDisplayMessageEvent(event: DisplayMessageUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (
event.playerID !== null &&
(!myPlayer || myPlayer.smallID() !== event.playerID)
@@ -202,7 +200,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onDisplayChatEvent(event: DisplayChatMessageUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (
event.playerID === null ||
!myPlayer ||
@@ -230,7 +228,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onAllianceRequestEvent(update: AllianceRequestUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer || update.recipientID !== myPlayer.smallID()) {
return;
}
@@ -282,7 +280,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer || update.request.requestorID !== myPlayer.smallID()) {
return;
}
@@ -303,7 +301,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onBrokeAllianceEvent(update: BrokeAllianceUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView;
@@ -341,7 +339,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onAllianceExpiredEvent(update: AllianceExpiredUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const otherID =
@@ -365,7 +363,7 @@ export class EventsDisplay extends LitElement implements Layer {
onTargetPlayerEvent(event: TargetPlayerUpdate) {
const other = this.game.playerBySmallID(event.playerID) as PlayerView;
- const myPlayer = this.game.playerByClientID(this.clientID) as PlayerView;
+ const myPlayer = this.game.myPlayer() as PlayerView;
if (!myPlayer || !myPlayer.isFriendly(other)) return;
const target = this.game.playerBySmallID(event.targetID) as PlayerView;
@@ -380,13 +378,13 @@ export class EventsDisplay extends LitElement implements Layer {
}
emitCancelAttackIntent(id: string) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
- this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id));
+ this.eventBus.emit(new CancelAttackIntentEvent(id));
}
emitBoatCancelIntent(id: number) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.eventBus.emit(new CancelBoatIntentEvent(id));
}
@@ -406,7 +404,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onEmojiMessageEvent(update: EmojiUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const recipient =
@@ -441,7 +439,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
onUnitIncomingEvent(event: UnitIncomingUpdate) {
- const myPlayer = this.game.playerByClientID(this.clientID);
+ const myPlayer = this.game.myPlayer();
if (!myPlayer || myPlayer.smallID() !== event.playerID) {
return;
diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts
new file mode 100644
index 000000000..5c1e206d9
--- /dev/null
+++ b/src/client/graphics/layers/HeadsUpMessage.ts
@@ -0,0 +1,47 @@
+import { LitElement, html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { GameView } from "../../../core/game/GameView";
+import { translateText } from "../../Utils";
+import { Layer } from "./Layer";
+
+@customElement("heads-up-message")
+export class HeadsUpMessage extends LitElement implements Layer {
+ public game: GameView;
+
+ @state()
+ private isVisible = false;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ init() {
+ this.isVisible = true;
+ this.requestUpdate();
+ }
+
+ tick() {
+ if (!this.game.inSpawnPhase()) {
+ this.isVisible = false;
+ this.requestUpdate();
+ }
+ }
+
+ render() {
+ if (!this.isVisible) {
+ return html``;
+ }
+
+ return html`
+
e.preventDefault()}
+ >
+ ${translateText("heads_up_message.choose_spawn")}
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts
index fb22697b4..1d9dbb35d 100644
--- a/src/client/graphics/layers/Leaderboard.ts
+++ b/src/client/graphics/layers/Leaderboard.ts
@@ -4,7 +4,6 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { translateText } from "../../../client/Utils";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
-import { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
@@ -36,7 +35,6 @@ export class GoToUnitEvent implements GameEvent {
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView | null = null;
- public clientID: ClientID | null = null;
public eventBus: EventBus | null = null;
players: Entry[] = [];
@@ -46,6 +44,12 @@ export class Leaderboard extends LitElement implements Layer {
private _shownOnInit = false;
private showTopFive = true;
+ @state()
+ private _sortKey: "tiles" | "gold" | "troops" = "tiles";
+
+ @state()
+ private _sortOrder: "asc" | "desc" = "desc";
+
init() {}
tick() {
@@ -64,18 +68,39 @@ export class Leaderboard extends LitElement implements Layer {
}
}
+ private setSort(key: "tiles" | "gold" | "troops") {
+ if (this._sortKey === key) {
+ this._sortOrder = this._sortOrder === "asc" ? "desc" : "asc";
+ } else {
+ this._sortKey = key;
+ this._sortOrder = "desc";
+ }
+ this.updateLeaderboard();
+ }
+
private updateLeaderboard() {
if (this.game === null) throw new Error("Not initialized");
- if (this.clientID === null) {
- return;
- }
- const myPlayer =
- this.game.playerViews().find((p) => p.clientID() === this.clientID) ??
- null;
+ const myPlayer = this.game.myPlayer();
- const sorted = this.game
- .playerViews()
- .sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
+ let sorted = this.game.playerViews();
+
+ const compare = (a: number, b: number) =>
+ this._sortOrder === "asc" ? a - b : b - a;
+
+ switch (this._sortKey) {
+ case "gold":
+ sorted = sorted.sort((a, b) =>
+ compare(Number(a.gold()), Number(b.gold())),
+ );
+ break;
+ case "troops":
+ sorted = sorted.sort((a, b) => compare(a.troops(), b.troops()));
+ break;
+ default:
+ sorted = sorted.sort((a, b) =>
+ compare(a.numTilesOwned(), b.numTilesOwned()),
+ );
+ }
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
@@ -181,6 +206,8 @@ export class Leaderboard extends LitElement implements Layer {
th {
background-color: rgb(31 41 55 / 0.5);
color: white;
+ cursor: pointer;
+ user-select: none;
}
.myPlayer {
font-weight: bold;
@@ -282,9 +309,30 @@ export class Leaderboard extends LitElement implements Layer {
| ${translateText("leaderboard.rank")} |
${translateText("leaderboard.player")} |
- ${translateText("leaderboard.owned")} |
- ${translateText("leaderboard.gold")} |
- ${translateText("leaderboard.troops")} |
+ this.setSort("tiles")}>
+ ${translateText("leaderboard.owned")}
+ ${this._sortKey === "tiles"
+ ? this._sortOrder === "asc"
+ ? "โฌ๏ธ"
+ : "โฌ๏ธ"
+ : ""}
+ |
+ this.setSort("gold")}>
+ ${translateText("leaderboard.gold")}
+ ${this._sortKey === "gold"
+ ? this._sortOrder === "asc"
+ ? "โฌ๏ธ"
+ : "โฌ๏ธ"
+ : ""}
+ |
+ this.setSort("troops")}>
+ ${translateText("leaderboard.troops")}
+ ${this._sortKey === "troops"
+ ? this._sortOrder === "asc"
+ ? "โฌ๏ธ"
+ : "โฌ๏ธ"
+ : ""}
+ |
diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts
new file mode 100644
index 000000000..739815f3c
--- /dev/null
+++ b/src/client/graphics/layers/MainRadialMenu.ts
@@ -0,0 +1,285 @@
+import { LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import { EventBus } from "../../../core/EventBus";
+import { PlayerActions, UnitType } from "../../../core/game/Game";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { TransformHandler } from "../TransformHandler";
+import { UIState } from "../UIState";
+import { BuildMenu } from "./BuildMenu";
+import { ChatIntegration } from "./ChatIntegration";
+import { EmojiTable } from "./EmojiTable";
+import { Layer } from "./Layer";
+import { MenuEventManager } from "./MenuEventManager";
+import { PlayerActionHandler } from "./PlayerActionHandler";
+import { PlayerInfoOverlay } from "./PlayerInfoOverlay";
+import { PlayerPanel } from "./PlayerPanel";
+import { RadialMenu, RadialMenuConfig } from "./RadialMenu";
+import {
+ COLORS,
+ MenuElementParams,
+ Slot,
+ createRadialMenuItems,
+ getRootMenuItems,
+ updateCenterButton,
+} from "./RadialMenuElements";
+
+import boatIcon from "../../../../resources/images/BoatIconWhite.svg";
+import buildIcon from "../../../../resources/images/BuildIconWhite.svg";
+import infoIcon from "../../../../resources/images/InfoIcon.svg";
+import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
+
+@customElement("main-radial-menu")
+export class MainRadialMenu extends LitElement implements Layer {
+ private radialMenu: RadialMenu;
+ private lastTickRefresh: number = 0;
+ private tickRefreshInterval: number = 500;
+ private needsRefresh: boolean = false;
+
+ private playerActionHandler: PlayerActionHandler;
+ private menuEventManager: MenuEventManager;
+ private chatIntegration: ChatIntegration;
+
+ constructor(
+ private eventBus: EventBus,
+ private game: GameView,
+ private transformHandler: TransformHandler,
+ private emojiTable: EmojiTable,
+ private buildMenu: BuildMenu,
+ private uiState: UIState,
+ private playerInfoOverlay: PlayerInfoOverlay,
+ private playerPanel: PlayerPanel,
+ ) {
+ super();
+
+ const menuConfig: RadialMenuConfig = {
+ centerButtonIcon: swordIcon,
+ tooltipStyle: `
+ .radial-tooltip .cost {
+ margin-top: 4px;
+ color: ${COLORS.tooltip.cost};
+ }
+ .radial-tooltip .count {
+ color: ${COLORS.tooltip.count};
+ }
+ `,
+ };
+
+ this.radialMenu = new RadialMenu(menuConfig);
+
+ this.playerActionHandler = new PlayerActionHandler(
+ this.eventBus,
+ this.uiState,
+ );
+
+ this.menuEventManager = new MenuEventManager(
+ this.eventBus,
+ this.game,
+ this.transformHandler,
+ this.radialMenu,
+ this.buildMenu,
+ this.emojiTable,
+ this.playerInfoOverlay,
+ this.playerPanel,
+ );
+
+ this.chatIntegration = new ChatIntegration(this.game, this.eventBus);
+
+ this.radialMenu.setRootMenuItems(getRootMenuItems());
+ }
+
+ init() {
+ this.radialMenu.init();
+
+ this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => {
+ this.handlePlayerActions(myPlayer, actions, tile);
+ });
+
+ this.menuEventManager.init();
+ }
+
+ private async handlePlayerActions(
+ myPlayer: PlayerView,
+ actions: PlayerActions,
+ tile: TileRef,
+ ) {
+ this.buildMenu.playerActions = actions;
+
+ const tileOwner = this.game.owner(tile);
+ const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
+
+ if (myPlayer && recipient) {
+ this.chatIntegration.setupChatModal(myPlayer, recipient);
+ }
+
+ const params: MenuElementParams = {
+ myPlayer,
+ selected: recipient,
+ tileOwner,
+ tile,
+ playerActions: actions,
+ game: this.game,
+ buildMenu: this.buildMenu,
+ emojiTable: this.emojiTable,
+ playerActionHandler: this.playerActionHandler,
+ playerPanel: this.playerPanel,
+ chatIntegration: this.chatIntegration,
+ closeMenu: () => this.menuEventManager.closeMenu(),
+ };
+
+ const menuItems = createRadialMenuItems(params);
+
+ this.radialMenu.setRootMenuItems(menuItems);
+
+ updateCenterButton(params, (enabled, action) => {
+ this.radialMenu.enableCenterButton(enabled, action);
+ });
+ }
+
+ async tick() {
+ const clickedCell = this.menuEventManager.getClickedCell();
+ if (!this.radialMenu.isMenuVisible() || clickedCell === null) return;
+
+ const currentTime = new Date().getTime();
+ if (
+ currentTime - this.lastTickRefresh < this.tickRefreshInterval &&
+ !this.needsRefresh
+ ) {
+ return;
+ }
+
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer === null || !myPlayer.isAlive()) return;
+
+ const tile = this.game.ref(clickedCell.x, clickedCell.y);
+
+ const isSpawnPhase = this.game.inSpawnPhase();
+ const wasInSpawnPhase = this.menuEventManager.getWasInSpawnPhase();
+
+ if (wasInSpawnPhase !== isSpawnPhase) {
+ if (wasInSpawnPhase && !isSpawnPhase) {
+ this.needsRefresh = true;
+ this.menuEventManager.setWasInSpawnPhase(isSpawnPhase);
+
+ const actions = await this.playerActionHandler.getPlayerActions(
+ myPlayer,
+ tile,
+ );
+ this.updateMenuState(myPlayer, actions, tile);
+ this.radialMenu.refreshMenu();
+ return;
+ }
+
+ this.menuEventManager.closeMenu();
+ return;
+ }
+
+ // Check if tile ownership has changed
+ const originalTileOwner = this.menuEventManager.getOriginalTileOwner();
+ if (originalTileOwner && originalTileOwner.isPlayer()) {
+ if (this.game.owner(tile) !== originalTileOwner) {
+ this.menuEventManager.closeMenu();
+ return;
+ }
+ } else if (originalTileOwner) {
+ if (
+ this.game.owner(tile).isPlayer() ||
+ this.game.owner(tile) === myPlayer
+ ) {
+ this.menuEventManager.closeMenu();
+ return;
+ }
+ }
+
+ this.lastTickRefresh = currentTime;
+ this.needsRefresh = false;
+
+ const actions = await this.playerActionHandler.getPlayerActions(
+ myPlayer,
+ tile,
+ );
+ this.updateMenuState(myPlayer, actions, tile);
+ }
+
+ private updateMenuState(
+ myPlayer: PlayerView,
+ actions: PlayerActions,
+ tile: TileRef,
+ ) {
+ if (!this.radialMenu.isMenuVisible()) return;
+
+ const tileOwner = this.game.owner(tile);
+ const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
+
+ const params: MenuElementParams = {
+ myPlayer,
+ selected: recipient,
+ tileOwner,
+ tile,
+ playerActions: actions,
+ game: this.game,
+ buildMenu: this.buildMenu,
+ emojiTable: this.emojiTable,
+ playerActionHandler: this.playerActionHandler,
+ playerPanel: this.playerPanel,
+ chatIntegration: this.chatIntegration,
+ closeMenu: () => this.menuEventManager.closeMenu(),
+ };
+
+ if (this.radialMenu.getCurrentLevel() === 0) {
+ updateCenterButton(params, (enabled, action) => {
+ this.radialMenu.enableCenterButton(enabled, action);
+ });
+ }
+
+ const canBuildTransport = actions.buildableUnits.find(
+ (bu) => bu.type === UnitType.TransportShip,
+ )?.canBuild;
+
+ this.radialMenu.updateMenuItem(
+ Slot.Build,
+ !this.game.inSpawnPhase(),
+ COLORS.build,
+ buildIcon,
+ );
+
+ if (actions?.interaction?.canSendAllianceRequest) {
+ this.radialMenu.updateMenuItem(Slot.Ally, true, COLORS.ally, undefined);
+ } else if (actions?.interaction?.canBreakAlliance) {
+ this.radialMenu.updateMenuItem(
+ Slot.Ally,
+ true,
+ COLORS.breakAlly,
+ undefined,
+ );
+ } else {
+ this.radialMenu.updateMenuItem(Slot.Ally, false, undefined, undefined);
+ }
+
+ this.radialMenu.updateMenuItem(
+ Slot.Boat,
+ !!canBuildTransport,
+ COLORS.boat,
+ boatIcon,
+ );
+
+ this.radialMenu.updateMenuItem(
+ Slot.Info,
+ this.game.hasOwner(tile),
+ COLORS.info,
+ infoIcon,
+ );
+ }
+
+ renderLayer(context: CanvasRenderingContext2D) {
+ this.radialMenu.renderLayer(context);
+ }
+
+ shouldTransform(): boolean {
+ return this.radialMenu.shouldTransform();
+ }
+
+ redraw() {
+ // No redraw implementation needed
+ }
+}
diff --git a/src/client/graphics/layers/MenuEventManager.ts b/src/client/graphics/layers/MenuEventManager.ts
new file mode 100644
index 000000000..1104529b2
--- /dev/null
+++ b/src/client/graphics/layers/MenuEventManager.ts
@@ -0,0 +1,185 @@
+import { EventBus } from "../../../core/EventBus";
+import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import {
+ CloseViewEvent,
+ ContextMenuEvent,
+ MouseUpEvent,
+ ShowBuildMenuEvent,
+} from "../../InputHandler";
+import { SendSpawnIntentEvent } from "../../Transport";
+import { TransformHandler } from "../TransformHandler";
+import { BuildMenu } from "./BuildMenu";
+import { EmojiTable } from "./EmojiTable";
+import { PlayerInfoOverlay } from "./PlayerInfoOverlay";
+import { PlayerPanel } from "./PlayerPanel";
+import { RadialMenu } from "./RadialMenu";
+
+export type ContextMenuCallback = (
+ myPlayer: PlayerView,
+ tile: TileRef,
+ actions: PlayerActions,
+) => void;
+
+export class MenuEventManager {
+ private clickedCell: Cell | null = null;
+ private lastClosed: number = 0;
+ private originalTileOwner: PlayerView | TerraNullius | null = null;
+ private wasInSpawnPhase: boolean = false;
+ private onContextMenuCallback: ContextMenuCallback | null = null;
+
+ constructor(
+ private eventBus: EventBus,
+ private game: GameView,
+ private transformHandler: TransformHandler,
+ private radialMenu: RadialMenu,
+ private buildMenu: BuildMenu,
+ private emojiTable: EmojiTable,
+ private playerInfoOverlay: PlayerInfoOverlay,
+ private playerPanel: PlayerPanel,
+ ) {}
+
+ init() {
+ this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e));
+ this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e));
+ this.eventBus.on(CloseViewEvent, () => this.closeMenu());
+ this.eventBus.on(ShowBuildMenuEvent, (e) => this.onShowBuildMenu(e));
+ }
+
+ setContextMenuCallback(callback: ContextMenuCallback) {
+ this.onContextMenuCallback = callback;
+ }
+
+ onContextMenu(event: ContextMenuEvent): Cell | null {
+ if (this.lastClosed + 200 > new Date().getTime()) return null;
+
+ this.closeMenu();
+
+ if (this.radialMenu.isMenuVisible()) {
+ this.radialMenu.hideRadialMenu();
+ return null;
+ } else {
+ this.radialMenu.showRadialMenu(event.x, event.y);
+ }
+
+ this.radialMenu.disableAllButtons();
+ this.clickedCell = this.transformHandler.screenToWorldCoordinates(
+ event.x,
+ event.y,
+ );
+
+ if (
+ !this.clickedCell ||
+ !this.game.isValidCoord(this.clickedCell.x, this.clickedCell.y)
+ ) {
+ return null;
+ }
+
+ const tile = this.game.ref(this.clickedCell.x, this.clickedCell.y);
+ this.originalTileOwner = this.game.owner(tile);
+ this.wasInSpawnPhase = this.game.inSpawnPhase();
+
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer === null) {
+ throw new Error("my player not found");
+ }
+
+ if (myPlayer && !myPlayer.isAlive() && !this.game.inSpawnPhase()) {
+ this.radialMenu.hideRadialMenu();
+ return null;
+ }
+
+ if (this.game.inSpawnPhase()) {
+ if (this.game.isLand(tile) && !this.game.hasOwner(tile)) {
+ this.radialMenu.enableCenterButton(true, () => {
+ if (this.clickedCell === null) return;
+ this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell));
+ this.radialMenu.hideRadialMenu();
+ });
+
+ return this.clickedCell;
+ }
+ }
+
+ myPlayer.actions(tile).then((actions) => {
+ if (this.onContextMenuCallback) {
+ this.onContextMenuCallback(myPlayer, tile, actions);
+ }
+ });
+
+ return this.clickedCell;
+ }
+
+ getClickedCell(): Cell | null {
+ return this.clickedCell;
+ }
+
+ getOriginalTileOwner(): PlayerView | TerraNullius | null {
+ return this.originalTileOwner;
+ }
+
+ getWasInSpawnPhase(): boolean {
+ return this.wasInSpawnPhase;
+ }
+
+ setWasInSpawnPhase(value: boolean) {
+ this.wasInSpawnPhase = value;
+ }
+
+ onPointerUp(event: MouseUpEvent) {
+ this.playerInfoOverlay.hide();
+ this.hideEverything();
+ }
+
+ onShowBuildMenu(e: ShowBuildMenuEvent): TileRef | null {
+ const clickedCell = this.transformHandler.screenToWorldCoordinates(
+ e.x,
+ e.y,
+ );
+ if (clickedCell === null) {
+ return null;
+ }
+ if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) {
+ return null;
+ }
+ const tile = this.game.ref(clickedCell.x, clickedCell.y);
+ const p = this.game.myPlayer();
+ if (p === null) {
+ return null;
+ }
+ this.buildMenu.showMenu(tile);
+ return tile;
+ }
+
+ closeMenu() {
+ if (this.radialMenu.isMenuVisible()) {
+ this.radialMenu.hideRadialMenu();
+ }
+
+ if (this.buildMenu.isVisible) {
+ this.buildMenu.hideMenu();
+ }
+
+ if (this.emojiTable.isVisible) {
+ this.emojiTable.hideTable();
+ }
+
+ if (this.playerPanel.isVisible) {
+ this.playerPanel.hide();
+ }
+ }
+
+ hideEverything() {
+ if (this.radialMenu.isMenuVisible()) {
+ this.radialMenu.hideRadialMenu();
+ this.lastClosed = new Date().getTime();
+ }
+ this.emojiTable.hideTable();
+ this.buildMenu.hideMenu();
+ }
+
+ enableCenterButton(enabled: boolean, action: () => void) {
+ this.radialMenu.enableCenterButton(enabled, action);
+ }
+}
diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts
index 08b3ef2e4..23f596c01 100644
--- a/src/client/graphics/layers/NameLayer.ts
+++ b/src/client/graphics/layers/NameLayer.ts
@@ -1,18 +1,20 @@
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
-import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg";
+import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
+import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg";
-import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
+import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
+import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import { PseudoRandom } from "../../../core/PseudoRandom";
-import { ClientID } from "../../../core/Schemas";
import { Theme } from "../../../core/configuration/Config";
import { AllPlayers, Cell, nukeTypes, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
+import { UserSettings } from "../../../core/game/UserSettings";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
@@ -40,23 +42,24 @@ export class NameLayer implements Layer {
private seenPlayers: Set = new Set();
private traitorIconImage: HTMLImageElement;
private disconnectedIconImage: HTMLImageElement;
- private allianceRequestIconImage: HTMLImageElement;
+ private allianceRequestBlackIconImage: HTMLImageElement;
+ private allianceRequestWhiteIconImage: HTMLImageElement;
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
- private embargoIconImage: HTMLImageElement;
+ private embargoBlackIconImage: HTMLImageElement;
+ private embargoWhiteIconImage: HTMLImageElement;
private nukeWhiteIconImage: HTMLImageElement;
private nukeRedIconImage: HTMLImageElement;
private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
- private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
private theme: Theme = this.game.config().theme();
+ private userSettings: UserSettings = new UserSettings();
constructor(
private game: GameView,
private transformHandler: TransformHandler,
- private clientID: ClientID,
) {
this.traitorIconImage = new Image();
this.traitorIconImage.src = traitorIcon;
@@ -64,14 +67,18 @@ export class NameLayer implements Layer {
this.disconnectedIconImage.src = disconnectedIcon;
this.allianceIconImage = new Image();
this.allianceIconImage.src = allianceIcon;
- this.allianceRequestIconImage = new Image();
- this.allianceRequestIconImage.src = allianceRequestIcon;
+ this.allianceRequestBlackIconImage = new Image();
+ this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon;
+ this.allianceRequestWhiteIconImage = new Image();
+ this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon;
this.crownIconImage = new Image();
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
- this.embargoIconImage = new Image();
- this.embargoIconImage.src = embargoIcon;
+ this.embargoBlackIconImage = new Image();
+ this.embargoBlackIconImage.src = embargoBlackIcon;
+ this.embargoWhiteIconImage = new Image();
+ this.embargoWhiteIconImage.src = embargoWhiteIcon;
this.nukeWhiteIconImage = new Image();
this.nukeWhiteIconImage.src = nukeWhiteIcon;
this.nukeRedIconImage = new Image();
@@ -218,18 +225,32 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
- const shieldDiv = document.createElement("div");
- shieldDiv.classList.add("player-shield");
- shieldDiv.style.zIndex = "3";
- shieldDiv.style.marginTop = "-5%";
- shieldDiv.style.display = "flex";
- shieldDiv.style.alignItems = "center";
- shieldDiv.style.gap = "0px";
- shieldDiv.innerHTML = `
-
- 0
- `;
- element.appendChild(shieldDiv);
+ // TODO: Remove the shield icon.
+ /* eslint-disable no-constant-condition */
+ if (false) {
+ const shieldDiv = document.createElement("div");
+ shieldDiv.classList.add("player-shield");
+ shieldDiv.style.zIndex = "3";
+ shieldDiv.style.marginTop = "-5%";
+ shieldDiv.style.display = "flex";
+ shieldDiv.style.alignItems = "center";
+ shieldDiv.style.gap = "0px";
+ const shieldImg = document.createElement("img");
+ shieldImg.src = this.shieldIconImage.src;
+ shieldImg.style.width = "16px";
+ shieldImg.style.height = "16px";
+
+ const shieldSpan = document.createElement("span");
+ shieldSpan.textContent = "0";
+ shieldSpan.style.color = "black";
+ shieldSpan.style.fontSize = "10px";
+ shieldSpan.style.marginTop = "-2px";
+
+ shieldDiv.appendChild(shieldImg);
+ shieldDiv.appendChild(shieldSpan);
+ element.appendChild(shieldDiv);
+ }
+ /* eslint-enable no-constant-condition */
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
@@ -298,11 +319,10 @@ export class NameLayer implements Layer {
const density = renderNumber(
render.player.troops() / render.player.numTilesOwned(),
);
- const shieldDiv = render.element.querySelector(
- ".player-shield",
- ) as HTMLDivElement;
- const shieldImg = shieldDiv.querySelector("img");
- const shieldNumber = shieldDiv.querySelector("span");
+ const shieldDiv: HTMLDivElement | null =
+ render.element.querySelector(".player-shield");
+ const shieldImg = shieldDiv?.querySelector("img");
+ const shieldNumber = shieldDiv?.querySelector("span");
if (shieldImg) {
shieldImg.style.width = `${render.fontSize * 0.8}px`;
shieldImg.style.height = `${render.fontSize * 0.8}px`;
@@ -318,7 +338,8 @@ export class NameLayer implements Layer {
".player-icons",
) as HTMLDivElement;
const iconSize = Math.min(render.fontSize * 1.5, 48);
- const myPlayer = this.getPlayer();
+ const myPlayer = this.game.myPlayer();
+ const isDarkMode = this.userSettings.darkMode();
// Crown icon
const existingCrown = iconsDiv.querySelector('[data-icon="crown"]');
@@ -388,13 +409,27 @@ export class NameLayer implements Layer {
}
// Alliance request icon
- const data = '[data-icon="alliance-request"]';
- const existingRequestAlliance = iconsDiv.querySelector(data);
+ let existingRequestAlliance = iconsDiv.querySelector(
+ '[data-icon="alliance-request"]',
+ );
+ const isThemeAllianceRequestIcon =
+ existingRequestAlliance?.getAttribute("dark-mode") ===
+ isDarkMode.toString();
+ const AllianceRequestIconImageSrc = isDarkMode
+ ? this.allianceRequestWhiteIconImage.src
+ : this.allianceRequestBlackIconImage.src;
+
if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) {
+ // Create new icon to match theme
+ if (existingRequestAlliance && !isThemeAllianceRequestIcon) {
+ existingRequestAlliance.remove();
+ existingRequestAlliance = null;
+ }
+
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
- this.allianceRequestIconImage.src,
+ AllianceRequestIconImageSrc,
iconSize,
"alliance-request",
),
@@ -449,19 +484,28 @@ export class NameLayer implements Layer {
existingEmoji.remove();
}
- const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
+ // Embargo icon
+ let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
const hasEmbargo =
myPlayer &&
(render.player.hasEmbargoAgainst(myPlayer) ||
myPlayer.hasEmbargoAgainst(render.player));
+ const isThemeEmbargoIcon =
+ existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString();
+ const embargoIconImageSrc = isDarkMode
+ ? this.embargoWhiteIconImage.src
+ : this.embargoBlackIconImage.src;
+
if (myPlayer && hasEmbargo) {
+ // Create new icon to match theme
+ if (existingEmbargo && !isThemeEmbargoIcon) {
+ existingEmbargo.remove();
+ existingEmbargo = null;
+ }
+
if (!existingEmbargo) {
iconsDiv.appendChild(
- this.createIconElement(
- this.embargoIconImage.src,
- iconSize,
- "embargo",
- ),
+ this.createIconElement(embargoIconImageSrc, iconSize, "embargo"),
);
}
} else if (existingEmbargo) {
@@ -535,6 +579,7 @@ export class NameLayer implements Layer {
icon.style.width = `${size}px`;
icon.style.height = `${size}px`;
icon.setAttribute("data-icon", id);
+ icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
if (center) {
icon.style.position = "absolute";
icon.style.top = "50%";
@@ -542,14 +587,4 @@ export class NameLayer implements Layer {
}
return icon;
}
-
- private getPlayer(): PlayerView | null {
- if (this.myPlayer !== null) {
- return this.myPlayer;
- }
- this.myPlayer =
- this.game.playerViews().find((p) => p.clientID() === this.clientID) ??
- null;
- return this.myPlayer;
- }
}
diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts
new file mode 100644
index 000000000..b8bb154bf
--- /dev/null
+++ b/src/client/graphics/layers/PlayerActionHandler.ts
@@ -0,0 +1,109 @@
+import { EventBus } from "../../../core/EventBus";
+import { Cell, PlayerActions, UnitType } from "../../../core/game/Game";
+import { TileRef } from "../../../core/game/GameMap";
+import { PlayerView } from "../../../core/game/GameView";
+import {
+ BuildUnitIntentEvent,
+ SendAllianceRequestIntentEvent,
+ SendAttackIntentEvent,
+ SendBoatAttackIntentEvent,
+ SendBreakAllianceIntentEvent,
+ SendDonateGoldIntentEvent,
+ SendDonateTroopsIntentEvent,
+ SendEmbargoIntentEvent,
+ SendEmojiIntentEvent,
+ SendQuickChatEvent,
+ SendSpawnIntentEvent,
+ SendTargetPlayerIntentEvent,
+} from "../../Transport";
+import { UIState } from "../UIState";
+
+export class PlayerActionHandler {
+ constructor(
+ private eventBus: EventBus,
+ private uiState: UIState,
+ ) {}
+
+ async getPlayerActions(
+ player: PlayerView,
+ tile: TileRef,
+ ): Promise {
+ return await player.actions(tile);
+ }
+
+ handleAttack(player: PlayerView, targetId: string | null) {
+ this.eventBus.emit(
+ new SendAttackIntentEvent(
+ targetId,
+ this.uiState.attackRatio * player.troops(),
+ ),
+ );
+ }
+
+ handleBoatAttack(
+ player: PlayerView,
+ targetId: string,
+ targetCell: Cell,
+ spawnTile: Cell | null,
+ ) {
+ this.eventBus.emit(
+ new SendBoatAttackIntentEvent(
+ targetId,
+ targetCell,
+ this.uiState.attackRatio * player.troops(),
+ spawnTile,
+ ),
+ );
+ }
+
+ async findBestTransportShipSpawn(
+ player: PlayerView,
+ tile: TileRef,
+ ): Promise {
+ return await player.bestTransportShipSpawn(tile);
+ }
+
+ handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) {
+ this.eventBus.emit(
+ new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)),
+ );
+ }
+
+ handleSpawn(spawnCell: Cell) {
+ this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
+ }
+
+ handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
+ this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient));
+ }
+
+ handleBreakAlliance(player: PlayerView, recipient: PlayerView) {
+ this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient));
+ }
+
+ handleTargetPlayer(targetId: string | null) {
+ if (!targetId) return;
+
+ this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId));
+ }
+
+ handleDonateGold(recipient: PlayerView) {
+ this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null));
+ }
+
+ handleDonateTroops(recipient: PlayerView) {
+ this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null));
+ }
+
+ handleEmbargo(recipient: PlayerView, action: "start" | "stop") {
+ this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action));
+ }
+
+ handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) {
+ this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex));
+ }
+
+ handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) {
+ this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params));
+ }
+}
diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts
index 394669f81..099a865e2 100644
--- a/src/client/graphics/layers/PlayerInfoOverlay.ts
+++ b/src/client/graphics/layers/PlayerInfoOverlay.ts
@@ -11,7 +11,6 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
-import { ClientID } from "../../../core/Schemas";
import { MouseMoveEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
@@ -42,9 +41,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@property({ type: Object })
public game!: GameView;
- @property({ type: String })
- public clientID!: ClientID;
-
@property({ type: Object })
public eventBus!: EventBus;
@@ -137,13 +133,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.requestUpdate();
}
- private myPlayer(): PlayerView | null {
- if (!this.game) {
- return null;
- }
- return this.game.playerByClientID(this.clientID);
- }
-
private getRelationClass(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
@@ -175,7 +164,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
private renderPlayerInfo(player: PlayerView) {
- const myPlayer = this.myPlayer();
+ const myPlayer = this.game.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
let relationHtml: TemplateResult | null = null;
const attackingTroops = player
@@ -212,7 +201,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
@@ -275,8 +264,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
private renderUnitInfo(unit: UnitView) {
const isAlly =
- (unit.owner() === this.myPlayer() ||
- this.myPlayer()?.isFriendly(unit.owner())) ??
+ (unit.owner() === this.game.myPlayer() ||
+ this.game.myPlayer()?.isFriendly(unit.owner())) ??
false;
return html`
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index 4e82be5bb..b9804d2d1 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer {
private tile: TileRef | null = null;
@state()
- private isVisible: boolean = false;
+ public isVisible: boolean = false;
@state()
private allianceExpiryText: string | null = null;
@@ -90,7 +90,6 @@ export class PlayerPanel extends LitElement implements Layer {
e.stopPropagation();
this.eventBus.emit(
new SendDonateTroopsIntentEvent(
- myPlayer,
other,
myPlayer.troops() * this.uiState.attackRatio,
),
@@ -104,7 +103,7 @@ export class PlayerPanel extends LitElement implements Layer {
other: PlayerView,
) {
e.stopPropagation();
- this.eventBus.emit(new SendDonateGoldIntentEvent(myPlayer, other, null));
+ this.eventBus.emit(new SendDonateGoldIntentEvent(other, null));
this.hide();
}
@@ -114,7 +113,7 @@ export class PlayerPanel extends LitElement implements Layer {
other: PlayerView,
) {
e.stopPropagation();
- this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start"));
+ this.eventBus.emit(new SendEmbargoIntentEvent(other, "start"));
this.hide();
}
@@ -124,7 +123,7 @@ export class PlayerPanel extends LitElement implements Layer {
other: PlayerView,
) {
e.stopPropagation();
- this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop"));
+ this.eventBus.emit(new SendEmbargoIntentEvent(other, "stop"));
this.hide();
}
@@ -330,6 +329,25 @@ export class PlayerPanel extends LitElement implements Layer {
+
+
+
+ ${translateText("player_panel.alliances")}
+ (${other.allies().length})
+
+
+ ${other.allies().length > 0
+ ? other
+ .allies()
+ .map((p) => p.name())
+ .join(", ")
+ : translateText("player_panel.none")}
+
+
+
${this.allianceExpiryText !== null
? html`
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts
index db79c507e..0ce75f91e 100644
--- a/src/client/graphics/layers/RadialMenu.ts
+++ b/src/client/graphics/layers/RadialMenu.ts
@@ -1,264 +1,166 @@
import * as d3 from "d3";
-import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
-import boatIcon from "../../../../resources/images/BoatIconWhite.svg";
-import buildIcon from "../../../../resources/images/BuildIconWhite.svg";
+import backIcon from "../../../../resources/images/BackIconWhite.svg";
import disabledIcon from "../../../../resources/images/DisabledIcon.svg";
-import infoIcon from "../../../../resources/images/InfoIcon.svg";
-import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
-import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
-import { consolex } from "../../../core/Consolex";
-import { EventBus } from "../../../core/EventBus";
-import {
- Cell,
- PlayerActions,
- TerraNullius,
- UnitType,
-} from "../../../core/game/Game";
-import { TileRef } from "../../../core/game/GameMap";
-import { GameView, PlayerView } from "../../../core/game/GameView";
-import { ClientID } from "../../../core/Schemas";
-import {
- CloseViewEvent,
- ContextMenuEvent,
- MouseUpEvent,
- ShowBuildMenuEvent,
-} from "../../InputHandler";
-import {
- SendAllianceRequestIntentEvent,
- SendAttackIntentEvent,
- SendBoatAttackIntentEvent,
- SendBreakAllianceIntentEvent,
- SendSpawnIntentEvent,
-} from "../../Transport";
-import { TransformHandler } from "../TransformHandler";
-import { UIState } from "../UIState";
-import { BuildMenu } from "./BuildMenu";
-import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
-import { PlayerInfoOverlay } from "./PlayerInfoOverlay";
-import { PlayerPanel } from "./PlayerPanel";
+import { MenuElement } from "./RadialMenuElements";
-enum Slot {
- Info,
- Boat,
- Build,
- Ally,
+export interface TooltipItem {
+ text: string;
+ className: string;
}
+export interface RadialMenuConfig {
+ menuSize?: number;
+ submenuScale?: number;
+ centerButtonSize?: number;
+ iconSize?: number;
+ centerIconSize?: number;
+ disabledColor?: string;
+ menuTransitionDuration?: number;
+ mainMenuInnerRadius?: number;
+ centerButtonIcon?: string;
+ maxNestedLevels?: number;
+ innerRadiusIncrement?: number;
+ tooltipStyle?: string;
+}
+
+type RequiredRadialMenuConfig = Required
;
+
export class RadialMenu implements Layer {
- private clickedCell: Cell | null = null;
- private lastClosed: number = 0;
-
- private originalTileOwner: PlayerView | TerraNullius;
private menuElement: d3.Selection;
+ private tooltipElement: HTMLDivElement | null = null;
private isVisible: boolean = false;
- private readonly menuItems: Map<
- Slot,
- {
- name: string;
- disabled: boolean;
- action: () => void;
- color?: string | null;
- icon?: string | null;
- }
- > = new Map([
- [
- Slot.Boat,
- {
- name: "boat",
- disabled: true,
- action: () => {},
- color: null,
- icon: null,
- },
- ],
- [Slot.Ally, { name: "ally", disabled: true, action: () => {} }],
- [Slot.Build, { name: "build", disabled: true, action: () => {} }],
- [
- Slot.Info,
- {
- name: "info",
- disabled: true,
- action: () => {},
- color: null,
- icon: null,
- },
- ],
- ]);
- private readonly menuSize = 190;
- private readonly centerButtonSize = 30;
- private readonly iconSize = 32;
- private readonly centerIconSize = 48;
- private readonly disabledColor = d3.rgb(128, 128, 128).toString();
+ private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.)
+ private menuStack: MenuElement[][] = []; // Stack to track menu navigation history
+ private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level)
+ private rootMenuItems: MenuElement[] = []; // Store the original root menu items
+
+ private readonly config: RequiredRadialMenuConfig;
+ private readonly backIconSize: number;
private isCenterButtonEnabled = false;
+ private originalCenterButtonEnabled = false;
+ private centerButtonAction: (() => void) | null = null;
+ private originalCenterButtonAction: (() => void) | null = null;
+ private backAction: (() => void) | null = null;
- constructor(
- private eventBus: EventBus,
- private g: GameView,
- private transformHandler: TransformHandler,
- private clientID: ClientID,
- private emojiTable: EmojiTable,
- private buildMenu: BuildMenu,
- private uiState: UIState,
- private playerInfoOverlay: PlayerInfoOverlay,
- private playerPanel: PlayerPanel,
- ) {}
+ private isTransitioning: boolean = false;
+ private lastHideTime: number = 0;
+ private reopenCooldownMs: number = 300;
- init() {
- this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e));
- this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e));
- this.eventBus.on(ShowBuildMenuEvent, (e) => {
- const clickedCell = this.transformHandler.screenToWorldCoordinates(
- e.x,
- e.y,
- );
- if (clickedCell === null) {
- return;
- }
- if (!this.g.isValidCoord(clickedCell.x, clickedCell.y)) {
- return;
- }
- const tile = this.g.ref(clickedCell.x, clickedCell.y);
- const p = this.g.playerByClientID(this.clientID);
- if (p === null) {
- return;
- }
- this.buildMenu.showMenu(tile);
- });
+ private menuGroups: Map<
+ number,
+ d3.Selection
+ > = new Map();
+ private menuPaths: Map<
+ string,
+ d3.Selection
+ > = new Map();
+ private menuIcons: Map<
+ string,
+ d3.Selection
+ > = new Map();
- this.eventBus.on(CloseViewEvent, () => this.closeMenu());
+ private selectedItemId: string | null = null;
+ private submenuHoverTimeout: number | null = null;
+ private backButtonHoverTimeout: number | null = null;
+ private navigationInProgress: boolean = false;
+ private originalCenterButtonIcon: string = "";
- this.createMenuElement();
+ constructor(config: RadialMenuConfig = {}) {
+ this.config = {
+ menuSize: config.menuSize ?? 190,
+ submenuScale: config.submenuScale ?? 1.5,
+ centerButtonSize: config.centerButtonSize ?? 30,
+ iconSize: config.iconSize ?? 32,
+ centerIconSize: config.centerIconSize ?? 48,
+ disabledColor: config.disabledColor ?? d3.rgb(128, 128, 128).toString(),
+ menuTransitionDuration: config.menuTransitionDuration ?? 300,
+ mainMenuInnerRadius: config.mainMenuInnerRadius ?? 40,
+ centerButtonIcon: config.centerButtonIcon ?? "",
+ maxNestedLevels: config.maxNestedLevels ?? 3,
+ innerRadiusIncrement: config.innerRadiusIncrement ?? 20,
+ tooltipStyle: config.tooltipStyle ?? "",
+ };
+ this.originalCenterButtonIcon = this.config.centerButtonIcon;
+ this.backIconSize = this.config.centerIconSize * 0.8;
}
- private closeMenu() {
- if (this.isVisible) {
- this.hideRadialMenu();
- }
-
- if (this.buildMenu.isVisible) {
- this.buildMenu.hideMenu();
- }
+ init() {
+ this.createMenuElement();
+ this.createTooltipElement();
}
private createMenuElement() {
+ // Create an overlay to catch clicks outside the menu
this.menuElement = d3
.select(document.body)
.append("div")
+ .attr("class", "radial-menu-container")
.style("position", "fixed")
.style("display", "none")
.style("z-index", "9999")
.style("touch-action", "none")
+ .style("top", "0")
+ .style("left", "0")
+ .style("width", "100vw")
+ .style("height", "100vh")
+ .on("click", () => {
+ this.hideRadialMenu();
+ })
.on("contextmenu", (e) => {
e.preventDefault();
this.hideRadialMenu();
});
+ // Calculate the total svg size needed for all potential nested menus
+ const totalSize =
+ this.config.menuSize *
+ Math.pow(this.config.submenuScale, this.config.maxNestedLevels - 1);
+
const svg = this.menuElement
.append("svg")
- .attr("width", this.menuSize)
- .attr("height", this.menuSize)
+ .attr("width", totalSize)
+ .attr("height", totalSize)
+ .style("position", "absolute")
+ .style("top", "50%")
+ .style("left", "50%")
+ .style("transform", "translate(-50%, -50%)")
+ .style("pointer-events", "all")
+ .on("click", (event) => this.hideRadialMenu());
+
+ const container = svg
.append("g")
- .attr(
- "transform",
- `translate(${this.menuSize / 2},${this.menuSize / 2})`,
- );
+ .attr("class", "menu-container")
+ .attr("transform", `translate(${totalSize / 2},${totalSize / 2})`);
- const pie = d3
- .pie()
- .value(() => 1)
- .padAngle(0.03)
- .startAngle(Math.PI / 4) // Start at 45 degrees (ฯ/4 radians)
- .endAngle(2 * Math.PI + Math.PI / 4); // Complete the circle but shifted by 45 degrees
-
- const arc = d3
- .arc()
- .innerRadius(this.centerButtonSize + 5)
- .outerRadius(this.menuSize / 2 - 10);
-
- const arcs = svg
- .selectAll("path")
- .data(pie(Array.from(this.menuItems.values())))
- .enter()
- .append("g");
-
- arcs
- .append("path")
- .attr("d", arc)
- .attr("fill", (d) =>
- d.data.disabled ? this.disabledColor : d.data.color,
- )
- .attr("stroke", "#ffffff")
- .attr("stroke-width", "2")
- .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer"))
- .style("opacity", (d) => (d.data.disabled ? 0.5 : 1))
- .attr("data-name", (d) => d.data.name)
- .on("mouseover", function (event, d) {
- if (!d.data.disabled) {
- d3.select(this)
- .transition()
- .duration(200)
- .attr("transform", "scale(1.05)")
- .attr("filter", "url(#glow)");
- }
- })
- .on("mouseout", function (event, d) {
- if (!d.data.disabled) {
- d3.select(this)
- .transition()
- .duration(200)
- .attr("transform", "scale(1)")
- .attr("filter", null);
- }
- })
- .on("click", (event, d) => {
- if (!d.data.disabled) {
- d.data.action();
- this.hideRadialMenu();
- }
- })
- .on("touchstart", (event, d) => {
- event.preventDefault();
- if (!d.data.disabled) {
- d.data.action();
- this.hideRadialMenu();
- }
- });
-
- arcs
- .append("image")
- .attr("xlink:href", (d) => d.data.icon)
- .attr("width", this.iconSize)
- .attr("height", this.iconSize)
- .attr("x", (d) => arc.centroid(d)[0] - this.iconSize / 2)
- .attr("y", (d) => arc.centroid(d)[1] - this.iconSize / 2)
- .style("pointer-events", "none")
- .attr("data-name", (d) => d.data.name);
-
- // Add glow filter
+ // Add glow filter for hover effects
const defs = svg.append("defs");
const filter = defs.append("filter").attr("id", "glow");
filter
.append("feGaussianBlur")
- .attr("stdDeviation", "3")
+ .attr("stdDeviation", "2")
.attr("result", "coloredBlur");
const feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
- const centerButton = svg.append("g").attr("class", "center-button");
+ const centerButton = container.append("g").attr("class", "center-button");
centerButton
.append("circle")
.attr("class", "center-button-hitbox")
- .attr("r", this.centerButtonSize)
+ .attr("r", this.config.centerButtonSize)
.attr("fill", "transparent")
.style("cursor", "pointer")
- .on("click", () => this.handleCenterButtonClick())
+ .on("click", (event) => {
+ event.stopPropagation();
+ this.handleCenterButtonClick();
+ })
.on("touchstart", (event: Event) => {
event.preventDefault();
+ event.stopPropagation();
this.handleCenterButtonClick();
})
.on("mouseover", () => this.onCenterButtonHover(true))
@@ -267,41 +169,878 @@ export class RadialMenu implements Layer {
centerButton
.append("circle")
.attr("class", "center-button-visible")
- .attr("r", this.centerButtonSize)
+ .attr("r", this.config.centerButtonSize)
.attr("fill", "#2c3e50")
.style("pointer-events", "none");
centerButton
.append("image")
.attr("class", "center-button-icon")
- .attr("xlink:href", swordIcon)
- .attr("width", this.centerIconSize)
- .attr("height", this.centerIconSize)
- .attr("x", -this.centerIconSize / 2)
- .attr("y", -this.centerIconSize / 2)
+ .attr("xlink:href", this.config.centerButtonIcon)
+ .attr("width", this.config.centerIconSize)
+ .attr("height", this.config.centerIconSize)
+ .attr("x", -this.config.centerIconSize / 2)
+ .attr("y", -this.config.centerIconSize / 2)
.style("pointer-events", "none");
}
- async tick() {
- // Only update when menu is visible
- if (!this.isVisible || this.clickedCell === null) return;
- const myPlayer = this.g.myPlayer();
- if (myPlayer === null || !myPlayer.isAlive()) return;
- const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
- if (this.originalTileOwner.isPlayer()) {
- if (this.g.owner(tile) !== this.originalTileOwner) {
- this.closeMenu();
- return;
+ private createTooltipElement() {
+ this.tooltipElement = document.createElement("div");
+ this.tooltipElement.className = "radial-tooltip";
+ this.tooltipElement.style.position = "absolute";
+ this.tooltipElement.style.pointerEvents = "none";
+ this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)";
+ this.tooltipElement.style.color = "white";
+ this.tooltipElement.style.padding = "6px 10px";
+ this.tooltipElement.style.borderRadius = "6px";
+ this.tooltipElement.style.fontSize = "12px";
+ this.tooltipElement.style.zIndex = "10000";
+ this.tooltipElement.style.maxWidth = "250px";
+ this.tooltipElement.style.display = "none";
+ document.body.appendChild(this.tooltipElement);
+
+ const style = document.createElement("style");
+ style.textContent = `
+ .radial-tooltip .title {
+ font-weight: bold;
+ font-size: 14px;
+ margin-bottom: 4px;
}
+
+ ${this.config.tooltipStyle}
+ `;
+ document.head.appendChild(style);
+ }
+
+ private getInnerRadiusForLevel(level: number): number {
+ return level === 0
+ ? this.config.mainMenuInnerRadius
+ : this.config.mainMenuInnerRadius + 34;
+ }
+
+ private getOuterRadiusForLevel(level: number): number {
+ const innerRadius = this.getInnerRadiusForLevel(level);
+ const arcWidth =
+ this.config.menuSize / 2 - this.config.mainMenuInnerRadius - 10;
+ return innerRadius + arcWidth;
+ }
+
+ private renderMenuItems(items: MenuElement[], level: number) {
+ const container = this.menuElement.select(".menu-container");
+ container.selectAll(`.menu-level-${level}`).remove();
+
+ const menuGroup = container
+ .append("g")
+ .attr("class", `menu-level-${level}`);
+
+ // Set initial animation styles
+ if (level === 0) {
+ menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)");
} else {
- if (this.g.owner(tile).isPlayer() || this.g.owner(tile) === myPlayer) {
- this.closeMenu();
+ menuGroup.style("opacity", 0).style("transform", "scale(0.5)");
+ }
+
+ this.menuGroups.set(level, menuGroup as any);
+
+ const pie = d3
+ .pie()
+ .value(() => 1)
+ .padAngle(0.03)
+ .startAngle(Math.PI / 3)
+ .endAngle(2 * Math.PI + Math.PI / 3);
+
+ const innerRadius = this.getInnerRadiusForLevel(level);
+ const outerRadius = this.getOuterRadiusForLevel(level);
+
+ const arc = d3
+ .arc>()
+ .innerRadius(innerRadius)
+ .outerRadius(outerRadius);
+
+ const arcs = menuGroup
+ .selectAll(".menu-item")
+ .data(pie(items))
+ .enter()
+ .append("g")
+ .attr("class", "menu-item-group");
+
+ this.renderPaths(arcs, arc, level);
+ this.setupEventHandlers(arcs, level);
+ this.renderIconsAndText(arcs, arc);
+ this.setupAnimations(menuGroup);
+
+ return menuGroup;
+ }
+
+ private renderPaths(
+ arcs: d3.Selection<
+ SVGGElement,
+ d3.PieArcDatum,
+ SVGGElement,
+ unknown
+ >,
+ arc: d3.Arc>,
+ level: number,
+ ) {
+ arcs
+ .append("path")
+ .attr("class", "menu-item-path")
+ .attr("d", arc)
+ .attr("fill", (d) => {
+ const color = d.data.disabled
+ ? this.config.disabledColor
+ : d.data.color || "#333333";
+ const opacity = d.data.disabled ? 0.5 : 0.7;
+
+ if (d.data.id === this.selectedItemId && this.currentLevel > level) {
+ return color;
+ }
+
+ return d3.color(color)?.copy({ opacity: opacity })?.toString() || color;
+ })
+ .attr("stroke", "#ffffff")
+ .attr("stroke-width", "2")
+ .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer"))
+ .style("opacity", (d) => (d.data.disabled ? 0.5 : 1))
+ .style(
+ "transition",
+ `filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${
+ this.config.menuTransitionDuration / 2
+ }ms, fill ${this.config.menuTransitionDuration / 2}ms`,
+ )
+ .attr("data-id", (d) => d.data.id);
+
+ arcs.each((d) => {
+ const pathId = d.data.id;
+ const path = d3.select(`path[data-id="${pathId}"]`);
+ this.menuPaths.set(pathId, path as any);
+
+ if (
+ pathId === this.selectedItemId &&
+ level === 0 &&
+ this.currentLevel > 0
+ ) {
+ path.attr("filter", "url(#glow)");
+ path.attr("stroke-width", "3");
+
+ const color = d.data.disabled
+ ? this.config.disabledColor
+ : d.data.color || "#333333";
+ path.attr("fill", color);
+ }
+ });
+
+ // Disable pointer events on previous menu levels
+ this.menuGroups.forEach((group, menuLevel) => {
+ if (menuLevel < this.currentLevel) {
+ group.selectAll("path").each(function () {
+ const pathElement = d3.select(this);
+ pathElement.style("pointer-events", "none");
+ });
+ } else if (menuLevel === this.currentLevel) {
+ group.selectAll("path").style("pointer-events", "auto");
+ }
+ });
+ }
+
+ private setupEventHandlers(
+ arcs: d3.Selection<
+ SVGGElement,
+ d3.PieArcDatum,
+ SVGGElement,
+ unknown
+ >,
+ level: number,
+ ) {
+ const onHover = (d: d3.PieArcDatum, path: any) => {
+ if (
+ d.data.disabled ||
+ (this.currentLevel > 0 && this.currentLevel !== level) ||
+ this.navigationInProgress
+ )
return;
+
+ path.attr("filter", "url(#glow)");
+ path.attr("stroke-width", "3");
+ const color = d.data.disabled
+ ? this.config.disabledColor
+ : d.data.color || "#333333";
+ path.attr("fill", color);
+
+ if (d.data.tooltipItems && d.data.tooltipItems.length > 0) {
+ this.showTooltip(d.data.tooltipItems);
+ }
+
+ if (
+ d.data.children &&
+ d.data.children.length > 0 &&
+ !d.data.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(d.data.children || []);
+ this.setCenterButtonAsBack();
+ }, 200);
+ }
+ };
+
+ const onMouseOut = (d: d3.PieArcDatum, path: any) => {
+ if (this.submenuHoverTimeout !== null) {
+ window.clearTimeout(this.submenuHoverTimeout);
+ this.submenuHoverTimeout = null;
+ }
+
+ this.hideTooltip();
+
+ if (
+ d.data.disabled ||
+ (this.currentLevel > 0 &&
+ level === 0 &&
+ d.data.id === this.selectedItemId)
+ )
+ return;
+ path.attr("filter", null);
+ path.attr("stroke-width", "2");
+ const color = d.data.disabled
+ ? this.config.disabledColor
+ : d.data.color || "#333333";
+ const opacity = d.data.disabled ? 0.5 : 0.7;
+ path.attr(
+ "fill",
+ d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
+ );
+ };
+
+ const onClick = (d: d3.PieArcDatum, event: Event) => {
+ event.stopPropagation();
+ if (d.data.disabled || this.navigationInProgress) return;
+
+ if (
+ this.currentLevel > 0 &&
+ level === 0 &&
+ d.data.id !== this.selectedItemId
+ )
+ return;
+
+ if (d.data.children && d.data.children.length > 0) {
+ this.navigationInProgress = true;
+ this.selectedItemId = d.data.id;
+ this.navigateToSubMenu(d.data.children || []);
+ this.setCenterButtonAsBack();
+ } else if (d.data._action) {
+ d.data._action();
+ this.hideRadialMenu();
+ } else {
+ throw new Error(`Menu item action is not a function: ${d.data.id}`);
+ }
+ };
+
+ function handleMouseMove(event: MouseEvent) {
+ const tooltipEl = document.querySelector(
+ ".radial-tooltip",
+ ) as HTMLElement;
+ if (tooltipEl && tooltipEl.style.display !== "none") {
+ tooltipEl.style.left = event.pageX + 10 + "px";
+ tooltipEl.style.top = event.pageY + 10 + "px";
}
}
- const actions = await myPlayer.actions(tile);
- this.disableAllButtons();
- this.handlePlayerActions(myPlayer, actions, tile);
+
+ arcs.each((d) => {
+ const pathId = d.data.id;
+ const path = d3.select(`path[data-id="${pathId}"]`);
+
+ path.on("mouseover", function () {
+ onHover(d, path);
+ });
+
+ path.on("mouseout", function () {
+ onMouseOut(d, path);
+ });
+
+ path.on("mousemove", function (event) {
+ handleMouseMove(event as MouseEvent);
+ });
+
+ path.on("click", function (event) {
+ onClick(d, event);
+ });
+
+ path.on("touchstart", function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ onClick(d, event);
+ });
+ });
+ }
+
+ private renderIconsAndText(
+ arcs: d3.Selection<
+ SVGGElement,
+ d3.PieArcDatum,
+ SVGGElement,
+ unknown
+ >,
+ arc: d3.Arc>,
+ ) {
+ arcs
+ .append("g")
+ .attr("class", "menu-item-content")
+ .style("pointer-events", "none")
+ .attr("data-id", (d) => d.data.id)
+ .each((d) => {
+ const contentId = d.data.id;
+ const content = d3.select(`g[data-id="${contentId}"]`);
+
+ if (d.data.text) {
+ content
+ .append("text")
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "central")
+ .attr("x", arc.centroid(d)[0])
+ .attr("y", arc.centroid(d)[1])
+ .attr("fill", "white")
+ .attr("font-size", d.data.fontSize ?? "12px")
+ .attr("font-family", "Arial, sans-serif")
+ .style("opacity", d.data.disabled ? 0.5 : 1)
+ .text(d.data.text);
+ } else {
+ content
+ .append("image")
+ .attr(
+ "xlink:href",
+ d.data.disabled ? disabledIcon : d.data.icon || disabledIcon,
+ )
+ .attr("width", this.config.iconSize)
+ .attr("height", this.config.iconSize)
+ .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2)
+ .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2);
+ }
+
+ this.menuIcons.set(contentId, content as any);
+ });
+ }
+
+ private setupAnimations(
+ menuGroup: d3.Selection,
+ ) {
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("opacity", 1)
+ .style("transform", "scale(1)")
+ .on("start", () => {
+ this.isTransitioning = true;
+ })
+ .on("end", () => {
+ this.isTransitioning = false;
+ });
+ }
+
+ private navigateToSubMenu(children: MenuElement[]) {
+ this.isTransitioning = true;
+
+ this.menuStack.push(this.currentMenuItems);
+ this.currentMenuItems = children;
+ this.currentLevel++;
+
+ this.renderMenuItems(this.currentMenuItems, this.currentLevel);
+ this.updateMenuGroupVisibility();
+ this.animatePreviousMenu();
+ }
+
+ private updateMenuGroupVisibility() {
+ // Hide all menus except the current and immediate previous one
+ this.menuGroups.forEach((menuGroup, level) => {
+ if (level === this.currentLevel) {
+ menuGroup.style("display", "block");
+ } else if (level === this.currentLevel - 1) {
+ menuGroup.style("display", "block");
+
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", "scale(0.59)")
+ .style("opacity", 0.8);
+
+ menuGroup.selectAll("path").each(function () {
+ const pathElement = d3.select(this);
+ pathElement.style("pointer-events", "none");
+ });
+ } else {
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.5)
+ .style("transform", "scale(0.5)")
+ .style("opacity", 0)
+ .on("end", function () {
+ d3.select(this).style("display", "none");
+ });
+ }
+ });
+ }
+
+ private animatePreviousMenu() {
+ const container = this.menuElement.select(".menu-container");
+ const currentMenu = container.select(
+ `.menu-level-${this.currentLevel - 1}`,
+ );
+
+ currentMenu
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`)
+ .style("opacity", 0.8)
+ .on("end", () => {
+ this.navigationInProgress = false;
+ });
+ }
+
+ private navigateBack() {
+ if (this.menuStack.length === 0) {
+ return;
+ }
+
+ this.isTransitioning = true;
+
+ this.updateMenuLevels();
+ this.clearSelectedItemHoverState();
+ this.updateMenuVisibility();
+ this.animateMenuTransitions();
+ }
+
+ private updateMenuLevels() {
+ const previousItems = this.menuStack.pop();
+ const previousLevel = this.currentLevel - 1;
+ this.currentLevel = previousLevel;
+
+ if (previousLevel === 0) {
+ this.selectedItemId = null;
+ }
+
+ this.currentMenuItems = previousItems || [];
+
+ if (this.currentLevel === 0) {
+ this.resetCenterButton();
+ }
+ }
+
+ private clearSelectedItemHoverState() {
+ // Clear the hover state on the item that opened the submenu
+ if (this.selectedItemId) {
+ const selectedPath = this.menuPaths.get(this.selectedItemId);
+ if (selectedPath) {
+ selectedPath.attr("filter", null);
+ selectedPath.attr("stroke-width", "2");
+
+ const item = this.findMenuItem(this.selectedItemId);
+ if (item) {
+ const color = item.disabled
+ ? this.config.disabledColor
+ : item.color || "#333333";
+ const opacity = item.disabled ? 0.5 : 0.7;
+ selectedPath.attr(
+ "fill",
+ d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
+ );
+ }
+ }
+ }
+ }
+
+ private updateMenuVisibility() {
+ this.menuGroups.forEach((menuGroup, level) => {
+ if (level === this.currentLevel) {
+ menuGroup.style("display", "block");
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", "scale(1)")
+ .style("opacity", 1);
+
+ menuGroup.selectAll("path").style("pointer-events", "auto");
+ } else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
+ menuGroup.style("display", "block");
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style(
+ "transform",
+ `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`,
+ )
+ .style("opacity", 0.8);
+ } else if (level !== this.currentLevel + 1) {
+ menuGroup
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.5)
+ .style("opacity", 0)
+ .on("end", function () {
+ d3.select(this).style("display", "none");
+ });
+ }
+ });
+ }
+
+ private animateMenuTransitions() {
+ const container = this.menuElement.select(".menu-container");
+ const currentSubmenu = container.select(
+ `.menu-level-${this.currentLevel + 1}`,
+ );
+ const previousMenu = container.select(`.menu-level-${this.currentLevel}`);
+
+ // Animate the current submenu (sliding out)
+ currentSubmenu
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", "scale(0.5)")
+ .style("opacity", 0)
+ .on("end", function () {
+ d3.select(this).remove();
+ });
+
+ // Handle previous menu animation
+ if (previousMenu.empty()) {
+ this.renderAndAnimateNewMenu();
+ } else {
+ this.animateExistingMenu(previousMenu);
+ }
+ }
+
+ private renderAndAnimateNewMenu() {
+ const menu = this.renderMenuItems(this.currentMenuItems, this.currentLevel);
+ menu
+ .style("transform", "scale(0.8)")
+ .style("opacity", 0.3)
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", "scale(1)")
+ .style("opacity", 1)
+ .on("end", () => {
+ this.isTransitioning = false;
+ this.navigationInProgress = false;
+ });
+ }
+
+ private animateExistingMenu(
+ previousMenu: d3.Selection,
+ ) {
+ previousMenu
+ .transition()
+ .duration(this.config.menuTransitionDuration * 0.8)
+ .style("transform", "scale(1)")
+ .style("opacity", 1)
+ .on("end", () => {
+ this.isTransitioning = false;
+ this.navigationInProgress = false;
+ });
+
+ previousMenu.selectAll("path").style("pointer-events", "auto");
+ }
+
+ private setCenterButtonAsBack() {
+ if (this.currentLevel === 1) {
+ this.originalCenterButtonEnabled = this.isCenterButtonEnabled;
+ this.originalCenterButtonAction = this.centerButtonAction;
+ }
+
+ this.backAction = () => {
+ this.navigateBack();
+ };
+
+ // Clear any hover state on the center button
+ this.menuElement
+ .select(".center-button-hitbox")
+ .transition()
+ .duration(0)
+ .attr("r", this.config.centerButtonSize);
+ this.menuElement
+ .select(".center-button-visible")
+ .transition()
+ .duration(0)
+ .attr("r", this.config.centerButtonSize);
+
+ const backIconImg = this.menuElement.select(".center-button-icon");
+ backIconImg
+ .attr("xlink:href", backIcon)
+ .attr("width", this.backIconSize)
+ .attr("height", this.backIconSize)
+ .attr("x", -this.backIconSize / 2)
+ .attr("y", -this.backIconSize / 2);
+
+ this.enableCenterButton(true, this.backAction);
+ }
+
+ private resetCenterButton() {
+ this.backAction = null;
+
+ const iconImg = this.menuElement.select(".center-button-icon");
+ iconImg
+ .attr("xlink:href", this.originalCenterButtonIcon)
+ .attr("width", this.config.centerIconSize)
+ .attr("height", this.config.centerIconSize)
+ .attr("x", -this.config.centerIconSize / 2)
+ .attr("y", -this.config.centerIconSize / 2);
+
+ this.enableCenterButton(
+ this.originalCenterButtonEnabled,
+ this.originalCenterButtonAction,
+ );
+ }
+
+ public showRadialMenu(x: number, y: number) {
+ if (!this.isReopeningAllowed()) return;
+
+ this.resetMenu();
+ this.isTransitioning = false;
+ this.selectedItemId = null;
+
+ this.menuElement.style("display", "block");
+
+ this.menuElement
+ .select("svg")
+ .style("top", `${y}px`)
+ .style("left", `${x}px`)
+ .style("transform", `translate(-50%, -50%)`);
+
+ this.isVisible = true;
+
+ this.renderMenuItems(this.currentMenuItems, this.currentLevel);
+ this.onCenterButtonHover(true);
+ }
+
+ public hideRadialMenu() {
+ if (!this.isVisible || this.isTransitioning) {
+ return;
+ }
+
+ this.menuElement.style("display", "none");
+ this.isVisible = false;
+ this.selectedItemId = null;
+ this.hideTooltip();
+
+ this.resetMenu();
+ this.isTransitioning = false;
+
+ this.menuGroups.clear();
+ this.menuPaths.clear();
+ this.menuIcons.clear();
+
+ this.lastHideTime = Date.now();
+ }
+
+ private handleCenterButtonClick() {
+ if (
+ !this.isCenterButtonEnabled ||
+ !this.centerButtonAction ||
+ this.navigationInProgress
+ ) {
+ return;
+ }
+
+ if (this.currentLevel > 0 && this.backAction) {
+ this.navigationInProgress = true;
+ }
+
+ this.centerButtonAction();
+ }
+
+ public disableAllButtons() {
+ this.originalCenterButtonEnabled = this.isCenterButtonEnabled;
+ this.originalCenterButtonAction = this.centerButtonAction;
+
+ this.enableCenterButton(false);
+
+ for (const item of this.currentMenuItems) {
+ item.disabled = true;
+ item.color = this.config.disabledColor;
+ }
+ }
+
+ public enableCenterButton(enabled: boolean, action?: (() => void) | null) {
+ if (this.currentLevel > 0 && this.backAction) {
+ this.isCenterButtonEnabled = true;
+
+ if (action !== undefined && action !== this.backAction) {
+ this.originalCenterButtonAction = action;
+ }
+
+ this.centerButtonAction = this.backAction;
+ } else {
+ this.isCenterButtonEnabled = enabled;
+ if (action !== undefined) {
+ this.centerButtonAction = action;
+ }
+ }
+
+ const centerButton = this.menuElement.select(".center-button");
+
+ centerButton
+ .select(".center-button-hitbox")
+ .style("cursor", this.isCenterButtonEnabled ? "pointer" : "not-allowed");
+
+ centerButton
+ .select(".center-button-visible")
+ .attr("fill", this.isCenterButtonEnabled ? "#2c3e50" : "#999999");
+
+ centerButton
+ .select(".center-button-icon")
+ .style("opacity", this.isCenterButtonEnabled ? 1 : 0.5);
+ }
+
+ private onCenterButtonHover(isHovering: boolean) {
+ if (!this.isCenterButtonEnabled) return;
+
+ const scale = isHovering ? 1.2 : 1;
+
+ this.menuElement
+ .select(".center-button-hitbox")
+ .transition()
+ .duration(200)
+ .attr("r", this.config.centerButtonSize * scale);
+
+ this.menuElement
+ .select(".center-button-visible")
+ .transition()
+ .duration(200)
+ .attr("r", this.config.centerButtonSize * scale);
+
+ if (this.currentLevel > 0 && this.backAction) {
+ if (isHovering) {
+ if (this.backButtonHoverTimeout !== null) {
+ window.clearTimeout(this.backButtonHoverTimeout);
+ }
+
+ this.backButtonHoverTimeout = window.setTimeout(() => {
+ if (this.navigationInProgress || !this.backAction) return;
+
+ this.navigationInProgress = true;
+ this.backAction();
+ }, 300);
+ } else {
+ if (this.backButtonHoverTimeout !== null) {
+ window.clearTimeout(this.backButtonHoverTimeout);
+ this.backButtonHoverTimeout = null;
+ }
+ }
+ }
+ }
+
+ public isMenuVisible(): boolean {
+ return this.isVisible;
+ }
+
+ public getCurrentLevel(): number {
+ return this.currentLevel;
+ }
+
+ public updateMenuItem(
+ id: string,
+ enabled: boolean,
+ color?: string,
+ icon?: string,
+ text?: string,
+ ) {
+ const path = this.menuPaths.get(id);
+ if (!path) return;
+
+ const item = this.findMenuItem(id);
+ if (item) {
+ item.disabled = !enabled;
+ if (color) item.color = enabled ? color : this.config.disabledColor;
+ if (icon) item.icon = icon;
+ if (text !== undefined) item.text = text;
+ }
+
+ const fillColor = enabled && color ? color : this.config.disabledColor;
+ const opacity = enabled ? 0.7 : 0.5;
+
+ const isSelected = id === this.selectedItemId && this.currentLevel > 0;
+ const finalOpacity = isSelected ? 1.0 : opacity;
+
+ path
+ .attr(
+ "fill",
+ d3.color(fillColor)?.copy({ opacity: finalOpacity })?.toString() ||
+ fillColor,
+ )
+ .style("opacity", enabled ? 1 : 0.5)
+ .style("cursor", enabled ? "pointer" : "not-allowed");
+
+ const iconElement = this.menuIcons.get(id);
+ if (iconElement) {
+ if (item?.text) {
+ const textElement = iconElement.select("text");
+ if (textElement.size() > 0) {
+ textElement
+ .style("opacity", enabled ? 1 : 0.5)
+ .text(text || item.text);
+ }
+ } else if (icon) {
+ const imageElement = iconElement.select("image");
+ if (imageElement.size() > 0) {
+ imageElement.attr("xlink:href", enabled ? icon : disabledIcon);
+ }
+ }
+ }
+ }
+
+ public setRootMenuItems(items: MenuElement[]) {
+ this.currentMenuItems = [...items];
+ this.rootMenuItems = [...items];
+ if (this.isVisible) {
+ this.refreshMenu();
+ }
+ }
+
+ private findMenuItem(id: string): MenuElement | undefined {
+ return this.currentMenuItems.find((item) => item.id === id);
+ }
+
+ private resetMenu() {
+ this.currentLevel = 0;
+ this.menuStack = [];
+
+ this.currentMenuItems = [...this.rootMenuItems];
+
+ this.backAction = null;
+ this.navigationInProgress = false;
+
+ this.menuGroups.clear();
+ this.menuPaths.clear();
+ this.menuIcons.clear();
+
+ const menuContainer = this.menuElement?.select(".menu-container");
+ if (menuContainer) {
+ menuContainer.selectAll("[class^='menu-level-']").remove();
+ }
+
+ this.resetCenterButton();
+
+ if (this.submenuHoverTimeout !== null) {
+ window.clearTimeout(this.submenuHoverTimeout);
+ this.submenuHoverTimeout = null;
+ }
+
+ if (this.backButtonHoverTimeout !== null) {
+ window.clearTimeout(this.backButtonHoverTimeout);
+ this.backButtonHoverTimeout = null;
+ }
+ }
+
+ public refreshMenu() {
+ if (!this.isVisible) return;
+ this.renderMenuItems(this.currentMenuItems, this.currentLevel);
}
renderLayer(context: CanvasRenderingContext2D) {
@@ -312,241 +1051,30 @@ export class RadialMenu implements Layer {
return false;
}
- private onContextMenu(event: ContextMenuEvent) {
- if (this.lastClosed + 200 > new Date().getTime()) return;
- if (this.buildMenu.isVisible) {
- this.buildMenu.hideMenu();
- return;
- }
- if (this.isVisible) {
- this.hideRadialMenu();
- return;
- } else {
- this.showRadialMenu(event.x, event.y);
- }
- this.disableAllButtons();
- this.clickedCell = this.transformHandler.screenToWorldCoordinates(
- event.x,
- event.y,
- );
- if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) {
- return;
- }
- const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
- this.originalTileOwner = this.g.owner(tile);
- if (this.g.inSpawnPhase()) {
- if (this.g.isLand(tile) && !this.g.hasOwner(tile)) {
- this.enableCenterButton(true);
- }
- return;
- }
-
- const myPlayer = this.g.myPlayer();
- if (myPlayer === null) {
- consolex.warn("my player not found");
- return;
- }
- if (myPlayer && !myPlayer.isAlive() && !this.g.inSpawnPhase()) {
- return this.hideRadialMenu();
- }
- myPlayer.actions(tile).then((actions) => {
- this.handlePlayerActions(myPlayer, actions, tile);
- });
+ private isReopeningAllowed(): boolean {
+ const now = Date.now();
+ const timeSinceHide = now - this.lastHideTime;
+ return timeSinceHide >= this.reopenCooldownMs;
}
- private handlePlayerActions(
- myPlayer: PlayerView,
- actions: PlayerActions,
- tile: TileRef,
- ) {
- if (!this.g.inSpawnPhase()) {
- this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
- this.buildMenu.showMenu(tile);
- });
+ private showTooltip(items: TooltipItem[]) {
+ if (!this.tooltipElement) return;
+
+ this.tooltipElement.innerHTML = "";
+
+ for (const item of items) {
+ const div = document.createElement("div");
+ div.className = item.className;
+ div.textContent = item.text;
+ this.tooltipElement.appendChild(div);
}
- if (this.g.hasOwner(tile)) {
- this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => {
- this.playerPanel.show(actions, tile);
- });
- }
-
- if (actions?.interaction?.canSendAllianceRequest) {
- this.activateMenuElement(Slot.Ally, "#53ac75", allianceIcon, () => {
- this.eventBus.emit(
- new SendAllianceRequestIntentEvent(
- myPlayer,
- this.g.owner(tile) as PlayerView,
- ),
- );
- });
- }
- if (actions?.interaction?.canBreakAlliance) {
- this.activateMenuElement(Slot.Ally, "#c74848", traitorIcon, () => {
- this.eventBus.emit(
- new SendBreakAllianceIntentEvent(
- myPlayer,
- this.g.owner(tile) as PlayerView,
- ),
- );
- });
- }
- if (
- actions.buildableUnits.find((bu) => bu.type === UnitType.TransportShip)
- ?.canBuild
- ) {
- this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
- // BestTransportShipSpawn is an expensive operation, so
- // we calculate it here and send the spawn tile to other clients.
- myPlayer.bestTransportShipSpawn(tile).then((spawn) => {
- let spawnTile: Cell | null = null;
- if (spawn !== false) {
- spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn));
- }
-
- if (this.clickedCell === null) return;
- this.eventBus.emit(
- new SendBoatAttackIntentEvent(
- this.g.owner(tile).id(),
- this.clickedCell,
- this.uiState.attackRatio * myPlayer.troops(),
- spawnTile,
- ),
- );
- });
- });
- }
- if (actions.canAttack) {
- this.enableCenterButton(true);
- }
-
- if (!this.g.hasOwner(tile)) {
- return;
- }
+ this.tooltipElement.style.display = "block";
}
- private onPointerUp(event: MouseUpEvent) {
- this.hideRadialMenu();
- this.emojiTable.hideTable();
- this.buildMenu.hideMenu();
- this.playerInfoOverlay.hide();
- }
-
- private showRadialMenu(x: number, y: number) {
- // Delay so center button isn't clicked immediately on press.
- setTimeout(() => {
- this.menuElement
- .style("left", `${x - this.menuSize / 2}px`)
- .style("top", `${y - this.menuSize / 2}px`)
- .style("display", "block");
- this.playerInfoOverlay.maybeShow(x, y);
- this.isVisible = true;
- }, 50);
- }
-
- private hideRadialMenu() {
- this.menuElement.style("display", "none");
- this.isVisible = false;
- this.playerInfoOverlay.hide();
- this.lastClosed = new Date().getTime();
- }
-
- private handleCenterButtonClick() {
- if (!this.isCenterButtonEnabled) {
- return;
+ private hideTooltip() {
+ if (this.tooltipElement) {
+ this.tooltipElement.style.display = "none";
}
- consolex.log("Center button clicked");
- if (this.clickedCell === null) return;
- const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y);
- if (this.g.inSpawnPhase()) {
- this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell));
- } else {
- const myPlayer = this.g.myPlayer();
- if (myPlayer !== null && this.g.owner(clicked) !== myPlayer) {
- this.eventBus.emit(
- new SendAttackIntentEvent(
- this.g.owner(clicked).id(),
- this.uiState.attackRatio * myPlayer.troops(),
- ),
- );
- }
- }
- this.hideRadialMenu();
- }
-
- private disableAllButtons() {
- this.enableCenterButton(false);
- for (const item of this.menuItems.values()) {
- item.disabled = true;
- this.updateMenuItemState(item);
- }
- }
-
- private activateMenuElement(
- slot: Slot,
- color: string,
- icon: string,
- action: () => void,
- ) {
- const menuItem = this.menuItems.get(slot);
- if (menuItem === undefined) return;
- menuItem.action = action;
- menuItem.disabled = false;
- menuItem.color = color;
- menuItem.icon = icon;
- this.updateMenuItemState(menuItem);
- }
-
- private updateMenuItemState(item: any) {
- const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`);
- menuItem
- .attr("fill", item.disabled ? this.disabledColor : item.color)
- .style("cursor", item.disabled ? "not-allowed" : "pointer")
- .style("opacity", item.disabled ? 0.5 : 1);
-
- this.menuElement
- .select(`image[data-name="${item.name}"]`)
- .attr("xlink:href", item.disabled ? disabledIcon : item.icon)
- .attr("fill", item.disabled ? "#999999" : "white");
- }
-
- private onCenterButtonHover(isHovering: boolean) {
- if (!this.isCenterButtonEnabled) return;
-
- const scale = isHovering ? 1.2 : 1;
- const fontSize = isHovering ? "18px" : "16px";
-
- this.menuElement
- .select(".center-button-hitbox")
- .transition()
- .duration(200)
- .attr("r", this.centerButtonSize * scale);
- this.menuElement
- .select(".center-button-visible")
- .transition()
- .duration(200)
- .attr("r", this.centerButtonSize * scale);
- this.menuElement
- .select(".center-button-text")
- .transition()
- .duration(200)
- .style("font-size", fontSize);
- }
-
- private enableCenterButton(enabled: boolean) {
- this.isCenterButtonEnabled = enabled;
- const centerButton = this.menuElement.select(".center-button");
-
- centerButton
- .select(".center-button-hitbox")
- .style("cursor", enabled ? "pointer" : "not-allowed");
-
- centerButton
- .select(".center-button-visible")
- .attr("fill", enabled ? "#2c3e50" : "#999999");
-
- centerButton
- .select(".center-button-text")
- .attr("fill", enabled ? "white" : "#cccccc");
}
}
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
new file mode 100644
index 000000000..1b5ed0fa0
--- /dev/null
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -0,0 +1,471 @@
+import {
+ AllPlayers,
+ Cell,
+ PlayerActions,
+ TerraNullius,
+ UnitType,
+} from "../../../core/game/Game";
+import { TileRef } from "../../../core/game/GameMap";
+import { GameView, PlayerView } from "../../../core/game/GameView";
+import { flattenedEmojiTable } from "../../../core/Util";
+import { renderNumber, translateText } from "../../Utils";
+import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
+import { ChatIntegration } from "./ChatIntegration";
+import { EmojiTable } from "./EmojiTable";
+import { PlayerActionHandler } from "./PlayerActionHandler";
+import { PlayerPanel } from "./PlayerPanel";
+import { TooltipItem } from "./RadialMenu";
+
+import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
+import boatIcon from "../../../../resources/images/BoatIconWhite.svg";
+import buildIcon from "../../../../resources/images/BuildIconWhite.svg";
+import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
+import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
+import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
+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";
+
+export interface MenuElementParams {
+ myPlayer: PlayerView;
+ selected: PlayerView | null;
+ tileOwner: PlayerView | TerraNullius;
+ tile: TileRef;
+ playerActions: PlayerActions;
+ game: GameView;
+ buildMenu: BuildMenu;
+ emojiTable: EmojiTable;
+ playerActionHandler: PlayerActionHandler;
+ playerPanel: PlayerPanel;
+ chatIntegration: ChatIntegration;
+ closeMenu: () => void;
+}
+
+export interface MenuElement {
+ id: string;
+ name: string;
+ disabled: boolean;
+ displayed?: boolean;
+ color?: string;
+ icon?: string;
+ text?: string;
+ fontSize?: string;
+ tooltipItems?: TooltipItem[];
+
+ action?: (params: MenuElementParams) => void; // For leaf items that perform actions
+ subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
+
+ // Runtime properties used by RadialMenu (not to be set by menu element creators)
+ children?: MenuElement[];
+ _action?: () => void;
+}
+
+export const COLORS = {
+ build: "#ebe250",
+ building: "#2c2c2c",
+ boat: "#3f6ab1",
+ ally: "#53ac75",
+ breakAlly: "#c74848",
+ info: "#64748B",
+ target: "#ff0000",
+ infoDetails: "#7f8c8d",
+ infoEmoji: "#f1c40f",
+ trade: "#008080",
+ embargo: "#6600cc",
+ tooltip: {
+ cost: "#ffd700",
+ count: "#aaa",
+ },
+ chat: {
+ default: "#66c",
+ help: "#4caf50",
+ attack: "#f44336",
+ defend: "#2196f3",
+ greet: "#ff9800",
+ misc: "#9c27b0",
+ warnings: "#e3c532",
+ },
+};
+
+export enum Slot {
+ Info = "info",
+ Boat = "boat",
+ Build = "build",
+ Ally = "ally",
+ Back = "back",
+}
+
+/**
+ * Convert a MenuElement tree to a version usable by the RadialMenu
+ * by resolving subMenu functions and setting up actions
+ */
+export function prepareMenuElementsForRadialMenu(
+ elements: MenuElement[],
+ params: MenuElementParams,
+): MenuElement[] {
+ return elements.map((element) => {
+ const prepared: MenuElement = { ...element };
+
+ // If the element has a subMenu function, execute it to get the children
+ if (element.subMenu) {
+ prepared.children = prepareMenuElementsForRadialMenu(
+ element.subMenu(params),
+ params,
+ );
+ // We don't need the subMenu function anymore
+ prepared.subMenu = undefined;
+ }
+
+ // Set up the action function to call the element's action with params
+ if (element.action) {
+ prepared._action = () => element.action!(params);
+ } else {
+ prepared._action = () => {};
+ }
+
+ return prepared;
+ });
+}
+
+export const buildMenuElement: MenuElement = {
+ id: Slot.Build,
+ name: "build",
+ disabled: false,
+ icon: buildIcon,
+ color: COLORS.build,
+
+ subMenu: (params: MenuElementParams) => {
+ const buildElements: MenuElement[] = flattenedBuildTable.map(
+ (item: BuildItemDisplay) => ({
+ id: `build_${item.unitType}`,
+ name: item.key
+ ? item.key.replace("unit_type.", "")
+ : item.unitType.toString(),
+ disabled: !params.buildMenu.canBuild(item),
+ color: params.buildMenu.canBuild(item) ? COLORS.building : undefined,
+ icon: item.icon,
+ tooltipItems: [
+ { text: translateText(item.key || ""), className: "title" },
+ {
+ text: translateText(item.description || ""),
+ className: "description",
+ },
+ {
+ text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
+ className: "cost",
+ },
+ item.countable
+ ? { text: `${params.buildMenu.count(item)}x`, className: "count" }
+ : 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),
+ );
+ params.closeMenu();
+ },
+ }),
+ );
+
+ buildElements.push({
+ id: "build_menu",
+ name: "build",
+ disabled: false,
+ color: COLORS.build,
+ icon: buildIcon,
+ action: (params: MenuElementParams) => {
+ params.buildMenu.showMenu(params.tile);
+ },
+ });
+
+ return buildElements;
+ },
+};
+
+export const boatMenuElement: MenuElement = {
+ id: Slot.Boat,
+ name: "boat",
+ disabled: false,
+ icon: boatIcon,
+ color: COLORS.boat,
+
+ action: async (params: MenuElementParams) => {
+ if (!params.selected) return;
+
+ const spawn = await params.playerActionHandler.findBestTransportShipSpawn(
+ params.myPlayer,
+ params.tile,
+ );
+
+ let spawnTile: Cell | null = null;
+ if (spawn !== false) {
+ spawnTile = new Cell(params.game.x(spawn), params.game.y(spawn));
+ }
+
+ params.playerActionHandler.handleBoatAttack(
+ params.myPlayer,
+ params.selected.id(),
+ new Cell(params.game.x(params.tile), params.game.y(params.tile)),
+ spawnTile,
+ );
+
+ params.closeMenu();
+ },
+};
+
+export const infoMenuElement: MenuElement = {
+ id: Slot.Info,
+ name: "info",
+ disabled: false,
+ icon: infoIcon,
+ color: COLORS.info,
+
+ subMenu: (params: MenuElementParams) => {
+ if (!params.selected) return [];
+
+ return [
+ {
+ id: "info_chat",
+ name: "chat",
+ disabled: false,
+ color: COLORS.chat.default,
+ icon: chatIcon,
+ subMenu: (params: MenuElementParams) =>
+ params.chatIntegration
+ .createQuickChatMenu(params.selected!)
+ .map((item) => ({
+ ...item,
+ action: item.action
+ ? (_params: MenuElementParams) => item.action!(params)
+ : undefined,
+ })),
+ },
+ {
+ id: "ally_target",
+ name: "target",
+ disabled: false,
+ color: COLORS.target,
+ icon: targetIcon,
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleTargetPlayer(params.selected!.id());
+ params.closeMenu();
+ },
+ },
+ {
+ id: "ally_trade",
+ name: "trade",
+ disabled: !!params.playerActions?.interaction?.canEmbargo,
+ displayed: !params.playerActions?.interaction?.canEmbargo,
+ color: COLORS.trade,
+ text: translateText("player_panel.start_trade"),
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleEmbargo(params.selected!, "start");
+ params.closeMenu();
+ },
+ },
+ {
+ id: "ally_embargo",
+ name: "embargo",
+ disabled: !params.playerActions?.interaction?.canEmbargo,
+ displayed: !!params.playerActions?.interaction?.canEmbargo,
+ color: COLORS.embargo,
+ text: translateText("player_panel.stop_trade"),
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleEmbargo(params.selected!, "stop");
+ params.closeMenu();
+ },
+ },
+ {
+ id: "ally_request",
+ name: "request",
+ disabled: !params.playerActions?.interaction?.canSendAllianceRequest,
+ displayed: !params.playerActions?.interaction?.canBreakAlliance,
+ color: COLORS.ally,
+ icon: allianceIcon,
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleAllianceRequest(
+ params.myPlayer,
+ params.selected!,
+ );
+ params.closeMenu();
+ },
+ },
+ {
+ id: "ally_break",
+ name: "break",
+ disabled: !params.playerActions?.interaction?.canBreakAlliance,
+ displayed: !!params.playerActions?.interaction?.canBreakAlliance,
+ color: COLORS.breakAlly,
+ icon: traitorIcon,
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleBreakAlliance(
+ params.myPlayer,
+ params.selected!,
+ );
+ params.closeMenu();
+ },
+ },
+
+ {
+ id: "ally_donate_gold",
+ name: "donate gold",
+ disabled: !params.playerActions?.interaction?.canDonate,
+ color: COLORS.ally,
+ icon: donateGoldIcon,
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleDonateGold(params.selected!);
+ params.closeMenu();
+ },
+ },
+ {
+ id: "ally_donate_troops",
+ name: "donate troops",
+ disabled: !params.playerActions?.interaction?.canDonate,
+ color: COLORS.ally,
+ icon: donateTroopIcon,
+ action: (params: MenuElementParams) => {
+ params.playerActionHandler.handleDonateTroops(params.selected!);
+ params.closeMenu();
+ },
+ },
+ {
+ id: "info_player",
+ name: "player",
+ disabled: false,
+ color: COLORS.info,
+ icon: infoIcon,
+ action: (params: MenuElementParams) => {
+ params.playerPanel.show(params.playerActions, params.tile);
+ },
+ },
+ {
+ id: "info_emoji",
+ name: "emoji",
+ disabled: false,
+ color: COLORS.infoEmoji,
+ icon: emojiIcon,
+ subMenu: () => {
+ const emojiElements: MenuElement[] = [];
+
+ const emojiCount = 15;
+ for (let i = 0; i < emojiCount; i++) {
+ emojiElements.push({
+ id: `emoji_${i}`,
+ name: flattenedEmojiTable[i],
+ text: flattenedEmojiTable[i],
+ disabled: false,
+ fontSize: "25px",
+ action: (params: MenuElementParams) => {
+ const targetPlayer =
+ params.selected === params.game.myPlayer()
+ ? AllPlayers
+ : params.selected;
+ params.playerActionHandler.handleEmoji(targetPlayer!, i);
+ params.closeMenu();
+ },
+ });
+ }
+
+ emojiElements.push({
+ id: "emoji_more",
+ name: "more",
+ disabled: false,
+ color: COLORS.infoEmoji,
+ icon: emojiIcon,
+ action: (params: MenuElementParams) => {
+ params.emojiTable.showTable((emoji) => {
+ const targetPlayer =
+ params.selected === params.game.myPlayer()
+ ? AllPlayers
+ : params.selected;
+ params.playerActionHandler.handleEmoji(
+ targetPlayer!,
+ flattenedEmojiTable.indexOf(emoji),
+ );
+ params.emojiTable.hideTable();
+ });
+ },
+ });
+
+ return emojiElements;
+ },
+ },
+ ].filter((item) => item.displayed !== false);
+ },
+};
+
+export function createMenuItems(params: MenuElementParams): MenuElement[] {
+ const canBuildTransport = params.playerActions.buildableUnits.find(
+ (bu) => bu.type === UnitType.TransportShip,
+ )?.canBuild;
+
+ return [
+ {
+ ...boatMenuElement,
+ disabled: !canBuildTransport || !params.selected,
+ },
+ {
+ ...buildMenuElement,
+ disabled: params.game.inSpawnPhase(),
+ },
+ {
+ ...infoMenuElement,
+ disabled: !params.game.hasOwner(params.tile),
+ },
+ ];
+}
+
+export function createRadialMenuItems(
+ params: MenuElementParams,
+): MenuElement[] {
+ const elements = createMenuItems(params);
+ return prepareMenuElementsForRadialMenu(elements, params);
+}
+
+export function getRootMenuItems(): MenuElement[] {
+ return [
+ {
+ id: Slot.Boat,
+ name: "boat",
+ disabled: true,
+ _action: () => {},
+ icon: boatIcon,
+ },
+ {
+ id: Slot.Build,
+ name: "build",
+ disabled: true,
+ _action: () => {},
+ icon: buildIcon,
+ },
+ {
+ id: Slot.Info,
+ name: "info",
+ disabled: true,
+ _action: () => {},
+ icon: infoIcon,
+ },
+ ];
+}
+
+export function updateCenterButton(
+ params: MenuElementParams,
+ enableCenterButton: (enabled: boolean, action?: (() => void) | null) => void,
+) {
+ if (params.playerActions.canAttack) {
+ enableCenterButton(true, () => {
+ if (params.tileOwner !== params.myPlayer) {
+ params.playerActionHandler.handleAttack(
+ params.myPlayer,
+ params.tileOwner.id(),
+ );
+ }
+ params.closeMenu();
+ });
+ } else {
+ enableCenterButton(false);
+ }
+}
diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts
index b671becb9..dcbbd10b0 100644
--- a/src/client/graphics/layers/TeamStats.ts
+++ b/src/client/graphics/layers/TeamStats.ts
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
-import { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
@@ -18,7 +17,6 @@ interface TeamEntry {
@customElement("team-stats")
export class TeamStats extends LitElement implements Layer {
public game: GameView;
- public clientID: ClientID;
public eventBus: EventBus;
teams: TeamEntry[] = [];
@@ -60,7 +58,7 @@ export class TeamStats extends LitElement implements Layer {
this.teams = Object.entries(grouped)
.map(([teamStr, teamPlayers]) => {
- let totalGold = 0;
+ let totalGold = 0n;
let totalTroops = 0;
let totalScoreSort = 0;
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index deb2cfd0a..1c2dc1bf1 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -8,6 +8,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { AlternateViewEvent, DragEvent } from "../../InputHandler";
+import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class TerritoryLayer implements Layer {
@@ -32,7 +33,7 @@ export class TerritoryLayer implements Layer {
private lastDragTime = 0;
private nodrawDragDuration = 200;
- private refreshRate = 10;
+ private refreshRate = 10; //refresh every 10ms
private lastRefresh = 0;
private lastFocusedPlayer: PlayerView | null = null;
@@ -40,6 +41,7 @@ export class TerritoryLayer implements Layer {
constructor(
private game: GameView,
private eventBus: EventBus,
+ private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
}
@@ -48,11 +50,10 @@ export class TerritoryLayer implements Layer {
return true;
}
- paintPlayerBorder(player: PlayerView) {
- player.borderTiles().then((playerBorderTiles) => {
- playerBorderTiles.borderTiles.forEach((tile: TileRef) => {
- this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
- });
+ async paintPlayerBorder(player: PlayerView) {
+ const tiles = await player.borderTiles();
+ tiles.borderTiles.forEach((tile: TileRef) => {
+ this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing
});
}
@@ -128,11 +129,7 @@ export class TerritoryLayer implements Layer {
euclDistFN(centerTile, 9, true),
)) {
if (!this.game.hasOwner(tile)) {
- this.paintHighlightCell(
- new Cell(this.game.x(tile), this.game.y(tile)),
- color,
- 255,
- );
+ this.paintHighlightTile(tile, color, 255);
}
}
}
@@ -155,16 +152,16 @@ export class TerritoryLayer implements Layer {
const context = this.canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
this.context = context;
+ this.canvas.width = this.game.width();
+ this.canvas.height = this.game.height();
this.imageData = this.context.getImageData(
0,
0,
- this.game.width(),
- this.game.height(),
+ this.canvas.width,
+ this.canvas.height,
);
this.initImageData();
- this.canvas.width = this.game.width();
- this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
// Add a second canvas for highlights
@@ -199,7 +196,19 @@ export class TerritoryLayer implements Layer {
) {
this.lastRefresh = now;
this.renderTerritory();
- this.context.putImageData(this.imageData, 0, 0);
+
+ const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
+ const vx0 = Math.max(0, topLeft.x);
+ const vy0 = Math.max(0, topLeft.y);
+ const vx1 = Math.min(this.game.width() - 1, bottomRight.x);
+ const vy1 = Math.min(this.game.height() - 1, bottomRight.y);
+
+ const w = vx1 - vx0 + 1;
+ const h = vy1 - vy0 + 1;
+
+ if (w > 0 && h > 0) {
+ this.context.putImageData(this.imageData, 0, 0, vx0, vy0, w, h);
+ }
}
if (this.alternativeView) {
return;
@@ -231,7 +240,13 @@ export class TerritoryLayer implements Layer {
while (numToRender > 0) {
numToRender--;
- const tile = this.tileToRenderQueue.pop().tile;
+
+ const entry = this.tileToRenderQueue.pop();
+ if (!entry) {
+ break;
+ }
+
+ const tile = entry.tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
@@ -245,15 +260,10 @@ export class TerritoryLayer implements Layer {
}
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
- this.paintCell(
- this.game.x(tile),
- this.game.y(tile),
- this.theme.falloutColor(),
- 150,
- );
+ this.paintTile(tile, this.theme.falloutColor(), 150);
return;
}
- this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
+ this.clearTile(tile);
return;
}
const owner = this.game.owner(tile) as PlayerView;
@@ -273,40 +283,28 @@ export class TerritoryLayer implements Layer {
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
const borderColor = lightTile ? borderColors.light : borderColors.dark;
- this.paintCell(x, y, borderColor, 255);
+ this.paintTile(tile, borderColor, 255);
} else {
const useBorderColor = playerIsFocused
? this.theme.focusedBorderColor()
: this.theme.borderColor(owner);
- this.paintCell(
- this.game.x(tile),
- this.game.y(tile),
- useBorderColor,
- 255,
- );
+ this.paintTile(tile, useBorderColor, 255);
}
} else {
- this.paintCell(
- this.game.x(tile),
- this.game.y(tile),
- this.theme.territoryColor(owner),
- 150,
- );
+ this.paintTile(tile, this.theme.territoryColor(owner), 150);
}
}
- paintCell(x: number, y: number, color: Colord, alpha: number) {
- const index = y * this.game.width() + x;
- const offset = index * 4;
+ paintTile(tile: TileRef, color: Colord, alpha: number) {
+ const offset = tile * 4;
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = alpha;
}
- clearCell(cell: Cell) {
- const index = cell.y * this.game.width() + cell.x;
- const offset = index * 4;
+ clearTile(tile: TileRef) {
+ const offset = tile * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
@@ -324,13 +322,17 @@ export class TerritoryLayer implements Layer {
});
}
- paintHighlightCell(cell: Cell, color: Colord, alpha: number) {
- this.clearCell(cell);
+ paintHighlightTile(tile: TileRef, color: Colord, alpha: number) {
+ this.clearTile(tile);
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
- this.highlightContext.fillRect(cell.x, cell.y, 1, 1);
+ this.highlightContext.fillRect(x, y, 1, 1);
}
- clearHighlightCell(cell: Cell) {
- this.highlightContext.clearRect(cell.x, cell.y, 1, 1);
+ clearHighlightTile(tile: TileRef) {
+ const x = this.game.x(tile);
+ const y = this.game.y(tile);
+ this.highlightContext.clearRect(x, y, 1, 1);
}
}
diff --git a/src/client/graphics/layers/TopBar.ts b/src/client/graphics/layers/TopBar.ts
index 76e218ba5..991fda739 100644
--- a/src/client/graphics/layers/TopBar.ts
+++ b/src/client/graphics/layers/TopBar.ts
@@ -50,7 +50,7 @@ export class TopBar extends LitElement implements Layer {
const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10;
const maxPop = this.game.config().maxPopulation(myPlayer);
- const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10;
+ const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n;
return html`
();
private transformHandler: TransformHandler;
@@ -55,7 +52,6 @@ export class UnitLayer implements Layer {
constructor(
private game: GameView,
private eventBus: EventBus,
- private clientID: ClientID,
transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
@@ -67,11 +63,11 @@ export class UnitLayer implements Layer {
}
tick() {
- if (this.myPlayer === null) {
- this.myPlayer = this.game.playerByClientID(this.clientID);
- }
+ const unitIds = this.game
+ .updatesSinceLastTick()
+ ?.[GameUpdateType.Unit]?.map((unit) => unit.id);
- this.updateUnitsSprites();
+ this.updateUnitsSprites(unitIds ?? []);
}
init() {
@@ -95,18 +91,13 @@ export class UnitLayer implements Layer {
}
const clickRef = this.game.ref(cell.x, cell.y);
- // Make sure we have the current player
- if (this.myPlayer === null) {
- this.myPlayer = this.game.playerByClientID(this.clientID);
- }
-
// Only select warships owned by the player
return this.game
.units(UnitType.Warship)
.filter(
(unit) =>
unit.isActive() &&
- unit.owner() === this.myPlayer && // Only allow selecting own warships
+ unit.owner() === this.game.myPlayer() && // Only allow selecting own warships
this.game.manhattanDist(unit.tile(), clickRef) <=
this.WARSHIP_SELECTION_RADIUS,
)
@@ -202,7 +193,7 @@ export class UnitLayer implements Layer {
this.transportShipTrailCanvas.width = this.game.width();
this.transportShipTrailCanvas.height = this.game.height();
- this.updateUnitsSprites();
+ this.updateUnitsSprites(this.game.units().map((unit) => unit.id()));
this.unitToTrail.forEach((trail, unit) => {
for (const t of trail) {
@@ -218,10 +209,9 @@ export class UnitLayer implements Layer {
});
}
- private updateUnitsSprites() {
- const unitsToUpdate = this.game
- .updatesSinceLastTick()
- ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
+ private updateUnitsSprites(unitIds: number[]) {
+ const unitsToUpdate = unitIds
+ ?.map((id) => this.game.unit(id))
.filter((unit) => unit !== undefined);
if (unitsToUpdate) {
@@ -254,13 +244,14 @@ export class UnitLayer implements Layer {
}
private relationship(unit: UnitView): Relationship {
- if (this.myPlayer === null) {
+ const myPlayer = this.game.myPlayer();
+ if (myPlayer === null) {
return Relationship.Enemy;
}
- if (this.myPlayer === unit.owner()) {
+ if (myPlayer === unit.owner()) {
return Relationship.Self;
}
- if (this.myPlayer.isFriendly(unit.owner())) {
+ if (myPlayer.isFriendly(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts
index ce0739e50..ce705513d 100644
--- a/src/client/graphics/layers/WinModal.ts
+++ b/src/client/graphics/layers/WinModal.ts
@@ -148,7 +148,24 @@ export class WinModal extends LitElement implements Layer {
}
innerHtml() {
- return html``;
+ return html`
+
+ ${translateText("win_modal.wishlist")}
+
+
`;
}
show() {
diff --git a/src/client/index.html b/src/client/index.html
index 3e15ba515..ae94e0ae7 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -99,6 +99,11 @@
display: none;
}
}
+
+ /* display:none if child has class parent-hidden since we can't use shadow DOM in Lit due to Tailwind */
+ .component-hideable:has(> .parent-hidden) {
+ display: none;
+ }
@@ -224,7 +229,9 @@
-
+
+
+
+
@@ -368,6 +380,16 @@
Terms of Service
+
+ Advertise
+
diff --git a/src/client/jwt.ts b/src/client/jwt.ts
index 99c337f5a..9d76ff857 100644
--- a/src/client/jwt.ts
+++ b/src/client/jwt.ts
@@ -1,4 +1,5 @@
import { decodeJwt } from "jose";
+import { z } from "zod/v4";
import {
RefreshResponseSchema,
TokenPayload,
@@ -49,7 +50,7 @@ export async function logOut(allSessions: boolean = false) {
__isLoggedIn = false;
const response = await fetch(
- getApiBase() + allSessions ? "/revoke" : "/logout",
+ getApiBase() + (allSessions ? "/revoke" : "/logout"),
{
method: "POST",
headers: {
@@ -138,12 +139,9 @@ function _isLoggedIn(): IsLoggedInResponse {
const result = TokenPayloadSchema.safeParse(payload);
if (!result.success) {
+ const error = z.prettifyError(result.error);
// Invalid response
- console.error(
- "Invalid payload",
- // JSON.stringify(payload),
- JSON.stringify(result.error),
- );
+ console.error("Invalid payload", error);
return false;
}
@@ -171,11 +169,8 @@ export async function postRefresh(): Promise {
const body = await response.json();
const result = RefreshResponseSchema.safeParse(body);
if (!result.success) {
- console.error(
- "Invalid response",
- JSON.stringify(body),
- JSON.stringify(result.error),
- );
+ const error = z.prettifyError(result.error);
+ console.error("Invalid response", error);
return false;
}
localStorage.setItem("token", result.data.token);
@@ -201,11 +196,8 @@ export async function getUserMe(): Promise {
const body = await response.json();
const result = UserMeResponseSchema.safeParse(body);
if (!result.success) {
- console.error(
- "Invalid response",
- JSON.stringify(body),
- JSON.stringify(result.error),
- );
+ const error = z.prettifyError(result.error);
+ console.error("Invalid response", error);
return false;
}
return result.data;
diff --git a/src/client/utilities/Maps.ts b/src/client/utilities/Maps.ts
index f583594e6..97aaac9f8 100644
--- a/src/client/utilities/Maps.ts
+++ b/src/client/utilities/Maps.ts
@@ -6,6 +6,7 @@ import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
import britannia from "../../../resources/maps/BritanniaThumb.webp";
import deglaciatedAntarctica from "../../../resources/maps/DeglaciatedAntarcticaThumb.webp";
+import eastasia from "../../../resources/maps/EastAsiaThumb.webp";
import europeClassic from "../../../resources/maps/EuropeClassicThumb.webp";
import europe from "../../../resources/maps/EuropeThumb.webp";
import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
@@ -13,7 +14,6 @@ import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
-import japan from "../../../resources/maps/JapanThumb.webp";
import mars from "../../../resources/maps/MarsThumb.webp";
import mena from "../../../resources/maps/MenaThumb.webp";
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
@@ -61,8 +61,8 @@ export function getMapsImage(map: GameMapType): string {
return australia;
case GameMapType.Iceland:
return iceland;
- case GameMapType.Japan:
- return japan;
+ case GameMapType.EastAsia:
+ return eastasia;
case GameMapType.BetweenTwoSeas:
return betweenTwoSeas;
case GameMapType.FaroeIslands:
diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts
new file mode 100644
index 000000000..c74aaf7ef
--- /dev/null
+++ b/src/client/utilities/RenderUnitTypeOptions.ts
@@ -0,0 +1,48 @@
+// renderUnitTypeOptions.ts
+import { html, TemplateResult } from "lit";
+import { UnitType } from "../../core/game/Game";
+import { translateText } from "../Utils";
+
+export interface UnitTypeRenderContext {
+ disabledUnits: UnitType[];
+ toggleUnit: (unit: UnitType, checked: boolean) => void;
+}
+
+const unitOptions: { type: UnitType; translationKey: string }[] = [
+ { type: UnitType.City, translationKey: "unit_type.city" },
+ { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
+ { type: UnitType.Port, translationKey: "unit_type.port" },
+ { type: UnitType.Warship, translationKey: "unit_type.warship" },
+ { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
+ { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
+ { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
+ { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
+ { type: UnitType.MIRV, translationKey: "unit_type.mirv" },
+];
+
+export function renderUnitTypeOptions({
+ disabledUnits,
+ toggleUnit,
+}: UnitTypeRenderContext): TemplateResult[] {
+ return unitOptions.map(
+ ({ type, translationKey }) => html`
+
+ `,
+ );
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 1c594e1a7..aec1ae506 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -28,25 +28,21 @@ export const TokenPayloadSchema = z.object({
iss: z.string(),
aud: z.string(),
exp: z.number(),
- rol: z
- .string()
- .optional()
- .transform((val) => (val ?? "").split(",")),
});
export type TokenPayload = z.infer;
export const UserMeResponseSchema = z.object({
user: z.object({
id: z.string(),
- avatar: z.string(),
+ avatar: z.string().nullable(),
username: z.string(),
- global_name: z.string(),
+ global_name: z.string().nullable(),
discriminator: z.string(),
- locale: z.string(),
+ locale: z.string().optional(),
}),
player: z.object({
publicId: z.string(),
- roles: z.string().array(),
+ roles: z.string().array().optional(),
}),
});
export type UserMeResponse = z.infer;
diff --git a/src/core/Consolex.ts b/src/core/Consolex.ts
deleted file mode 100644
index 7817d3b08..000000000
--- a/src/core/Consolex.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { EventBus, GameEvent } from "./EventBus";
-import { LogSeverity } from "./Schemas";
-
-export const consolex = {
- log: console.log,
- warn: console.warn,
- error: console.error,
-};
-
-let inited = false;
-
-// Only call this in client/browser!
-export function initRemoteSender(eventBus: EventBus) {
- if (inited) {
- return;
- }
- inited = true;
-
- consolex.log = (...args: any[]): void => {
- console.log(...args);
- // eventBus.emit(new SendLogEvent(LogSeverity.Info, args.join(' ')))
- };
-
- consolex.warn = (...args: any[]): void => {
- console.warn(...args);
- // eventBus.emit(new SendLogEvent(LogSeverity.Warn, args.join(' ')))
- };
-
- consolex.error = (...args: any[]): void => {
- console.error(...args);
- // eventBus.emit(new SendLogEvent(LogSeverity.Error, args.join(' ')))
- };
-}
-export class SendLogEvent implements GameEvent {
- constructor(
- public readonly severity: LogSeverity,
- public readonly log: string,
- ) {}
-}
diff --git a/src/core/PseudoRandom.ts b/src/core/PseudoRandom.ts
index daf27497f..406cfb243 100644
--- a/src/core/PseudoRandom.ts
+++ b/src/core/PseudoRandom.ts
@@ -9,6 +9,9 @@ export class PseudoRandom {
private c: number = 12345;
private state: number;
+ private static readonly POW36_8 = Math.pow(36, 8); // Pre-compute 36^8
+ private static readonly INV_2_32 = 1 / 4294967296; // 1 / 2^32 for float conversion
+
constructor(seed: number) {
// Initialize the XorShift state with seed
this.state0 = seed | 0; // Force to 32-bit integer with bitwise OR
@@ -48,6 +51,13 @@ export class PseudoRandom {
return (this.state0 + this.state1) | 0;
}
+ /**
+ * Optimized version that directly returns unsigned 32-bit integer
+ */
+ private _nextUInt32(): number {
+ return this._nextIntInternal() >>> 0;
+ }
+
/**
* Generates the next pseudorandom number.
* @returns A number between 0 (inclusive) and 1 (exclusive).
@@ -55,7 +65,7 @@ export class PseudoRandom {
next(): number {
// Get a 32-bit integer and convert to [0,1) range
// Using >>> 0 to get unsigned interpretation (positive number)
- const int = this._nextIntInternal() >>> 0;
+ const int = this._nextUInt32();
// Update the state variable to maintain compatibility with original interface
this.state = int % this.m;
@@ -64,25 +74,33 @@ export class PseudoRandom {
return this.state / this.m;
}
+ /**
+ * Optimized version for internal use - directly converts to [0,1) without state update
+ */
+ private _nextFloat(): number {
+ return this._nextUInt32() * PseudoRandom.INV_2_32;
+ }
+
/**
* Generates a random integer between min (inclusive) and max (exclusive).
*/
nextInt(min: number, max: number): number {
- return Math.floor(this.next() * (max - min) + min);
+ // keep max exclusive, min inclusive โ round down to get an int
+ return Math.floor(this._nextFloat() * (max - min)) + min;
}
/**
* Generates a random float between min (inclusive) and max (exclusive).
*/
nextFloat(min: number, max: number): number {
- return this.next() * (max - min) + min;
+ return this._nextFloat() * (max - min) + min;
}
/**
* Generates a random ID (8 characters, alphanumeric).
*/
nextID(): string {
- return this.nextInt(0, Math.pow(36, 8)) // 36^8 possibilities
+ return Math.floor(this._nextFloat() * PseudoRandom.POW36_8) // 36^8 possibilities
.toString(36) // Convert to base36 (0-9 and a-z)
.padStart(8, "0"); // Ensure 8 chars by padding with zeros
}
@@ -94,25 +112,25 @@ export class PseudoRandom {
if (arr.length === 0) {
throw new Error("array must not be empty");
}
- return arr[this.nextInt(0, arr.length)];
+ return arr[Math.floor(this._nextFloat() * arr.length)];
}
/**
* Returns true with probability 1/odds.
*/
chance(odds: number): boolean {
- return this.nextInt(0, odds) === 0;
+ return Math.floor(this._nextFloat() * odds) === 0;
}
/**
* Returns a shuffled copy of the array using Fisher-Yates algorithm.
*/
shuffleArray(array: T[]): T[] {
- for (let i = array.length - 1; i >= 0; i--) {
- const j = this.nextInt(0, i + 1);
- [array[i], array[j]] = [array[j], array[i]];
+ const result = [...array];
+ for (let i = result.length - 1; i >= 0; i--) {
+ const j = Math.floor(this._nextFloat() * (i + 1));
+ [result[i], result[j]] = [result[j], result[i]];
}
-
- return array;
+ return result;
}
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index fdb6b3f9c..e4fff95a6 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -116,7 +116,7 @@ export enum LogSeverity {
Fatal = "FATAL",
}
-const GameConfigSchema = z.object({
+export const GameConfigSchema = z.object({
gameMap: z.nativeEnum(GameMapType),
difficulty: z.nativeEnum(Difficulty),
gameType: z.nativeEnum(GameType),
@@ -245,7 +245,7 @@ export const EmbargoIntentSchema = BaseIntentSchema.extend({
export const DonateGoldIntentSchema = BaseIntentSchema.extend({
type: z.literal("donate_gold"),
recipient: ID,
- gold: z.number().nullable(),
+ gold: z.bigint().nullable(),
});
export const DonateTroopIntentSchema = BaseIntentSchema.extend({
@@ -457,10 +457,12 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({
});
export type GameEndInfo = z.infer;
+const GitCommitSchema = z.string().regex(/^[0-9a-fA-F]{40}$/);
+
export const AnalyticsRecordSchema = z.object({
info: GameEndInfoSchema,
version: z.literal("v0.0.2"),
- gitCommit: z.string(),
+ gitCommit: GitCommitSchema,
});
export type AnalyticsRecord = z.infer;
diff --git a/src/core/Util.ts b/src/core/Util.ts
index b518f5f1a..d78f7091d 100644
--- a/src/core/Util.ts
+++ b/src/core/Util.ts
@@ -195,7 +195,7 @@ export function createGameRecord(
): GameRecord {
const duration = Math.floor((end - start) / 1000);
const version = "v0.0.2";
- const gitCommit = "";
+ const gitCommit = process.env.GIT_COMMIT ?? "unknown";
const num_turns = allTurns.length;
const turns = allTurns.filter(
(t) => t.intents.length !== 0 || t.hash !== undefined,
diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts
new file mode 100644
index 000000000..0a06b1571
--- /dev/null
+++ b/src/core/WorkerSchemas.ts
@@ -0,0 +1,11 @@
+import { z } from "zod";
+import { GameConfigSchema } from "./Schemas";
+
+export const CreateGameInputSchema = GameConfigSchema.or(
+ z
+ .object({})
+ .strict()
+ .transform((val) => undefined),
+);
+
+export const GameInputSchema = GameConfigSchema.partial();
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 7da156e3b..69315c4db 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -29,7 +29,11 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(): number;
gameCreationRate(): number;
- lobbyMaxPlayers(map: GameMapType, mode: GameMode): number;
+ lobbyMaxPlayers(
+ map: GameMapType,
+ mode: GameMode,
+ numPlayerTeams: number | undefined,
+ ): number;
numWorkers(): number;
workerIndex(gameID: GameID): number;
workerPath(gameID: GameID): string;
@@ -52,6 +56,11 @@ export interface ServerConfig {
jwtAudience(): string;
jwtIssuer(): string;
jwkPublicKey(): Promise;
+ domain(): string;
+ subdomain(): string;
+ cloudflareAccountId(): string;
+ cloudflareApiToken(): string;
+ cloudflareConfigDir(): string;
}
export interface NukeMagnitude {
@@ -80,7 +89,7 @@ export interface Config {
startManpower(playerInfo: PlayerInfo): number;
populationIncreaseRate(player: Player | PlayerView): number;
- goldAdditionRate(player: Player | PlayerView): number;
+ goldAdditionRate(player: Player | PlayerView): Gold;
troopAdjustmentRate(player: Player): number;
attackTilesPerTick(
attckTroops: number,
@@ -125,8 +134,7 @@ export interface Config {
defensePostRange(): number;
SAMCooldown(): number;
SiloCooldown(): number;
- defensePostLossMultiplier(): number;
- defensePostSpeedMultiplier(): number;
+ defensePostDefenseBonus(): number;
falloutDefenseModifier(percentOfFallout: number): number;
difficultyModifier(difficulty: Difficulty): number;
warshipPatrolRange(): number;
diff --git a/src/core/configuration/ConfigLoader.ts b/src/core/configuration/ConfigLoader.ts
index 94a723c13..184902694 100644
--- a/src/core/configuration/ConfigLoader.ts
+++ b/src/core/configuration/ConfigLoader.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import { UserSettings } from "../game/UserSettings";
import { GameConfig } from "../Schemas";
import { Config, GameEnv, ServerConfig } from "./Config";
@@ -20,7 +19,7 @@ export async function getConfig(
return new DevConfig(sc, gameConfig, userSettings, isReplay);
case GameEnv.Preprod:
case GameEnv.Prod:
- consolex.log("using prod config");
+ console.log("using prod config");
return new DefaultConfig(sc, gameConfig, userSettings, isReplay);
default:
throw Error(`unsupported server configuration: ${process.env.GAME_ENV}`);
@@ -51,13 +50,13 @@ export function getServerConfigFromServer(): ServerConfig {
export function getServerConfig(gameEnv: string) {
switch (gameEnv) {
case "dev":
- consolex.log("using dev server config");
+ console.log("using dev server config");
return new DevServerConfig();
case "staging":
- consolex.log("using preprod server config");
+ console.log("using preprod server config");
return preprodConfig;
case "prod":
- consolex.log("using prod server config");
+ console.log("using prod server config");
return prodConfig;
default:
throw Error(`unsupported server configuration: ${gameEnv}`);
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 5963148c8..3b1768134 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -53,7 +53,7 @@ const numPlayersConfig = {
[GameMapType.Mena]: [60, 50, 30],
[GameMapType.Mars]: [50, 40, 30],
[GameMapType.Oceania]: [30, 20, 10],
- [GameMapType.Japan]: [50, 40, 30],
+ [GameMapType.EastAsia]: [50, 40, 30],
[GameMapType.FaroeIslands]: [50, 40, 30],
[GameMapType.DeglaciatedAntarctica]: [50, 40, 30],
[GameMapType.EuropeClassic]: [80, 30, 50],
@@ -65,13 +65,22 @@ const numPlayersConfig = {
[GameMapType.Halkidiki]: [50, 40, 30],
} as const satisfies Record;
-const TERRAIN_EFFECTS = {
- [TerrainType.Plains]: { mag: 0.85, speed: 0.8 },
- [TerrainType.Highland]: { mag: 1, speed: 1 },
- [TerrainType.Mountain]: { mag: 1.2, speed: 1.3 },
-} as const;
-
export abstract class DefaultServerConfig implements ServerConfig {
+ domain(): string {
+ return process.env.DOMAIN ?? "";
+ }
+ subdomain(): string {
+ return process.env.SUBDOMAIN ?? "";
+ }
+ cloudflareAccountId(): string {
+ return process.env.CF_ACCOUNT_ID ?? "";
+ }
+ cloudflareApiToken(): string {
+ return process.env.CF_API_TOKEN ?? "";
+ }
+ cloudflareConfigDir(): string {
+ return process.env.CF_CONFIG_DIR ?? "";
+ }
private publicKey: JWK;
abstract jwtAudience(): string;
jwtIssuer(): string {
@@ -143,11 +152,19 @@ export abstract class DefaultServerConfig implements ServerConfig {
return 120 * 1000;
}
- lobbyMaxPlayers(map: GameMapType, mode: GameMode): number {
+ lobbyMaxPlayers(
+ map: GameMapType,
+ mode: GameMode,
+ numPlayerTeams: number | undefined,
+ ): number {
const [l, m, s] = numPlayersConfig[map] ?? [50, 30, 20];
const r = Math.random();
const base = r < 0.3 ? l : r < 0.6 ? m : s;
- return mode === GameMode.Team ? Math.ceil(base * 1.5) : base;
+ let p = Math.min(mode === GameMode.Team ? Math.ceil(base * 1.5) : base, l);
+ if (numPlayerTeams !== undefined) {
+ p -= p % numPlayerTeams;
+ }
+ return p;
}
workerIndex(gameID: GameID): number {
@@ -229,8 +246,8 @@ export class DefaultConfig implements Config {
falloutDefenseModifier(falloutRatio: number): number {
// falloutRatio is between 0 and 1
- // So defense modifier is between [3, 1]
- return 3 - falloutRatio * 2;
+ // So defense modifier is between [5, 2.5]
+ return 5 - falloutRatio * 2;
}
SAMCooldown(): number {
return 75;
@@ -240,13 +257,10 @@ export class DefaultConfig implements Config {
}
defensePostRange(): number {
- return 40;
+ return 30;
}
- defensePostLossMultiplier(): number {
- return 6;
- }
- defensePostSpeedMultiplier(): number {
- return 3;
+ defensePostDefenseBonus(): number {
+ return 5;
}
playerTeams(): number | typeof Duos {
return this._gameConfig.playerTeams ?? 0;
@@ -273,54 +287,59 @@ export class DefaultConfig implements Config {
return this._gameConfig.infiniteTroops;
}
tradeShipGold(dist: number): Gold {
- return 10000 + 150 * Math.pow(dist, 1.1);
+ return BigInt(Math.floor(10000 + 150 * Math.pow(dist, 1.1)));
}
tradeShipSpawnRate(numberOfPorts: number): number {
- return Math.round(10 * Math.pow(numberOfPorts, 0.6));
+ return Math.min(50, Math.round(10 * Math.pow(numberOfPorts, 0.6)));
}
unitInfo(type: UnitType): UnitInfo {
switch (type) {
case UnitType.TransportShip:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: false,
};
case UnitType.Warship:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : Math.min(
- 1_000_000,
- (p.unitsIncludingConstruction(UnitType.Warship).length + 1) *
- 250_000,
+ ? 0n
+ : BigInt(
+ Math.min(
+ 1_000_000,
+ (p.unitsIncludingConstruction(UnitType.Warship).length +
+ 1) *
+ 250_000,
+ ),
),
territoryBound: false,
maxHealth: 1000,
};
case UnitType.Shell:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: false,
damage: 250,
};
case UnitType.SAMMissile:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: false,
};
case UnitType.Port:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : Math.min(
- 1_000_000,
- Math.pow(
- 2,
- p.unitsIncludingConstruction(UnitType.Port).length,
- ) * 125_000,
+ ? 0n
+ : BigInt(
+ Math.min(
+ 1_000_000,
+ Math.pow(
+ 2,
+ p.unitsIncludingConstruction(UnitType.Port).length,
+ ) * 125_000,
+ ),
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
@@ -328,41 +347,43 @@ export class DefaultConfig implements Config {
case UnitType.AtomBomb:
return {
cost: (p: Player) =>
- p.type() === PlayerType.Human && this.infiniteGold() ? 0 : 750_000,
+ p.type() === PlayerType.Human && this.infiniteGold()
+ ? 0n
+ : 750_000n,
territoryBound: false,
};
case UnitType.HydrogenBomb:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : 5_000_000,
+ ? 0n
+ : 5_000_000n,
territoryBound: false,
};
case UnitType.MIRV:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : 25_000_000,
+ ? 0n
+ : 25_000_000n,
territoryBound: false,
};
case UnitType.MIRVWarhead:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: false,
};
case UnitType.TradeShip:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: false,
};
case UnitType.MissileSilo:
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : 1_000_000,
+ ? 0n
+ : 1_000_000n,
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
};
@@ -370,12 +391,14 @@ export class DefaultConfig implements Config {
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : Math.min(
- 250_000,
- (p.unitsIncludingConstruction(UnitType.DefensePost).length +
- 1) *
- 50_000,
+ ? 0n
+ : BigInt(
+ Math.min(
+ 250_000,
+ (p.unitsIncludingConstruction(UnitType.DefensePost).length +
+ 1) *
+ 50_000,
+ ),
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
@@ -384,12 +407,14 @@ export class DefaultConfig implements Config {
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : Math.min(
- 3_000_000,
- (p.unitsIncludingConstruction(UnitType.SAMLauncher).length +
- 1) *
- 1_500_000,
+ ? 0n
+ : BigInt(
+ Math.min(
+ 3_000_000,
+ (p.unitsIncludingConstruction(UnitType.SAMLauncher).length +
+ 1) *
+ 1_500_000,
+ ),
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
@@ -398,20 +423,22 @@ export class DefaultConfig implements Config {
return {
cost: (p: Player) =>
p.type() === PlayerType.Human && this.infiniteGold()
- ? 0
- : Math.min(
- 1_000_000,
- Math.pow(
- 2,
- p.unitsIncludingConstruction(UnitType.City).length,
- ) * 125_000,
+ ? 0n
+ : BigInt(
+ Math.min(
+ 1_000_000,
+ Math.pow(
+ 2,
+ p.unitsIncludingConstruction(UnitType.City).length,
+ ) * 125_000,
+ ),
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
};
case UnitType.Construction:
return {
- cost: () => 0,
+ cost: () => 0n,
territoryBound: true,
};
default:
@@ -478,27 +505,34 @@ export class DefaultConfig implements Config {
defenderTroopLoss: number;
tilesPerTickUsed: number;
} {
+ let mag = 0;
+ let speed = 0;
const type = gm.terrainType(tileToConquer);
- const mod = TERRAIN_EFFECTS[type];
- if (!mod) {
- throw new Error(`terrain type ${type} not supported`);
+ switch (type) {
+ case TerrainType.Plains:
+ mag = 85;
+ speed = 16.5;
+ break;
+ case TerrainType.Highland:
+ mag = 100;
+ speed = 20;
+ break;
+ case TerrainType.Mountain:
+ mag = 120;
+ speed = 25;
+ break;
+ default:
+ throw new Error(`terrain type ${type} not supported`);
}
- let mag = mod.mag;
- let speed = mod.speed;
-
- const attackerType = attacker.type();
- const defenderIsPlayer = defender.isPlayer();
- const defenderType = defenderIsPlayer ? defender.type() : null;
-
- if (defenderIsPlayer) {
+ if (defender.isPlayer()) {
for (const dp of gm.nearbyUnits(
tileToConquer,
gm.config().defensePostRange(),
UnitType.DefensePost,
)) {
if (dp.unit.owner() === defender) {
- mag *= this.defensePostLossMultiplier();
- speed *= this.defensePostSpeedMultiplier();
+ mag *= this.defensePostDefenseBonus();
+ speed *= this.defensePostDefenseBonus();
break;
}
}
@@ -510,50 +544,55 @@ export class DefaultConfig implements Config {
speed *= this.falloutDefenseModifier(falloutRatio);
}
- if (attacker.isPlayer() && defenderIsPlayer) {
+ if (attacker.isPlayer() && defender.isPlayer()) {
if (
- attackerType === PlayerType.Human &&
- defenderType === PlayerType.Bot
+ attacker.type() === PlayerType.Human &&
+ defender.type() === PlayerType.Bot
) {
mag *= 0.8;
}
if (
- attackerType === PlayerType.FakeHuman &&
- defenderType === PlayerType.Bot
+ attacker.type() === PlayerType.FakeHuman &&
+ defender.type() === PlayerType.Bot
) {
mag *= 0.8;
}
}
- if (attackerType === PlayerType.Bot) {
- speed *= 4; // slow bot attacks
+
+ let largeLossModifier = 1;
+ if (attacker.numTilesOwned() > 100_000) {
+ largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned());
}
- if (defenderIsPlayer) {
- const defenderTroops = defender.troops();
- const defenderTiles = defender.numTilesOwned();
- const defenderDensity = defenderTroops / defenderTiles;
- const attackRatio = defenderTroops / attackTroops;
- const traitorDebuff = defender.isTraitor()
- ? this.traitorDefenseDebuff()
- : 1;
- const baseTroopLoss = 16;
- const baseTileCost = 23;
- const attackStandardSize = 10_000;
+ let largeSpeedMalus = 1;
+ if (attacker.numTilesOwned() > 75_000) {
+ // sqrt is only exponent 1/2 which doesn't slow enough huge players
+ largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6;
+ }
+
+ if (defender.isPlayer()) {
return {
attackerTroopLoss:
- mag * (baseTroopLoss + defenderDensity * traitorDebuff),
- defenderTroopLoss: defenderDensity,
+ within(defender.troops() / attackTroops, 0.6, 2) *
+ mag *
+ 0.8 *
+ largeLossModifier *
+ (defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
+ defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
tilesPerTickUsed:
- baseTileCost *
- within(defenderDensity, 3, 100) ** 0.2 *
- (attackStandardSize / attackTroops) ** 0.1 *
+ within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
- within(attackRatio, 0.1, 20) ** 0.4,
+ largeSpeedMalus,
};
} else {
return {
- attackerTroopLoss: 16 * mag,
+ attackerTroopLoss:
+ attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5,
defenderTroopLoss: 0,
- tilesPerTickUsed: 31 * speed,
+ tilesPerTickUsed: within(
+ (2000 * Math.max(10, speed)) / attackTroops,
+ 5,
+ 100,
+ ),
};
}
}
@@ -565,9 +604,13 @@ export class DefaultConfig implements Config {
numAdjacentTilesWithEnemy: number,
): number {
if (defender.isPlayer()) {
- return 10 * numAdjacentTilesWithEnemy;
+ return (
+ within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
+ numAdjacentTilesWithEnemy *
+ 3
+ );
} else {
- return 12 * numAdjacentTilesWithEnemy;
+ return numAdjacentTilesWithEnemy * 2;
}
}
@@ -597,28 +640,28 @@ export class DefaultConfig implements Config {
startManpower(playerInfo: PlayerInfo): number {
if (playerInfo.playerType === PlayerType.Bot) {
- return 6_000;
+ return 10_000;
}
if (playerInfo.playerType === PlayerType.FakeHuman) {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
- return 2_500 + 1000 * (playerInfo?.nation?.strength ?? 1);
+ return 2_500 * (playerInfo?.nation?.strength ?? 1);
case Difficulty.Medium:
- return 6_000 + 2000 * (playerInfo?.nation?.strength ?? 1);
+ return 5_000 * (playerInfo?.nation?.strength ?? 1);
case Difficulty.Hard:
- return 20_000 + 4000 * (playerInfo?.nation?.strength ?? 1);
+ return 20_000 * (playerInfo?.nation?.strength ?? 1);
case Difficulty.Impossible:
- return 50_000 + 8000 * (playerInfo?.nation?.strength ?? 1);
+ return 50_000 * (playerInfo?.nation?.strength ?? 1);
}
}
- return this.infiniteTroops() ? 1_000_000 : 20_000;
+ return this.infiniteTroops() ? 1_000_000 : 25_000;
}
maxPopulation(player: Player | PlayerView): number {
const maxPop =
player.type() === PlayerType.Human && this.infiniteTroops()
? 1_000_000_000
- : 1 * (player.numTilesOwned() * 30 + 50000) +
+ : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
player.units(UnitType.City).length * this.cityPopulationIncrease();
if (player.type() === PlayerType.Bot) {
@@ -631,26 +674,22 @@ export class DefaultConfig implements Config {
switch (this._gameConfig.difficulty) {
case Difficulty.Easy:
- return maxPop * 0.4;
+ return maxPop * 0.5;
case Difficulty.Medium:
- return maxPop * 0.8;
+ return maxPop * 1;
case Difficulty.Hard:
- return maxPop * 1.4;
+ return maxPop * 1.5;
case Difficulty.Impossible:
- return maxPop * 1.8;
+ return maxPop * 2;
}
}
populationIncreaseRate(player: Player): number {
const max = this.maxPopulation(player);
- //population grows proportional to current population with growth decreasing as it approaches max
- // smaller countries recieve a boost to pop growth to speed up early game
- const baseAdditionRate = 10;
- const basePopGrowthRate = 1300 / max + 1 / 140;
- const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers();
- let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop;
- const totalPop = player.totalPopulation();
- const ratio = 1 - totalPop / max;
+
+ let toAdd = 10 + Math.pow(player.population(), 0.73) / 4;
+
+ const ratio = 1 - player.population() / max;
toAdd *= ratio;
if (player.type() === PlayerType.Bot) {
@@ -674,15 +713,15 @@ export class DefaultConfig implements Config {
}
}
- return Math.min(totalPop + toAdd, max) - totalPop;
+ return Math.min(player.population() + toAdd, max) - player.population();
}
- goldAdditionRate(player: Player): number {
- return 0.045 * player.workers() ** 0.7;
+ goldAdditionRate(player: Player): Gold {
+ return BigInt(Math.floor(0.045 * player.workers() ** 0.7));
}
troopAdjustmentRate(player: Player): number {
- const maxDiff = this.maxPopulation(player) / 500;
+ const maxDiff = this.maxPopulation(player) / 1000;
const target = player.population() * player.targetTroopRatio();
const diff = target - player.troops();
if (Math.abs(diff) < maxDiff) {
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 42d361736..6c765a63c 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -22,7 +22,6 @@ export class AttackExecution implements Execution {
private random = new PseudoRandom(123);
- private _owner: Player;
private target: Player | TerraNullius;
private mg: Game;
@@ -31,7 +30,7 @@ export class AttackExecution implements Execution {
constructor(
private startTroops: number | null = null,
- private _ownerID: PlayerID,
+ private _owner: Player,
private _targetID: PlayerID | null,
private sourceTile: TileRef | null = null,
private removeTroops: boolean = true,
@@ -51,18 +50,12 @@ export class AttackExecution implements Execution {
}
this.mg = mg;
- if (!mg.hasPlayer(this._ownerID)) {
- console.warn(`player ${this._ownerID} not found`);
- this.active = false;
- return;
- }
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
console.warn(`target ${this._targetID} not found`);
this.active = false;
return;
}
- this._owner = mg.player(this._ownerID);
this.target =
this._targetID === this.mg.terraNullius().id()
? mg.terraNullius()
diff --git a/src/core/execution/BoatRetreatExecution.ts b/src/core/execution/BoatRetreatExecution.ts
index bcef746a4..c6afedff1 100644
--- a/src/core/execution/BoatRetreatExecution.ts
+++ b/src/core/execution/BoatRetreatExecution.ts
@@ -1,30 +1,15 @@
-import { consolex } from "../Consolex";
-import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game";
+import { Execution, Game, Player, UnitType } from "../game/Game";
export class BoatRetreatExecution implements Execution {
private active = true;
- private player: Player | undefined;
constructor(
- private playerID: PlayerID,
+ private player: Player,
private unitID: number,
) {}
- init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.playerID)) {
- console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
- this.active = false;
- return;
- }
- this.player = mg.player(this.playerID);
- }
+ init(mg: Game, ticks: number): void {}
tick(ticks: number): void {
- if (!this.player) {
- console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
- this.active = false;
- return;
- }
-
const unit = this.player
.units()
.find(
@@ -33,7 +18,7 @@ export class BoatRetreatExecution implements Execution {
);
if (!unit) {
- consolex.warn(`Didn't find outgoing boat with id ${this.unitID}`);
+ console.warn(`Didn't find outgoing boat with id ${this.unitID}`);
this.active = false;
return;
}
@@ -43,9 +28,6 @@ export class BoatRetreatExecution implements Execution {
}
owner(): Player {
- if (this.player === undefined) {
- throw new Error("Not initialized");
- }
return this.player;
}
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index 3b0354810..84d46768c 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -79,7 +79,6 @@ export class BotExecution implements Execution {
}
this.behavior.forgetOldEnemies();
- this.behavior.checkIncomingAttacks();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
diff --git a/src/core/execution/BotSpawner.ts b/src/core/execution/BotSpawner.ts
index 521729a67..644fbc803 100644
--- a/src/core/execution/BotSpawner.ts
+++ b/src/core/execution/BotSpawner.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import { Game, PlayerInfo, PlayerType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
@@ -22,7 +21,7 @@ export class BotSpawner {
let tries = 0;
while (this.bots.length < numBots) {
if (tries > 10000) {
- consolex.log("too many retries while spawning bots, giving up");
+ console.log("too many retries while spawning bots, giving up");
return this.bots;
}
const botName = this.randomBotName();
diff --git a/src/core/execution/CityExecution.ts b/src/core/execution/CityExecution.ts
index c6046f2de..880755573 100644
--- a/src/core/execution/CityExecution.ts
+++ b/src/core/execution/CityExecution.ts
@@ -1,40 +1,25 @@
-import { consolex } from "../Consolex";
-import {
- Execution,
- Game,
- Player,
- PlayerID,
- Unit,
- UnitType,
-} from "../game/Game";
+import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
export class CityExecution implements Execution {
- private player: Player;
private mg: Game;
private city: Unit | null = null;
private active: boolean = true;
constructor(
- private ownerId: PlayerID,
+ private player: Player,
private tile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
- if (!mg.hasPlayer(this.ownerId)) {
- console.warn(`CityExecution: player ${this.ownerId} not found`);
- this.active = false;
- return;
- }
- this.player = mg.player(this.ownerId);
}
tick(ticks: number): void {
if (this.city === null) {
const spawnTile = this.player.canBuild(UnitType.City, this.tile);
if (spawnTile === false) {
- consolex.warn("cannot build city");
+ console.warn("cannot build city");
this.active = false;
return;
}
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index 49ed2e89f..10d31ddd2 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -1,9 +1,8 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
+ Gold,
Player,
- PlayerID,
Tick,
Unit,
UnitType,
@@ -19,29 +18,22 @@ import { SAMLauncherExecution } from "./SAMLauncherExecution";
import { WarshipExecution } from "./WarshipExecution";
export class ConstructionExecution implements Execution {
- private player: Player;
private construction: Unit | null = null;
private active: boolean = true;
private mg: Game;
private ticksUntilComplete: Tick;
- private cost: number;
+ private cost: Gold;
constructor(
- private ownerId: PlayerID,
+ private player: Player,
private tile: TileRef,
private constructionType: UnitType,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
- if (!mg.hasPlayer(this.ownerId)) {
- console.warn(`ConstructionExecution: owner ${this.ownerId} not found`);
- this.active = false;
- return;
- }
- this.player = mg.player(this.ownerId);
}
tick(ticks: number): void {
@@ -54,7 +46,7 @@ export class ConstructionExecution implements Execution {
}
const spawnTile = this.player.canBuild(this.constructionType, this.tile);
if (spawnTile === false) {
- consolex.warn(`cannot build ${this.constructionType}`);
+ console.warn(`cannot build ${this.constructionType}`);
this.active = false;
return;
}
@@ -97,11 +89,11 @@ export class ConstructionExecution implements Execution {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.mg.addExecution(
- new NukeExecution(this.constructionType, player.id(), this.tile),
+ new NukeExecution(this.constructionType, player, this.tile),
);
break;
case UnitType.MIRV:
- this.mg.addExecution(new MirvExecution(player.id(), this.tile));
+ this.mg.addExecution(new MirvExecution(player, this.tile));
break;
case UnitType.Warship:
this.mg.addExecution(
@@ -109,19 +101,19 @@ export class ConstructionExecution implements Execution {
);
break;
case UnitType.Port:
- this.mg.addExecution(new PortExecution(player.id(), this.tile));
+ this.mg.addExecution(new PortExecution(player, this.tile));
break;
case UnitType.MissileSilo:
- this.mg.addExecution(new MissileSiloExecution(player.id(), this.tile));
+ this.mg.addExecution(new MissileSiloExecution(player, this.tile));
break;
case UnitType.DefensePost:
- this.mg.addExecution(new DefensePostExecution(player.id(), this.tile));
+ this.mg.addExecution(new DefensePostExecution(player, this.tile));
break;
case UnitType.SAMLauncher:
- this.mg.addExecution(new SAMLauncherExecution(player.id(), this.tile));
+ this.mg.addExecution(new SAMLauncherExecution(player, this.tile));
break;
case UnitType.City:
- this.mg.addExecution(new CityExecution(player.id(), this.tile));
+ this.mg.addExecution(new CityExecution(player, this.tile));
break;
default:
throw Error(`unit type ${this.constructionType} not supported`);
diff --git a/src/core/execution/DefensePostExecution.ts b/src/core/execution/DefensePostExecution.ts
index c0d6e4711..ab36f81ae 100644
--- a/src/core/execution/DefensePostExecution.ts
+++ b/src/core/execution/DefensePostExecution.ts
@@ -1,17 +1,8 @@
-import { consolex } from "../Consolex";
-import {
- Execution,
- Game,
- Player,
- PlayerID,
- Unit,
- UnitType,
-} from "../game/Game";
+import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ShellExecution } from "./ShellExecution";
export class DefensePostExecution implements Execution {
- private player: Player;
private mg: Game;
private post: Unit | null = null;
private active: boolean = true;
@@ -22,18 +13,12 @@ export class DefensePostExecution implements Execution {
private alreadySentShell = new Set();
constructor(
- private ownerId: PlayerID,
+ private player: Player,
private tile: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
- if (!mg.hasPlayer(this.ownerId)) {
- console.warn(`DefensePostExectuion: owner ${this.ownerId} not found`);
- this.active = false;
- return;
- }
- this.player = mg.player(this.ownerId);
}
private shoot() {
@@ -63,7 +48,7 @@ export class DefensePostExecution implements Execution {
if (this.post === null) {
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
if (spawnTile === false) {
- consolex.warn("cannot build Defense Post");
+ console.warn("cannot build Defense Post");
this.active = false;
return;
}
diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts
index 166f34cde..c4b65939f 100644
--- a/src/core/execution/DonateGoldExecution.ts
+++ b/src/core/execution/DonateGoldExecution.ts
@@ -1,44 +1,38 @@
-import { consolex } from "../Consolex";
-import { Execution, Game, Player, PlayerID } from "../game/Game";
+import { Execution, Game, Gold, Player, PlayerID } from "../game/Game";
export class DonateGoldExecution implements Execution {
- private sender: Player;
private recipient: Player;
private active = true;
constructor(
- private senderID: PlayerID,
+ private sender: Player,
private recipientID: PlayerID,
- private gold: number | null,
+ private gold: Gold | null,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.senderID)) {
- console.warn(`DonateExecution: sender ${this.senderID} not found`);
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.recipientID)) {
console.warn(`DonateExecution recipient ${this.recipientID} not found`);
this.active = false;
return;
}
- this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
if (this.gold === null) {
- this.gold = Math.round(this.sender.gold() / 3);
+ this.gold = this.sender.gold() / 3n;
}
}
tick(ticks: number): void {
if (this.gold === null) throw new Error("not initialized");
- if (this.sender.canDonate(this.recipient)) {
- this.sender.donateGold(this.recipient, this.gold);
+ if (
+ this.sender.canDonate(this.recipient) &&
+ this.sender.donateGold(this.recipient, this.gold)
+ ) {
this.recipient.updateRelation(this.sender, 50);
} else {
- consolex.warn(
+ console.warn(
`cannot send gold from ${this.sender.name()} to ${this.recipient.name()}`,
);
}
diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts
index 99dc4ca66..0570ac641 100644
--- a/src/core/execution/DonateTroopExecution.ts
+++ b/src/core/execution/DonateTroopExecution.ts
@@ -1,45 +1,42 @@
-import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class DonateTroopsExecution implements Execution {
- private sender: Player;
private recipient: Player;
private active = true;
constructor(
- private senderID: PlayerID,
+ private sender: Player,
private recipientID: PlayerID,
private troops: number | null,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.senderID)) {
- console.warn(`DonateExecution: sender ${this.senderID} not found`);
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.recipientID)) {
console.warn(`DonateExecution recipient ${this.recipientID} not found`);
this.active = false;
return;
}
- this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
if (this.troops === null) {
this.troops = mg.config().defaultDonationAmount(this.sender);
}
+ const maxDonation =
+ mg.config().maxPopulation(this.recipient) - this.recipient.population();
+ this.troops = Math.min(this.troops, maxDonation);
}
tick(ticks: number): void {
if (this.troops === null) throw new Error("not initialized");
- if (this.sender.canDonate(this.recipient)) {
- this.sender.donateTroops(this.recipient, this.troops);
+ if (
+ this.sender.canDonate(this.recipient) &&
+ this.sender.donateTroops(this.recipient, this.troops)
+ ) {
this.recipient.updateRelation(this.sender, 50);
} else {
- consolex.warn(
- `cannot send tropps from ${this.sender} to ${this.recipient}`,
+ console.warn(
+ `cannot send troops from ${this.sender} to ${this.recipient}`,
);
}
this.active = false;
diff --git a/src/core/execution/EmbargoExecution.ts b/src/core/execution/EmbargoExecution.ts
index 79e4b8773..67a0664d1 100644
--- a/src/core/execution/EmbargoExecution.ts
+++ b/src/core/execution/EmbargoExecution.ts
@@ -10,11 +10,6 @@ export class EmbargoExecution implements Execution {
) {}
init(mg: Game, _: number): void {
- if (!mg.hasPlayer(this.player.id())) {
- console.warn(`EmbargoExecution: sender ${this.player.id()} not found`);
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.targetID)) {
console.warn(`EmbargoExecution recipient ${this.targetID} not found`);
this.active = false;
diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts
index a544411e4..94f84e58d 100644
--- a/src/core/execution/EmojiExecution.ts
+++ b/src/core/execution/EmojiExecution.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import {
AllPlayers,
Execution,
@@ -10,30 +9,23 @@ import {
import { flattenedEmojiTable } from "../Util";
export class EmojiExecution implements Execution {
- private requestor: Player;
private recipient: Player | typeof AllPlayers;
private active = true;
constructor(
- private senderID: PlayerID,
+ private requestor: Player,
private recipientID: PlayerID | typeof AllPlayers,
private emoji: number,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.senderID)) {
- console.warn(`EmojiExecution: sender ${this.senderID} not found`);
- this.active = false;
- return;
- }
if (this.recipientID !== AllPlayers && !mg.hasPlayer(this.recipientID)) {
console.warn(`EmojiExecution: recipient ${this.recipientID} not found`);
this.active = false;
return;
}
- this.requestor = mg.player(this.senderID);
this.recipient =
this.recipientID === AllPlayers
? AllPlayers
@@ -43,7 +35,7 @@ export class EmojiExecution implements Execution {
tick(ticks: number): void {
const emojiString = flattenedEmojiTable[this.emoji];
if (emojiString === undefined) {
- consolex.warn(
+ console.warn(
`cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`,
);
} else if (this.requestor.canSendEmoji(this.recipient)) {
@@ -56,7 +48,7 @@ export class EmojiExecution implements Execution {
this.recipient.updateRelation(this.requestor, -100);
}
} else {
- consolex.warn(
+ console.warn(
`cannot send emoji from ${this.requestor} to ${this.recipient}`,
);
}
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index db094ca3e..a333d95ec 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -48,21 +48,21 @@ export class Executor {
console.warn(`player with clientID ${intent.clientID} not found`);
return new NoOpExecution();
}
- const playerID = player.id();
+ // create execution
switch (intent.type) {
case "attack": {
return new AttackExecution(
intent.troops,
- playerID,
+ player,
intent.targetID,
null,
);
}
case "cancel_attack":
- return new RetreatExecution(playerID, intent.attackID);
+ return new RetreatExecution(player, intent.attackID);
case "cancel_boat":
- return new BoatRetreatExecution(playerID, intent.unitID);
+ return new BoatRetreatExecution(player, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
case "spawn":
@@ -76,47 +76,47 @@ export class Executor {
src = this.mg.ref(intent.srcX, intent.srcY);
}
return new TransportShipExecution(
- playerID,
+ player,
intent.targetID,
this.mg.ref(intent.dstX, intent.dstY),
intent.troops,
src,
);
case "allianceRequest":
- return new AllianceRequestExecution(playerID, intent.recipient);
+ return new AllianceRequestExecution(player, intent.recipient);
case "allianceRequestReply":
return new AllianceRequestReplyExecution(
intent.requestor,
- playerID,
+ player,
intent.accept,
);
case "breakAlliance":
- return new BreakAllianceExecution(playerID, intent.recipient);
+ return new BreakAllianceExecution(player, intent.recipient);
case "targetPlayer":
- return new TargetPlayerExecution(playerID, intent.target);
+ return new TargetPlayerExecution(player, intent.target);
case "emoji":
- return new EmojiExecution(playerID, intent.recipient, intent.emoji);
+ return new EmojiExecution(player, intent.recipient, intent.emoji);
case "donate_troops":
return new DonateTroopsExecution(
- playerID,
+ player,
intent.recipient,
intent.troops,
);
case "donate_gold":
- return new DonateGoldExecution(playerID, intent.recipient, intent.gold);
+ return new DonateGoldExecution(player, intent.recipient, intent.gold);
case "troop_ratio":
- return new SetTargetTroopRatioExecution(playerID, intent.ratio);
+ return new SetTargetTroopRatioExecution(player, intent.ratio);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
case "build_unit":
return new ConstructionExecution(
- playerID,
+ player,
this.mg.ref(intent.x, intent.y),
intent.unit,
);
case "quick_chat":
return new QuickChatExecution(
- playerID,
+ player,
intent.recipient,
intent.quickChatKey,
intent.variables ?? {},
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index af71e77c2..8c03a4d49 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -1,9 +1,9 @@
-import { consolex } from "../Consolex";
import {
Cell,
Difficulty,
Execution,
Game,
+ Gold,
Nation,
Player,
PlayerID,
@@ -116,7 +116,7 @@ export class FakeHumanExecution implements Execution {
if (this.mg.inSpawnPhase()) {
const rl = this.randomLand();
if (rl === null) {
- consolex.warn(`cannot spawn ${this.nation.playerInfo.name}`);
+ console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
@@ -262,7 +262,6 @@ export class FakeHumanExecution implements Execution {
throw new Error("not initialized");
}
this.behavior.forgetOldEnemies();
- this.behavior.checkIncomingAttacks();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
if (!enemy) return;
@@ -283,7 +282,7 @@ export class FakeHumanExecution implements Execution {
this.lastEmojiSent.set(enemy, this.mg.ticks());
this.mg.addExecution(
new EmojiExecution(
- this.player.id(),
+ this.player,
enemy.id(),
this.random.randElement(this.heckleEmoji),
),
@@ -355,7 +354,7 @@ export class FakeHumanExecution implements Execution {
const tick = this.mg.ticks();
this.lastNukeSent.push([tick, tile]);
this.mg.addExecution(
- new NukeExecution(UnitType.AtomBomb, this.player.id(), tile),
+ new NukeExecution(UnitType.AtomBomb, this.player, tile),
);
}
@@ -374,14 +373,21 @@ export class FakeHumanExecution implements Execution {
return 50_000;
case UnitType.Port:
return 10_000;
- case UnitType.SAMLauncher:
- return 5_000;
default:
return 0;
}
})
.reduce((prev, cur) => prev + cur, 0);
+ // Avoid areas defended by SAM launchers
+ const dist50 = euclDistFN(tile, 50, false);
+ tileValue -=
+ 50_000 *
+ targets.filter(
+ (unit) =>
+ unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
+ ).length;
+
// Prefer tiles that are closer to a silo
const siloTiles = silos.map((u) => u.tile());
const result = closestTwoTiles(this.mg, siloTiles, [tile]);
@@ -415,7 +421,7 @@ export class FakeHumanExecution implements Execution {
}
this.mg.addExecution(
new TransportShipExecution(
- this.player.id(),
+ this.player,
other.id(),
closest.y,
this.player.troops() / 5,
@@ -435,7 +441,7 @@ export class FakeHumanExecution implements Execution {
if (oceanTiles.length > 0) {
const buildTile = this.random.randElement(oceanTiles);
this.mg.addExecution(
- new ConstructionExecution(player.id(), buildTile, UnitType.Port),
+ new ConstructionExecution(player, buildTile, UnitType.Port),
);
}
return;
@@ -464,9 +470,7 @@ export class FakeHumanExecution implements Execution {
if (canBuild === false) {
return;
}
- this.mg.addExecution(
- new ConstructionExecution(this.player.id(), tile, type),
- );
+ this.mg.addExecution(new ConstructionExecution(this.player, tile, type));
}
private maybeSpawnWarship(): boolean {
@@ -488,15 +492,11 @@ export class FakeHumanExecution implements Execution {
}
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
if (canBuild === false) {
- consolex.warn("cannot spawn destroyer");
+ console.warn("cannot spawn destroyer");
return false;
}
this.mg.addExecution(
- new ConstructionExecution(
- this.player.id(),
- targetTile,
- UnitType.Warship,
- ),
+ new ConstructionExecution(this.player, targetTile, UnitType.Warship),
);
return true;
}
@@ -544,7 +544,7 @@ export class FakeHumanExecution implements Execution {
return null;
}
- private cost(type: UnitType): number {
+ private cost(type: UnitType): Gold {
if (this.player === null) throw new Error("not initialized");
return this.mg.unitInfo(type).cost(this.player);
}
@@ -567,7 +567,7 @@ export class FakeHumanExecution implements Execution {
this.mg.addExecution(
new TransportShipExecution(
- this.player.id(),
+ this.player,
this.mg.owner(dst).id(),
dst,
this.player.troops() / 5,
diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts
index 41e617f8a..18411e80d 100644
--- a/src/core/execution/MIRVExecution.ts
+++ b/src/core/execution/MIRVExecution.ts
@@ -1,10 +1,8 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
MessageType,
Player,
- PlayerID,
TerraNullius,
Unit,
UnitType,
@@ -16,8 +14,6 @@ import { simpleHash } from "../Util";
import { NukeExecution } from "./NukeExecution";
export class MirvExecution implements Execution {
- private player: Player;
-
private active = true;
private mg: Game;
@@ -38,21 +34,14 @@ export class MirvExecution implements Execution {
private speed: number = -1;
constructor(
- private senderID: PlayerID,
+ private player: Player,
private dst: TileRef,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.senderID)) {
- console.warn(`MIRVExecution: player ${this.senderID} not found`);
- this.active = false;
- return;
- }
-
- this.random = new PseudoRandom(mg.ticks() + simpleHash(this.senderID));
+ this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id()));
this.mg = mg;
this.pathFinder = new ParabolaPathFinder(mg);
- this.player = mg.player(this.senderID);
this.targetPlayer = this.mg.owner(this.dst);
this.speed = this.mg.config().defaultNukeSpeed();
@@ -64,7 +53,7 @@ export class MirvExecution implements Execution {
if (this.nuke === null) {
const spawn = this.player.canBuild(UnitType.MIRV, this.dst);
if (spawn === false) {
- consolex.warn(`cannot build MIRV`);
+ console.warn(`cannot build MIRV`);
this.active = false;
return;
}
@@ -119,7 +108,7 @@ export class MirvExecution implements Execution {
this.mg.addExecution(
new NukeExecution(
UnitType.MIRVWarhead,
- this.senderID,
+ this.player,
dst,
this.nuke.tile(),
15 + Math.floor((i / this.warheadCount) * 5),
diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts
index d9b30f9dd..7fd9459d8 100644
--- a/src/core/execution/MissileSiloExecution.ts
+++ b/src/core/execution/MissileSiloExecution.ts
@@ -1,44 +1,25 @@
-import { consolex } from "../Consolex";
-import {
- Execution,
- Game,
- Player,
- PlayerID,
- Unit,
- UnitType,
-} from "../game/Game";
+import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
export class MissileSiloExecution implements Execution {
private active = true;
- private mg: Game | null = null;
- private player: Player | null = null;
+ private mg: Game;
private silo: Unit | null = null;
constructor(
- private _owner: PlayerID,
+ private player: Player,
private tile: TileRef,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this._owner)) {
- console.warn(`MissileSiloExecution: owner ${this._owner} not found`);
- this.active = false;
- return;
- }
-
this.mg = mg;
- this.player = mg.player(this._owner);
}
tick(ticks: number): void {
- if (this.player === null || this.mg === null) {
- throw new Error("Not initialized");
- }
if (this.silo === null) {
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
if (spawn === false) {
- consolex.warn(
+ console.warn(
`player ${this.player} cannot build missile silo at ${this.tile}`,
);
this.active = false;
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index facdbb649..91ed7d725 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -1,10 +1,8 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
MessageType,
Player,
- PlayerID,
TerraNullius,
Unit,
UnitType,
@@ -16,8 +14,7 @@ import { NukeType } from "../StatsSchemas";
export class NukeExecution implements Execution {
private active = true;
- private player: Player | null = null;
- private mg: Game | null = null;
+ private mg: Game;
private nuke: Unit | null = null;
private tilesToDestroyCache: Set | undefined;
@@ -25,8 +22,8 @@ export class NukeExecution implements Execution {
private pathFinder: ParabolaPathFinder;
constructor(
- private type: NukeType,
- private senderID: PlayerID,
+ private nukeType: NukeType,
+ private player: Player,
private dst: TileRef,
private src?: TileRef | null,
private speed: number = -1,
@@ -34,14 +31,7 @@ export class NukeExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.senderID)) {
- console.warn(`NukeExecution: sender ${this.senderID} not found`);
- this.active = false;
- return;
- }
-
this.mg = mg;
- this.player = mg.player(this.senderID);
this.random = new PseudoRandom(ticks);
if (this.speed === -1) {
this.speed = this.mg.config().defaultNukeSpeed();
@@ -50,9 +40,6 @@ export class NukeExecution implements Execution {
}
public target(): Player | TerraNullius {
- if (this.mg === null) {
- throw new Error("Not initialized");
- }
return this.mg.owner(this.dst);
}
@@ -60,7 +47,7 @@ export class NukeExecution implements Execution {
if (this.tilesToDestroyCache !== undefined) {
return this.tilesToDestroyCache;
}
- if (this.mg === null || this.nuke === null) {
+ if (this.nuke === null) {
throw new Error("Not initialized");
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
@@ -75,7 +62,7 @@ export class NukeExecution implements Execution {
}
private breakAlliances(toDestroy: Set) {
- if (this.mg === null || this.player === null || this.nuke === null) {
+ if (this.nuke === null) {
throw new Error("Not initialized");
}
const attacked = new Map();
@@ -102,30 +89,26 @@ export class NukeExecution implements Execution {
}
tick(ticks: number): void {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
-
if (this.nuke === null) {
- const spawn = this.src ?? this.player.canBuild(this.type, this.dst);
+ const spawn = this.src ?? this.player.canBuild(this.nukeType, this.dst);
if (spawn === false) {
- consolex.warn(`cannot build Nuke`);
+ console.warn(`cannot build Nuke`);
this.active = false;
return;
}
this.pathFinder.computeControlPoints(
spawn,
this.dst,
- this.type !== UnitType.MIRVWarhead,
+ this.nukeType !== UnitType.MIRVWarhead,
);
- this.nuke = this.player.buildUnit(this.type, spawn, {
+ this.nuke = this.player.buildUnit(this.nukeType, spawn, {
targetTile: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
const target = this.mg.owner(this.dst);
if (!target.isPlayer()) {
// Ignore terra nullius
- } else if (this.type === UnitType.AtomBomb) {
+ } else if (this.nukeType === UnitType.AtomBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
`${this.player.name()} - atom bomb inbound`,
@@ -133,7 +116,7 @@ export class NukeExecution implements Execution {
target.id(),
);
this.breakAlliances(this.tilesToDestroy());
- } else if (this.type === UnitType.HydrogenBomb) {
+ } else if (this.nukeType === UnitType.HydrogenBomb) {
this.mg.displayIncomingUnit(
this.nuke.id(),
`${this.player.name()} - hydrogen bomb inbound`,
@@ -144,9 +127,7 @@ export class NukeExecution implements Execution {
}
// Record stats
- this.mg
- .stats()
- .bombLaunch(this.player, target, this.nuke.type() as NukeType);
+ this.mg.stats().bombLaunch(this.player, target, this.nukeType);
}
// after sending a nuke set the missilesilo on cooldown
@@ -161,7 +142,7 @@ export class NukeExecution implements Execution {
// make the nuke unactive if it was intercepted
if (!this.nuke.isActive()) {
- consolex.log(`Nuke destroyed before reaching target`);
+ console.log(`Nuke destroyed before reaching target`);
this.active = false;
return;
}
@@ -182,7 +163,7 @@ export class NukeExecution implements Execution {
}
private detonate() {
- if (this.mg === null || this.nuke === null || this.player === null) {
+ if (this.nuke === null) {
throw new Error("Not initialized");
}
@@ -249,9 +230,6 @@ export class NukeExecution implements Execution {
}
owner(): Player {
- if (this.player === null) {
- throw new Error("Not initialized");
- }
return this.player;
}
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index d68d1eae5..6d68ca783 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -17,35 +17,25 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
export class PlayerExecution implements Execution {
private readonly ticksPerClusterCalc = 20;
- private player: Player | null = null;
- private config: Config | null = null;
+ private config: Config;
private lastCalc = 0;
- private mg: Game | null = null;
+ private mg: Game;
private active = true;
- constructor(private playerID: PlayerID) {}
+ constructor(private player: Player) {}
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game, ticks: number) {
- if (!mg.hasPlayer(this.playerID)) {
- console.warn(`PlayerExecution: player ${this.playerID} not found`);
- this.active = false;
- return;
- }
this.mg = mg;
this.config = mg.config();
- this.player = mg.player(this.playerID);
this.lastCalc =
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
}
tick(ticks: number) {
- if (this.mg === null || this.config === null || this.player === null) {
- throw new Error("Not initialized");
- }
this.player.decayRelations();
this.player.units().forEach((u) => {
const tileOwner = this.mg!.owner(u.tile());
@@ -133,16 +123,13 @@ export class PlayerExecution implements Execution {
this.removeClusters();
const end = performance.now();
if (end - start > 1000) {
- consolex.log(`player ${this.player.name()}, took ${end - start}ms`);
+ console.log(`player ${this.player.name()}, took ${end - start}ms`);
}
}
}
}
private removeClusters() {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
const clusters = this.calculateClusters();
clusters.sort((a, b) => b.size - a.size);
@@ -162,9 +149,6 @@ export class PlayerExecution implements Execution {
}
private surroundedBySamePlayer(cluster: Set): false | Player {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
const enemies = new Set();
for (const tile of cluster) {
const isOceanShore = this.mg.isOceanShore(tile);
@@ -199,9 +183,6 @@ export class PlayerExecution implements Execution {
}
private isSurrounded(cluster: Set): boolean {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
const enemyTiles = new Set();
for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
@@ -225,9 +206,6 @@ export class PlayerExecution implements Execution {
}
private removeCluster(cluster: Set) {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
if (
Array.from(cluster).some(
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
@@ -309,9 +287,6 @@ export class PlayerExecution implements Execution {
}
private getCapturingPlayer(cluster: Set): Player | null {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
const neighborsIDs = new Set();
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
@@ -354,9 +329,6 @@ export class PlayerExecution implements Execution {
}
private calculateClusters(): Set[] {
- if (this.mg === null || this.player === null) {
- throw new Error("Not initialized");
- }
const seen = new Set();
const border = this.player.borderTiles();
const clusters: Set[] = [];
diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts
index 74b6554f6..1f5e10d64 100644
--- a/src/core/execution/PortExecution.ts
+++ b/src/core/execution/PortExecution.ts
@@ -1,12 +1,4 @@
-import { consolex } from "../Consolex";
-import {
- Execution,
- Game,
- Player,
- PlayerID,
- Unit,
- UnitType,
-} from "../game/Game";
+import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { TradeShipExecution } from "./TradeShipExecution";
@@ -19,16 +11,11 @@ export class PortExecution implements Execution {
private checkOffset: number | null = null;
constructor(
- private _owner: PlayerID,
+ private player: Player,
private tile: TileRef,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this._owner)) {
- console.warn(`PortExecution: player ${this._owner} not found`);
- this.active = false;
- return;
- }
this.mg = mg;
this.random = new PseudoRandom(mg.ticks());
this.checkOffset = mg.ticks() % 10;
@@ -40,14 +27,15 @@ export class PortExecution implements Execution {
}
if (this.port === null) {
const tile = this.tile;
- const player = this.mg.player(this._owner);
- const spawn = player.canBuild(UnitType.Port, tile);
+ const spawn = this.player.canBuild(UnitType.Port, tile);
if (spawn === false) {
- consolex.warn(`player ${player} cannot build port at ${this.tile}`);
+ console.warn(
+ `player ${this.player.id()} cannot build port at ${this.tile}`,
+ );
this.active = false;
return;
}
- this.port = player.buildUnit(UnitType.Port, spawn, {});
+ this.port = this.player.buildUnit(UnitType.Port, spawn, {});
}
if (!this.port.isActive()) {
@@ -55,8 +43,8 @@ export class PortExecution implements Execution {
return;
}
- if (this._owner !== this.port.owner().id()) {
- this._owner = this.port.owner().id();
+ if (this.player.id() !== this.port.owner().id()) {
+ this.player = this.port.owner();
}
// Only check every 10 ticks for performance.
@@ -71,16 +59,14 @@ export class PortExecution implements Execution {
return;
}
- const ports = this.player().tradingPorts(this.port);
+ const ports = this.player.tradingPorts(this.port);
if (ports.length === 0) {
return;
}
const port = this.random.randElement(ports);
- this.mg.addExecution(
- new TradeShipExecution(this.player().id(), this.port, port),
- );
+ this.mg.addExecution(new TradeShipExecution(this.player, this.port, port));
}
isActive(): boolean {
@@ -90,11 +76,4 @@ export class PortExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
-
- player(): Player {
- if (this.port === null) {
- throw new Error("Not initialized");
- }
- return this.port.owner();
- }
}
diff --git a/src/core/execution/QuickChatExecution.ts b/src/core/execution/QuickChatExecution.ts
index 002171a83..4e545b6c5 100644
--- a/src/core/execution/QuickChatExecution.ts
+++ b/src/core/execution/QuickChatExecution.ts
@@ -1,15 +1,13 @@
-import { consolex } from "../Consolex";
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class QuickChatExecution implements Execution {
- private sender: Player;
private recipient: Player;
private mg: Game;
private active = true;
constructor(
- private senderID: PlayerID,
+ private sender: Player,
private recipientID: PlayerID,
private quickChatKey: string,
private variables: Record,
@@ -17,20 +15,14 @@ export class QuickChatExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
- if (!mg.hasPlayer(this.senderID)) {
- consolex.warn(`QuickChatExecution: sender ${this.senderID} not found`);
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.recipientID)) {
- consolex.warn(
+ console.warn(
`QuickChatExecution: recipient ${this.recipientID} not found`,
);
this.active = false;
return;
}
- this.sender = mg.player(this.senderID);
this.recipient = mg.player(this.recipientID);
}
@@ -55,7 +47,7 @@ export class QuickChatExecution implements Execution {
this.recipient.name(),
);
- consolex.log(
+ console.log(
`[QuickChat] ${this.sender.name} โ ${this.recipient.name}: ${message}`,
);
diff --git a/src/core/execution/RetreatExecution.ts b/src/core/execution/RetreatExecution.ts
index c40929adc..3383aec4c 100644
--- a/src/core/execution/RetreatExecution.ts
+++ b/src/core/execution/RetreatExecution.ts
@@ -1,26 +1,19 @@
-import { Execution, Game, Player, PlayerID } from "../game/Game";
+import { Execution, Game, Player } from "../game/Game";
const cancelDelay = 20;
export class RetreatExecution implements Execution {
private active = true;
private retreatOrdered = false;
- private player: Player;
private startTick: number;
private mg: Game;
constructor(
- private playerID: PlayerID,
+ private player: Player,
private attackID: string,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.playerID)) {
- console.warn(`RetreatExecution: player ${this.playerID} not found`);
- return;
- }
this.mg = mg;
-
- this.player = mg.player(this.playerID);
this.startTick = mg.ticks();
}
diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts
index e2547218c..9fffdaab3 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -1,10 +1,8 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
MessageType,
Player,
- PlayerID,
Unit,
UnitType,
} from "../game/Game";
@@ -13,11 +11,11 @@ import { PseudoRandom } from "../PseudoRandom";
import { SAMMissileExecution } from "./SAMMissileExecution";
export class SAMLauncherExecution implements Execution {
- private player: Player;
private mg: Game;
private active: boolean = true;
private searchRangeRadius = 80;
+ private targetRangeRadius = 120; // Nuke's target should be in this range to be focusable
// As MIRV go very fast we have to detect them very early but we only
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
private MIRVWarheadSearchRadius = 400;
@@ -26,7 +24,7 @@ export class SAMLauncherExecution implements Execution {
private pseudoRandom: PseudoRandom | undefined;
constructor(
- private ownerId: PlayerID,
+ private player: Player,
private tile: TileRef | null,
private sam: Unit | null = null,
) {
@@ -37,12 +35,18 @@ export class SAMLauncherExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
- if (!mg.hasPlayer(this.ownerId)) {
- console.warn(`SAMLauncherExecution: owner ${this.ownerId} not found`);
- this.active = false;
- return;
+ }
+
+ private nukeTargetInRange(nuke: Unit) {
+ const targetTile = nuke.targetTile();
+ if (this.sam === null || targetTile === undefined) {
+ return false;
}
- this.player = mg.player(this.ownerId);
+ const targetRangeSquared = this.targetRangeRadius * this.targetRangeRadius;
+ return (
+ this.mg.euclideanDistSquared(this.sam.tile(), targetTile) <
+ targetRangeSquared
+ );
}
private getSingleTarget(): Unit | null {
@@ -54,7 +58,9 @@ export class SAMLauncherExecution implements Execution {
])
.filter(
({ unit }) =>
- unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
+ unit.owner() !== this.player &&
+ !this.player.isFriendly(unit.owner()) &&
+ this.nukeTargetInRange(unit),
);
return (
@@ -102,7 +108,7 @@ export class SAMLauncherExecution implements Execution {
}
const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile);
if (spawnTile === false) {
- consolex.warn("cannot build SAM Launcher");
+ console.warn("cannot build SAM Launcher");
this.active = false;
return;
}
diff --git a/src/core/execution/SetTargetTroopRatioExecution.ts b/src/core/execution/SetTargetTroopRatioExecution.ts
index 2d143e245..d43834003 100644
--- a/src/core/execution/SetTargetTroopRatioExecution.ts
+++ b/src/core/execution/SetTargetTroopRatioExecution.ts
@@ -1,28 +1,18 @@
-import { consolex } from "../Consolex";
-import { Execution, Game, Player, PlayerID } from "../game/Game";
+import { Execution, Game, Player } from "../game/Game";
export class SetTargetTroopRatioExecution implements Execution {
- private player: Player;
-
private active = true;
constructor(
- private playerID: PlayerID,
+ private player: Player,
private targetTroopsRatio: number,
) {}
- init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.playerID)) {
- console.warn(
- `SetTargetTRoopRatioExecution: player ${this.playerID} not found`,
- );
- }
- this.player = mg.player(this.playerID);
- }
+ init(mg: Game, ticks: number): void {}
tick(ticks: number): void {
if (this.targetTroopsRatio < 0 || this.targetTroopsRatio > 1) {
- consolex.warn(
+ console.warn(
`target troop ratio of ${this.targetTroopsRatio} for player ${this.player} invalid`,
);
} else {
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index add0550a4..d6d45b3d4 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -38,7 +38,7 @@ export class SpawnExecution implements Execution {
});
if (!player.hasSpawned()) {
- this.mg.addExecution(new PlayerExecution(player.id()));
+ this.mg.addExecution(new PlayerExecution(player));
if (player.type() === PlayerType.Bot) {
this.mg.addExecution(new BotExecution(player));
}
diff --git a/src/core/execution/TargetPlayerExecution.ts b/src/core/execution/TargetPlayerExecution.ts
index 8b22d2998..e6e454534 100644
--- a/src/core/execution/TargetPlayerExecution.ts
+++ b/src/core/execution/TargetPlayerExecution.ts
@@ -1,31 +1,22 @@
import { Execution, Game, Player, PlayerID } from "../game/Game";
export class TargetPlayerExecution implements Execution {
- private requestor: Player;
private target: Player;
private active = true;
constructor(
- private requestorID: PlayerID,
+ private requestor: Player,
private targetID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.requestorID)) {
- console.warn(
- `TargetPlayerExecution: requestor ${this.requestorID} not found`,
- );
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.targetID)) {
console.warn(`TargetPlayerExecution: target ${this.targetID} not found`);
this.active = false;
return;
}
- this.requestor = mg.player(this.requestorID);
this.target = mg.player(this.targetID);
}
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index 3094b9384..2ae94dca1 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -1,11 +1,9 @@
import { renderNumber } from "../../client/Utils";
-import { consolex } from "../Consolex";
import {
Execution,
Game,
MessageType,
Player,
- PlayerID,
Unit,
UnitType,
} from "../game/Game";
@@ -17,20 +15,18 @@ import { distSortUnit } from "../Util";
export class TradeShipExecution implements Execution {
private active = true;
private mg: Game;
- private origOwner: Player;
private tradeShip: Unit | undefined;
private wasCaptured = false;
private pathFinder: PathFinder;
constructor(
- private _owner: PlayerID,
+ private origOwner: Player,
private srcPort: Unit,
private _dstPort: Unit,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
- this.origOwner = mg.player(this._owner);
this.pathFinder = PathFinder.Mini(mg, 2500);
}
@@ -41,7 +37,7 @@ export class TradeShipExecution implements Execution {
this.srcPort.tile(),
);
if (spawn === false) {
- consolex.warn(`cannot build trade ship`);
+ console.warn(`cannot build trade ship`);
this.active = false;
return;
}
@@ -115,7 +111,7 @@ export class TradeShipExecution implements Execution {
this.tradeShip.move(result.tile);
break;
case PathFindResultType.PathNotFound:
- consolex.warn("captured trade ship cannot find route");
+ console.warn("captured trade ship cannot find route");
if (this.tradeShip.isActive()) {
this.tradeShip.delete(false);
}
diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts
index 8d59d34d9..78cc71c06 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
@@ -24,7 +23,6 @@ export class TransportShipExecution implements Execution {
private active = true;
private mg: Game;
- private attacker: Player;
private target: Player | TerraNullius;
// TODO make private
@@ -36,7 +34,7 @@ export class TransportShipExecution implements Execution {
private pathFinder: PathFinder;
constructor(
- private attackerID: PlayerID,
+ private attacker: Player,
private targetID: PlayerID | null,
private ref: TileRef,
private troops: number,
@@ -48,13 +46,6 @@ export class TransportShipExecution implements Execution {
}
init(mg: Game, ticks: number) {
- if (!mg.hasPlayer(this.attackerID)) {
- console.warn(
- `TransportShipExecution: attacker ${this.attackerID} not found`,
- );
- this.active = false;
- return;
- }
if (this.targetID !== null && !mg.hasPlayer(this.targetID)) {
console.warn(`TransportShipExecution: target ${this.targetID} not found`);
this.active = false;
@@ -65,8 +56,6 @@ export class TransportShipExecution implements Execution {
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, 10);
- this.attacker = mg.player(this.attackerID);
-
if (
this.attacker.units(UnitType.TransportShip).length >=
mg.config().boatMaxNumber()
@@ -74,7 +63,7 @@ export class TransportShipExecution implements Execution {
mg.displayMessage(
`No boats available, max ${mg.config().boatMaxNumber()}`,
MessageType.WARN,
- this.attackerID,
+ this.attacker.id(),
);
this.active = false;
this.attacker.addTroops(this.troops);
@@ -100,7 +89,7 @@ export class TransportShipExecution implements Execution {
this.dst = targetTransportTile(this.mg, this.ref);
if (this.dst === null) {
- consolex.warn(
+ console.warn(
`${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`,
);
this.active = false;
@@ -112,7 +101,7 @@ export class TransportShipExecution implements Execution {
this.dst,
);
if (closestTileSrc === false) {
- consolex.warn(`can't build transport ship`);
+ console.warn(`can't build transport ship`);
this.active = false;
return;
}
@@ -176,7 +165,7 @@ export class TransportShipExecution implements Execution {
switch (result.type) {
case PathFindResultType.Completed:
if (this.mg.owner(this.dst) === this.attacker) {
- this.attacker.addTroops(this.troops);
+ this.attacker.addTroops(this.boat.troops());
this.boat.delete(false);
this.active = false;
@@ -193,7 +182,7 @@ export class TransportShipExecution implements Execution {
this.mg.addExecution(
new AttackExecution(
this.troops,
- this.attacker.id(),
+ this.attacker,
this.targetID,
this.dst,
false,
@@ -215,7 +204,7 @@ export class TransportShipExecution implements Execution {
break;
case PathFindResultType.PathNotFound:
// TODO: add to poisoned port list
- consolex.warn(`path not found to dst`);
+ console.warn(`path not found to dst`);
this.attacker.addTroops(this.troops);
this.boat.delete(false);
this.active = false;
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index f5400c74d..969eec2e0 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import {
Execution,
Game,
@@ -79,28 +78,45 @@ export class WarshipExecution implements Execution {
const hasPort = this.warship.owner().units(UnitType.Port).length > 0;
const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
- const ships = this.mg
- .nearbyUnits(
- this.warship.patrolTile()!,
- this.mg.config().warshipTargettingRange(),
- [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
- )
- .filter(
- ({ unit }) =>
- unit.owner() !== this.warship.owner() &&
- unit !== this.warship &&
- !unit.owner().isFriendly(this.warship.owner()) &&
- !this.alreadySentShell.has(unit) &&
- (unit.type() !== UnitType.TradeShip ||
- (hasPort &&
- this.mg.euclideanDistSquared(this.warship.tile(), unit.tile()) <=
- patrolRangeSquared &&
- unit.targetUnit()?.owner() !== this.warship.owner() &&
- !unit.targetUnit()?.owner().isFriendly(this.warship.owner()) &&
- unit.isSafeFromPirates() !== true)),
- );
+ const ships = this.mg.nearbyUnits(
+ this.warship.tile()!,
+ this.mg.config().warshipTargettingRange(),
+ [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
+ );
+ const potentialTargets: { unit: Unit; distSquared: number }[] = [];
+ for (const { unit, distSquared } of ships) {
+ if (
+ unit.owner() === this.warship.owner() ||
+ unit === this.warship ||
+ unit.owner().isFriendly(this.warship.owner()) ||
+ this.alreadySentShell.has(unit)
+ ) {
+ continue;
+ }
+ if (unit.type() === UnitType.TradeShip) {
+ if (
+ !hasPort ||
+ unit.isSafeFromPirates() ||
+ unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port
+ unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally
+ ) {
+ continue;
+ }
+ if (
+ this.mg.euclideanDistSquared(
+ this.warship.patrolTile()!,
+ unit.tile(),
+ ) > patrolRangeSquared
+ ) {
+ // Prevent warship from chasing trade ship that is too far away from
+ // the patrol tile to prevent warships from wandering around the map.
+ continue;
+ }
+ }
+ potentialTargets.push({ unit: unit, distSquared });
+ }
- return ships.sort((a, b) => {
+ return potentialTargets.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
@@ -175,7 +191,7 @@ export class WarshipExecution implements Execution {
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
- consolex.log(`path not found to target`);
+ console.log(`path not found to target`);
break;
}
}
@@ -205,7 +221,7 @@ export class WarshipExecution implements Execution {
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
- consolex.warn(`path not found to target tile`);
+ console.warn(`path not found to target tile`);
this.warship.setTargetTile(undefined);
break;
}
diff --git a/src/core/execution/alliance/AllianceRequestExecution.ts b/src/core/execution/alliance/AllianceRequestExecution.ts
index 7698b0c82..419b8b92c 100644
--- a/src/core/execution/alliance/AllianceRequestExecution.ts
+++ b/src/core/execution/alliance/AllianceRequestExecution.ts
@@ -1,24 +1,15 @@
-import { consolex } from "../../Consolex";
import { Execution, Game, Player, PlayerID } from "../../game/Game";
export class AllianceRequestExecution implements Execution {
private active = true;
- private requestor: Player | null = null;
private recipient: Player | null = null;
constructor(
- private requestorID: PlayerID,
+ private requestor: Player,
private recipientID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.requestorID)) {
- console.warn(
- `AllianceRequestExecution requester ${this.requestorID} not found`,
- );
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.recipientID)) {
console.warn(
`AllianceRequestExecution recipient ${this.recipientID} not found`,
@@ -27,18 +18,17 @@ export class AllianceRequestExecution implements Execution {
return;
}
- this.requestor = mg.player(this.requestorID);
this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
- if (this.requestor === null || this.recipient === null) {
+ if (this.recipient === null) {
throw new Error("Not initialized");
}
if (this.requestor.isFriendly(this.recipient)) {
- consolex.warn("already allied");
+ console.warn("already allied");
} else if (!this.requestor.canSendAllianceRequest(this.recipient)) {
- consolex.warn("recent or pending alliance request");
+ console.warn("recent or pending alliance request");
} else {
this.requestor.createAllianceRequest(this.recipient);
}
diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts
index 3c6bdc5e7..bd3d90a58 100644
--- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts
+++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts
@@ -1,14 +1,12 @@
-import { consolex } from "../../Consolex";
import { Execution, Game, Player, PlayerID } from "../../game/Game";
export class AllianceRequestReplyExecution implements Execution {
private active = true;
private requestor: Player | null = null;
- private recipient: Player | null = null;
constructor(
private requestorID: PlayerID,
- private recipientID: PlayerID,
+ private recipient: Player,
private accept: boolean,
) {}
@@ -20,29 +18,21 @@ export class AllianceRequestReplyExecution implements Execution {
this.active = false;
return;
}
- if (!mg.hasPlayer(this.recipientID)) {
- console.warn(
- `AllianceRequestReplyExecution recipient ${this.recipientID} not found`,
- );
- this.active = false;
- return;
- }
this.requestor = mg.player(this.requestorID);
- this.recipient = mg.player(this.recipientID);
}
tick(ticks: number): void {
- if (this.requestor === null || this.recipient === null) {
+ if (this.requestor === null) {
throw new Error("Not initialized");
}
if (this.requestor.isFriendly(this.recipient)) {
- consolex.warn("already allied");
+ console.warn("already allied");
} else {
const request = this.requestor
.outgoingAllianceRequests()
.find((ar) => ar.recipient() === this.recipient);
if (request === undefined) {
- consolex.warn("no alliance request found");
+ console.warn("no alliance request found");
} else {
if (this.accept) {
request.accept();
diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts
index 65e2ebc16..de614c1cc 100644
--- a/src/core/execution/alliance/BreakAllianceExecution.ts
+++ b/src/core/execution/alliance/BreakAllianceExecution.ts
@@ -1,25 +1,16 @@
-import { consolex } from "../../Consolex";
import { Execution, Game, Player, PlayerID } from "../../game/Game";
export class BreakAllianceExecution implements Execution {
private active = true;
- private requestor: Player | null = null;
private recipient: Player | null = null;
private mg: Game | null = null;
constructor(
- private requestorID: PlayerID,
+ private requestor: Player,
private recipientID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
- if (!mg.hasPlayer(this.requestorID)) {
- console.warn(
- `BreakAllianceExecution requester ${this.requestorID} not found`,
- );
- this.active = false;
- return;
- }
if (!mg.hasPlayer(this.recipientID)) {
console.warn(
`BreakAllianceExecution: recipient ${this.recipientID} not found`,
@@ -27,7 +18,6 @@ export class BreakAllianceExecution implements Execution {
this.active = false;
return;
}
- this.requestor = mg.player(this.requestorID);
this.recipient = mg.player(this.recipientID);
this.mg = mg;
}
@@ -42,7 +32,7 @@ export class BreakAllianceExecution implements Execution {
}
const alliance = this.requestor.allianceWith(this.recipient);
if (alliance === null) {
- consolex.warn("cant break alliance, not allied");
+ console.warn("cant break alliance, not allied");
} else {
this.requestor.breakAlliance(alliance);
this.recipient.updateRelation(this.requestor, -200);
diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts
index afbb53331..39ff00907 100644
--- a/src/core/execution/utils/BotBehavior.ts
+++ b/src/core/execution/utils/BotBehavior.ts
@@ -40,9 +40,7 @@ export class BotBehavior {
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
- this.game.addExecution(
- new EmojiExecution(this.player.id(), player.id(), emoji),
- );
+ this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
forgetOldEnemies() {
@@ -52,7 +50,7 @@ export class BotBehavior {
}
}
- checkIncomingAttacks() {
+ private checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length > 0) {
@@ -109,6 +107,11 @@ export class BotBehavior {
}
}
+ // Retaliate against incoming attacks
+ if (this.enemy === null) {
+ this.checkIncomingAttacks();
+ }
+
// Select the most hated player
if (this.enemy === null) {
const mostHated = this.player.allRelationsSorted()[0];
@@ -145,8 +148,15 @@ export class BotBehavior {
this.enemy = neighbor;
this.enemyUpdated = this.game.ticks();
}
+ }
- // Select a traitor as an enemy
+ // Retaliate against incoming attacks
+ if (this.enemy === null) {
+ this.checkIncomingAttacks();
+ }
+
+ // Select a traitor as an enemy
+ if (this.enemy === null) {
const traitors = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
@@ -182,7 +192,7 @@ export class BotBehavior {
this.game.addExecution(
new AttackExecution(
troops,
- this.player.id(),
+ this.player,
target.isPlayer() ? target.id() : null,
),
);
@@ -190,11 +200,12 @@ export class BotBehavior {
}
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
- const notTraitor = !request.requestor().isTraitor();
- const noMalice = player.relation(request.requestor()) >= Relation.Neutral;
+ const isTraitor = request.requestor().isTraitor();
+ const hasMalice = player.relation(request.requestor()) < Relation.Neutral;
const requestorIsMuchLarger =
request.requestor().numTilesOwned() > player.numTilesOwned() * 3;
- const notTooManyAlliances =
- requestorIsMuchLarger || request.requestor().alliances().length < 3;
- return notTraitor && noMalice && notTooManyAlliances;
+ const tooManyAlliances = request.requestor().alliances().length >= 3;
+ return (
+ !isTraitor && !hasMalice && (requestorIsMuchLarger || !tooManyAlliances)
+ );
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index b7c0b607d..cccefbb6d 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -12,7 +12,7 @@ import { Stats } from "./Stats";
export type PlayerID = string;
export type Tick = number;
-export type Gold = number;
+export type Gold = bigint;
export const AllPlayers = "AllPlayers" as const;
@@ -70,7 +70,7 @@ export enum GameMapType {
GatewayToTheAtlantic = "Gateway to the Atlantic",
Australia = "Australia",
Iceland = "Iceland",
- Japan = "Japan",
+ EastAsia = "East Asia",
BetweenTwoSeas = "Between Two Seas",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
@@ -97,7 +97,7 @@ export const mapCategories: Record = {
GameMapType.GatewayToTheAtlantic,
GameMapType.BetweenTwoSeas,
GameMapType.Iceland,
- GameMapType.Japan,
+ GameMapType.EastAsia,
GameMapType.Mena,
GameMapType.Australia,
GameMapType.FaroeIslands,
@@ -449,12 +449,11 @@ export interface Player {
// Resources & Population
gold(): Gold;
population(): number;
- totalPopulation(): number;
workers(): number;
troops(): number;
targetTroopRatio(): number;
addGold(toAdd: Gold): void;
- removeGold(toRemove: Gold): void;
+ removeGold(toRemove: Gold): Gold;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
setTargetTroopRatio(target: number): void;
@@ -511,8 +510,8 @@ export interface Player {
// Donation
canDonate(recipient: Player): boolean;
- donateTroops(recipient: Player, troops: number): void;
- donateGold(recipient: Player, gold: number): void;
+ donateTroops(recipient: Player, troops: number): boolean;
+ donateGold(recipient: Player, gold: Gold): boolean;
// Embargo
hasEmbargoAgainst(other: Player): boolean;
@@ -624,7 +623,7 @@ export interface PlayerActions {
export interface BuildableUnit {
canBuild: TileRef | false;
type: UnitType;
- cost: number;
+ cost: Gold;
}
export interface PlayerProfile {
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index f4ce9ea10..af7a1c41c 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -1,5 +1,4 @@
import { Config } from "../configuration/Config";
-import { consolex } from "../Consolex";
import { AllPlayersStats, ClientID } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
@@ -196,7 +195,7 @@ export class GameImpl implements Game {
recipient: Player,
): AllianceRequest | null {
if (requestor.isAlliedWith(recipient)) {
- consolex.log("cannot request alliance, already allied");
+ console.log("cannot request alliance, already allied");
return null;
}
if (
@@ -204,14 +203,14 @@ export class GameImpl implements Game {
.incomingAllianceRequests()
.find((ar) => ar.requestor() === requestor) !== undefined
) {
- consolex.log(`duplicate alliance request from ${requestor.name()}`);
+ console.log(`duplicate alliance request from ${requestor.name()}`);
return null;
}
const correspondingReq = requestor
.incomingAllianceRequests()
.find((ar) => ar.requestor() === recipient);
if (correspondingReq !== undefined) {
- consolex.log(`got corresponding alliance requests, accepting`);
+ console.log(`got corresponding alliance requests, accepting`);
correspondingReq.accept();
return null;
}
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index cbd1b7e78..7078d6b9f 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -2,6 +2,7 @@ import { AllPlayersStats, ClientID } from "../Schemas";
import {
EmojiMessage,
GameUpdates,
+ Gold,
MessageType,
NameViewData,
PlayerID,
@@ -104,9 +105,8 @@ export interface PlayerUpdate {
isAlive: boolean;
isDisconnected: boolean;
tilesOwned: number;
- gold: number;
+ gold: Gold;
population: number;
- totalPopulation: number;
workers: number;
troops: number;
targetTroopRatio: number;
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 7684bcbfe..08bdab9b5 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -229,9 +229,6 @@ export class PlayerView {
population(): number {
return this.data.population;
}
- totalPopulation(): number {
- return this.data.totalPopulation;
- }
workers(): number {
return this.data.workers;
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 80724feba..da8a1efed 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1,5 +1,4 @@
import { renderNumber, renderTroops } from "../../client/Utils";
-import { consolex } from "../Consolex";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
import {
@@ -139,9 +138,8 @@ export class PlayerImpl implements Player {
isAlive: this.isAlive(),
isDisconnected: this.isDisconnected(),
tilesOwned: this.numTilesOwned(),
- gold: Number(this._gold),
+ gold: this._gold,
population: this.population(),
- totalPopulation: this.totalPopulation(),
workers: this.workers(),
troops: this.troops(),
targetTroopRatio: this.targetTroopRatio(),
@@ -296,7 +294,7 @@ export class PlayerImpl implements Player {
orderRetreat(id: string) {
const attack = this._outgoingAttacks.filter((attack) => attack.id() === id);
if (!attack || !attack[0]) {
- consolex.warn(`Didn't find outgoing attack with id ${id}`);
+ console.warn(`Didn't find outgoing attack with id ${id}`);
return;
}
attack[0].orderRetreat();
@@ -566,9 +564,13 @@ export class PlayerImpl implements Player {
return true;
}
- donateTroops(recipient: Player, troops: number): void {
+ donateTroops(recipient: Player, troops: number): boolean {
+ if (troops <= 0) return false;
+ const removed = this.removeTroops(troops);
+ if (removed === 0) return false;
+ recipient.addTroops(removed);
+
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
- recipient.addTroops(this.removeTroops(troops));
this.mg.displayMessage(
`Sent ${renderTroops(troops)} troops to ${recipient.name()}`,
MessageType.INFO,
@@ -579,10 +581,16 @@ export class PlayerImpl implements Player {
MessageType.SUCCESS,
recipient.id(),
);
+ return true;
}
- donateGold(recipient: Player, gold: number): void {
+
+ donateGold(recipient: Player, gold: Gold): boolean {
+ if (gold <= 0n) return false;
+ const removed = this.removeGold(gold);
+ if (removed === 0n) return false;
+ recipient.addGold(removed);
+
this.sentDonations.push(new Donation(recipient, this.mg.ticks()));
- recipient.addGold(this.removeGold(gold));
this.mg.displayMessage(
`Sent ${renderNumber(gold)} gold to ${recipient.name()}`,
MessageType.INFO,
@@ -593,6 +601,7 @@ export class PlayerImpl implements Player {
MessageType.SUCCESS,
recipient.id(),
);
+ return true;
}
hasEmbargoAgainst(other: Player): boolean {
@@ -659,40 +668,25 @@ export class PlayerImpl implements Player {
}
gold(): Gold {
- return Number(this._gold);
+ return this._gold;
}
addGold(toAdd: Gold): void {
- this._gold += toInt(toAdd);
+ this._gold += toAdd;
}
- removeGold(toRemove: Gold): number {
- if (toRemove <= 1) {
- return 0;
+ removeGold(toRemove: Gold): Gold {
+ if (toRemove <= 0n) {
+ return 0n;
}
- const actualRemoved = minInt(this._gold, toInt(toRemove));
+ const actualRemoved = minInt(this._gold, toRemove);
this._gold -= actualRemoved;
- return Number(actualRemoved);
+ return actualRemoved;
}
population(): number {
return Number(this._troops + this._workers);
}
- totalPopulation(): number {
- return this.population() + this.attackingTroops();
- }
- private attackingTroops(): number {
- const landAttackTroops = this._outgoingAttacks
- .filter((a) => a.isActive())
- .reduce((sum, a) => sum + a.troops(), 0);
-
- const boatTroops = this.units(UnitType.TransportShip)
- .map((u) => u.troops())
- .reduce((sum, n) => sum + n, 0);
-
- return landAttackTroops + boatTroops;
- }
-
workers(): number {
return Math.max(1, Number(this._workers));
}
@@ -728,7 +722,7 @@ export class PlayerImpl implements Player {
this._troops += toInt(troops);
}
removeTroops(troops: number): number {
- if (troops <= 1) {
+ if (troops <= 0) {
return 0;
}
const toRemove = minInt(this._troops, toInt(troops));
diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts
index e77e83716..baefc6623 100644
--- a/src/core/game/TeamAssignment.ts
+++ b/src/core/game/TeamAssignment.ts
@@ -1,4 +1,6 @@
-import { PlayerInfo, Team } from "./Game";
+import { PseudoRandom } from "../PseudoRandom";
+import { simpleHash } from "../Util";
+import { PlayerInfo, PlayerType, Team } from "./Game";
export function assignTeams(
players: PlayerInfo[],
@@ -57,7 +59,19 @@ export function assignTeams(
}
// Then, assign non-clan players to balance teams
- for (const player of noClanPlayers) {
+ let nationPlayers = noClanPlayers.filter(
+ (player) => player.playerType === PlayerType.FakeHuman,
+ );
+ if (nationPlayers.length > 0) {
+ // Shuffle only nations to randomize their team assignment
+ const random = new PseudoRandom(simpleHash(nationPlayers[0].id));
+ nationPlayers = random.shuffleArray(nationPlayers);
+ }
+ const otherPlayers = noClanPlayers.filter(
+ (player) => player.playerType !== PlayerType.FakeHuman,
+ );
+
+ for (const player of otherPlayers.concat(nationPlayers)) {
let team: Team | null = null;
let teamSize = 0;
for (const t of teams) {
diff --git a/src/core/game/TerrainMapFileLoader.ts b/src/core/game/TerrainMapFileLoader.ts
index 821209f93..5edbe9b67 100644
--- a/src/core/game/TerrainMapFileLoader.ts
+++ b/src/core/game/TerrainMapFileLoader.ts
@@ -39,7 +39,7 @@ const MAP_FILE_NAMES: Record = {
[GameMapType.GatewayToTheAtlantic]: "GatewayToTheAtlantic",
[GameMapType.Australia]: "Australia",
[GameMapType.Iceland]: "Iceland",
- [GameMapType.Japan]: "Japan",
+ [GameMapType.EastAsia]: "EastAsia",
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts
index adf2399fb..73098359e 100644
--- a/src/core/game/TerrainMapLoader.ts
+++ b/src/core/game/TerrainMapLoader.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import { GameMapType } from "./Game";
import { GameMap, GameMapImpl } from "./GameMap";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@@ -65,13 +64,13 @@ export async function genTerrainFromBin(data: string): Promise {
}
function logBinaryAsAscii(data: string, length: number = 8) {
- consolex.log("Binary data (1 = set bit, 0 = unset bit):");
+ console.log("Binary data (1 = set bit, 0 = unset bit):");
for (let i = 0; i < Math.min(length, data.length); i++) {
const byte = data.charCodeAt(i);
let byteString = "";
for (let j = 7; j >= 0; j--) {
byteString += byte & (1 << j) ? "1" : "0";
}
- consolex.log(`Byte ${i}: ${byteString}`);
+ console.log(`Byte ${i}: ${byteString}`);
}
}
diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts
index 9d26439e0..049407ad0 100644
--- a/src/core/pathfinding/PathFinding.ts
+++ b/src/core/pathfinding/PathFinding.ts
@@ -1,4 +1,3 @@
-import { consolex } from "../Consolex";
import { Game } from "../game/Game";
import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
@@ -117,11 +116,11 @@ export class PathFinder {
dist: number = 1,
): TileResult {
if (curr === null) {
- consolex.error("curr is null");
+ console.error("curr is null");
return { type: PathFindResultType.PathNotFound };
}
if (dst === null) {
- consolex.error("dst is null");
+ console.error("dst is null");
return { type: PathFindResultType.PathNotFound };
}
diff --git a/src/core/pathfinding/SerialAStar.ts b/src/core/pathfinding/SerialAStar.ts
index 7655a75ba..927fc4b90 100644
--- a/src/core/pathfinding/SerialAStar.ts
+++ b/src/core/pathfinding/SerialAStar.ts
@@ -1,5 +1,4 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
-import { consolex } from "../Consolex";
import { GameMap, TileRef } from "../game/GameMap";
import { AStar, PathFindResultType } from "./AStar";
@@ -154,7 +153,7 @@ export class SerialAStar implements AStar {
Math.abs(this.gameMap.y(a) - this.gameMap.y(b)))
);
} catch {
- consolex.log("uh oh");
+ console.log("uh oh");
return 0;
}
}
diff --git a/src/scripts/generateTerrainMaps.ts b/src/scripts/generateTerrainMaps.ts
index 92c61062a..2a9fd37a3 100644
--- a/src/scripts/generateTerrainMaps.ts
+++ b/src/scripts/generateTerrainMaps.ts
@@ -22,7 +22,7 @@ const maps = [
"Pangaea",
"Iceland",
"BetweenTwoSeas",
- "Japan",
+ "EastAsia",
"KnownWorld",
"FaroeIslands",
"DeglaciatedAntarctica",
diff --git a/src/server/Client.ts b/src/server/Client.ts
index 6daa49a25..c367c0e04 100644
--- a/src/server/Client.ts
+++ b/src/server/Client.ts
@@ -13,6 +13,7 @@ export class Client {
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly claims: TokenPayload | null,
+ public readonly roles: string[] | undefined,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts
new file mode 100644
index 000000000..ceea88fc3
--- /dev/null
+++ b/src/server/Cloudflare.ts
@@ -0,0 +1,271 @@
+import { spawn } from "child_process";
+import { promises as fs } from "fs";
+import yaml from "js-yaml";
+import { join } from "path";
+import { logger } from "./Logger";
+
+const log = logger.child({
+ module: "cloudflare",
+});
+
+export interface TunnelConfig {
+ domain: string;
+ subdomain: string;
+ subdomainToService: Map;
+}
+
+interface TunnelResponse {
+ result: {
+ id: string;
+ token: string;
+ };
+}
+
+interface ZoneResponse {
+ result: Array<{
+ id: string;
+ }>;
+}
+
+interface DNSRecordResponse {
+ result: Array<{
+ id: string;
+ }>;
+}
+
+interface CloudflaredConfig {
+ tunnel: string;
+ "credentials-file": string;
+ ingress: Array<{
+ hostname?: string;
+ service: string;
+ }>;
+}
+
+export class Cloudflare {
+ private baseUrl = "https://api.cloudflare.com/client/v4";
+
+ constructor(
+ private accountId: string,
+ private apiToken: string,
+ private configDir: string,
+ ) {
+ log.info(`Using config directory: ${this.configDir}`);
+ }
+
+ private async makeRequest(
+ url: string,
+ method: string = "GET",
+ data?: any,
+ ): Promise {
+ const response = await fetch(url, {
+ method,
+ headers: {
+ Authorization: `Bearer ${this.apiToken}`,
+ "Content-Type": "application/json",
+ },
+ body: data ? JSON.stringify(data) : undefined,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Cloudflare API error: url ${url} ${response.status} - ${errorText}`,
+ );
+ }
+
+ return response.json() as Promise;
+ }
+
+ public async createTunnel(config: TunnelConfig): Promise<{
+ tunnelId: string;
+ tunnelToken: string;
+ tunnelUrl: string;
+ configPath: string;
+ }> {
+ const { domain, subdomain, subdomainToService } = config;
+
+ // Generate unique tunnel name
+ const timestamp = new Date().toISOString().replace(/[-:.]/g, "");
+ const tunnelName = `${subdomain}-tunnel-${timestamp}`;
+
+ log.info(`Creating tunnel with name: ${tunnelName}`);
+
+ // Create tunnel via API to get official tunnel ID and token
+ const tunnelResponse = await this.makeRequest(
+ `${this.baseUrl}/accounts/${this.accountId}/cfd_tunnel`,
+ "POST",
+ { name: tunnelName },
+ );
+
+ const tunnelId = tunnelResponse.result.id;
+ const tunnelToken = tunnelResponse.result.token;
+
+ if (!tunnelId) {
+ throw new Error("Failed to create tunnel");
+ }
+
+ log.info(`Tunnel created with ID: ${tunnelId}`);
+
+ // Create local config file instead of using API configuration
+ const configPath = await this.writeTunnelConfig(
+ tunnelId,
+ tunnelToken,
+ subdomain,
+ domain,
+ subdomainToService,
+ tunnelName,
+ );
+
+ // Get zone ID
+ const zoneResponse = await this.makeRequest(
+ `${this.baseUrl}/zones?name=${domain}`,
+ );
+
+ const zoneId = zoneResponse.result[0]?.id;
+ if (!zoneId) {
+ throw new Error(`Could not find zone ID for domain ${domain}`);
+ }
+
+ await Promise.all(
+ Array.from(subdomainToService.entries()).map(([subdomain, _]) =>
+ this.updateDNSRecord(zoneId, tunnelId, subdomain, domain),
+ ),
+ );
+
+ const tunnelUrl = `https://${subdomain}.${domain}`;
+ log.info(`Tunnel is set up! Site will be available at: ${tunnelUrl}`);
+
+ return { tunnelId, tunnelToken, tunnelUrl, configPath };
+ }
+
+ private async writeTunnelConfig(
+ tunnelId: string,
+ tunnelToken: string,
+ subdomain: string,
+ domain: string,
+ subdomainToService: Map,
+ tunnelName: string,
+ ): Promise {
+ log.info(`Creating local config for tunnel ${subdomain}.${domain}...`);
+
+ const configPath = join(this.configDir, `${tunnelName}.yml`);
+ const credentialsFile = join(this.configDir, `${tunnelId}.json`);
+
+ const tokenData = JSON.parse(
+ Buffer.from(tunnelToken, "base64").toString("utf8"),
+ );
+
+ const credentials = {
+ AccountTag: tokenData.a || this.accountId,
+ TunnelID: tokenData.t || tunnelId,
+ TunnelName: tunnelName,
+ TunnelSecret: tokenData.s,
+ };
+
+ await fs.writeFile(
+ credentialsFile,
+ JSON.stringify(credentials, null, 2),
+ "utf8",
+ );
+ log.info(`Created credentials file at: ${credentialsFile}`);
+
+ const tunnelConfig: CloudflaredConfig = {
+ tunnel: tunnelId,
+ "credentials-file": credentialsFile,
+ ingress: [
+ ...Array.from(subdomainToService.entries()).map(
+ ([subdomain, service]) => ({
+ hostname: `${subdomain}.${domain}`,
+ service: service,
+ }),
+ ),
+ {
+ service: "http_status:404",
+ },
+ ],
+ };
+
+ // Write config file
+ await fs.writeFile(configPath, yaml.dump(tunnelConfig), "utf8");
+ log.info(`Created config file at: ${configPath}`);
+
+ return configPath;
+ }
+
+ private async updateDNSRecord(
+ zoneId: string,
+ tunnelId: string,
+ subdomain: string,
+ domain: string,
+ ): Promise {
+ const existingRecords = await this.makeRequest(
+ `${this.baseUrl}/zones/${zoneId}/dns_records?name=${subdomain}.${domain}`,
+ );
+
+ const recordId = existingRecords.result[0]?.id;
+ const dnsData = {
+ type: "CNAME",
+ name: subdomain,
+ content: `${tunnelId}.cfargotunnel.com`,
+ ttl: 1,
+ proxied: true,
+ };
+
+ if (recordId) {
+ log.info(`Updating existing DNS record for ${subdomain}.${domain}...`);
+ await this.makeRequest(
+ `${this.baseUrl}/zones/${zoneId}/dns_records/${recordId}`,
+ "PUT",
+ dnsData,
+ );
+ } else {
+ log.info(`Creating new DNS record for ${subdomain}.${domain}...`);
+ await this.makeRequest(
+ `${this.baseUrl}/zones/${zoneId}/dns_records`,
+ "POST",
+ dnsData,
+ );
+ }
+ }
+
+ public async startCloudflared(configPath: string) {
+ const cloudflared = spawn(
+ "cloudflared",
+ ["tunnel", "--config", configPath, "--loglevel", "error", "run"],
+ {
+ detached: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ // Set this to bypass origin cert requirement for named tunnels
+ TUNNEL_ORIGIN_CERT: "/dev/null",
+ },
+ },
+ );
+
+ cloudflared.stdout?.on("data", (data) => {
+ log.info(data.toString().trim());
+ });
+ cloudflared.stderr?.on("data", (data) => {
+ log.error(data.toString().trim());
+ });
+
+ cloudflared.on("error", (error) => {
+ log.error("Failed to start cloudflared", {
+ error: error.message,
+ });
+ });
+
+ cloudflared.on("exit", (code, signal) => {
+ if (code !== null) {
+ log.error(`Cloudflared exited with code ${code}`, {
+ exitCode: code,
+ signal,
+ });
+ }
+ });
+
+ cloudflared.unref();
+ }
+}
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 4bc8a9adc..88db909dd 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -1,9 +1,9 @@
import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
+import { z } from "zod/v4";
import {
ClientID,
- ClientMessage,
ClientMessageSchema,
ClientSendWinnerMessage,
GameConfig,
@@ -147,7 +147,9 @@ export class GameServer {
existingIP: ipAnonymize(conflicting.ip),
existingPersistentID: conflicting.persistentID,
});
- return;
+ // Kick the existing client instead of the new one, because this was causing issues when
+ // a client wanted to replay the game afterwards.
+ this.kickClient(conflicting.clientID);
}
}
@@ -184,12 +186,16 @@ export class GameServer {
"message",
gatekeeper.wsHandler(client.ip, async (message: string) => {
try {
- let clientMsg: ClientMessage | null = null;
- try {
- clientMsg = ClientMessageSchema.parse(JSON.parse(message));
- } catch (error) {
- throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`);
+ const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
+ if (!parsed.success) {
+ const error = z.prettifyError(parsed.error);
+ this.log.error("Failed to parse client message", error, {
+ clientID: client.clientID,
+ });
+ client.ws.close();
+ return;
}
+ const clientMsg = parsed.data;
if (clientMsg.type === "intent") {
if (clientMsg.intent.clientID !== client.clientID) {
this.log.warn(
@@ -581,20 +587,23 @@ export class GameServer {
gameID: this.id,
winner: this.winner?.winner,
});
- const playerRecords: PlayerRecord[] = Array.from(
- this.allClients.values(),
- ).map((client) => {
- const stats = this.winner?.allPlayersStats[client.clientID];
- if (stats === undefined) {
- this.log.warn(`Unable to find stats for clientID ${client.clientID}`);
- }
- return {
- clientID: client.clientID,
- username: client.username,
- persistentID: client.persistentID,
- stats,
- } satisfies PlayerRecord;
- });
+
+ // Players must stay in the same order as the game start info.
+ const playerRecords: PlayerRecord[] = this.gameStartInfo.players.map(
+ (player) => {
+ const stats = this.winner?.allPlayersStats[player.clientID];
+ if (stats === undefined) {
+ this.log.warn(`Unable to find stats for clientID ${player.clientID}`);
+ }
+ return {
+ clientID: player.clientID,
+ username: player.username,
+ persistentID:
+ this.allClients.get(player.clientID)?.persistentID ?? "",
+ stats,
+ } satisfies PlayerRecord;
+ },
+ );
archive(
createGameRecord(
this.id,
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index c961ce389..4e3daaf5a 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -26,7 +26,7 @@ const frequency = {
Asia: 1,
Mars: 1,
BetweenTwoSeas: 1,
- Japan: 1,
+ EastAsia: 1,
BlackSea: 1,
FaroeIslands: 1,
FalklandIslands: 1,
@@ -51,18 +51,17 @@ export class MapPlaylist {
// Create the default public game config (from your GameManager)
return {
gameMap: map,
- maxPlayers: config.lobbyMaxPlayers(map, mode),
+ maxPlayers: config.lobbyMaxPlayers(map, mode, numPlayerTeams),
gameType: GameType.Public,
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNPCs: mode === GameMode.Team,
- disableNukes: false,
gameMode: mode,
playerTeams: numPlayerTeams,
bots: 400,
- } as GameConfig;
+ } satisfies GameConfig;
}
private getNextMap(): MapWithMode {
diff --git a/src/server/Master.ts b/src/server/Master.ts
index 19bddcfad..388aba19c 100644
--- a/src/server/Master.ts
+++ b/src/server/Master.ts
@@ -282,9 +282,7 @@ async function schedulePublicGame(playlist: MapPlaylist) {
"Content-Type": "application/json",
[config.adminHeader()]: config.adminToken(),
},
- body: JSON.stringify({
- gameConfig: playlist.gameConfig(),
- }),
+ body: JSON.stringify(playlist.gameConfig()),
},
);
diff --git a/src/server/Server.ts b/src/server/Server.ts
index 59468c10b..92cecff2f 100644
--- a/src/server/Server.ts
+++ b/src/server/Server.ts
@@ -1,14 +1,22 @@
import cluster from "cluster";
import * as dotenv from "dotenv";
+import { GameEnv } from "../core/configuration/Config";
+import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
+import { Cloudflare, TunnelConfig } from "./Cloudflare";
import { startMaster } from "./Master";
import { startWorker } from "./Worker";
+const config = getServerConfigFromServer();
+
dotenv.config();
// Main entry point of the application
async function main() {
// Check if this is the primary (master) process
if (cluster.isPrimary) {
+ if (config.env() !== GameEnv.Dev) {
+ await setupTunnels();
+ }
console.log("Starting master process...");
await startMaster();
} else {
@@ -23,3 +31,31 @@ main().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
+
+async function setupTunnels() {
+ const cloudflare = new Cloudflare(
+ config.cloudflareAccountId(),
+ config.cloudflareApiToken(),
+ config.cloudflareConfigDir(),
+ );
+
+ const domainToService = new Map().set(
+ config.subdomain(),
+ `http://localhost:3000`,
+ );
+
+ for (let i = 0; i < config.numWorkers(); i++) {
+ domainToService.set(
+ `w${i}-${config.subdomain()}`,
+ `http://localhost:${3000 + i + 1}`,
+ );
+ }
+
+ const tunnel = await cloudflare.createTunnel({
+ subdomain: config.subdomain(),
+ domain: config.domain(),
+ subdomainToService: domainToService,
+ } as TunnelConfig);
+
+ await cloudflare.startCloudflared(tunnel.configPath);
+}
diff --git a/src/server/Worker.ts b/src/server/Worker.ts
index 5ff087242..5bee6b603 100644
--- a/src/server/Worker.ts
+++ b/src/server/Worker.ts
@@ -5,15 +5,21 @@ import ipAnonymize from "ip-anonymize";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
+import { z } from "zod/v4";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
-import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas";
+import {
+ ClientJoinMessageSchema,
+ GameRecord,
+ GameRecordSchema,
+} from "../core/Schemas";
+import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, readGameRecord } from "./Archive";
import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
-import { verifyClientToken } from "./jwt";
+import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -83,7 +89,13 @@ export function startWorker() {
return res.status(400).json({ error: "Game ID is required" });
}
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
- const gc = req.body?.gameConfig as GameConfig;
+ const result = CreateGameInputSchema.safeParse(req.body);
+ if (!result.success) {
+ const error = z.prettifyError(result.error);
+ return res.status(400).json({ error });
+ }
+
+ const gc = result.data;
if (
gc?.gameType === GameType.Public &&
req.headers[config.adminHeader()] !== config.adminToken()
@@ -91,9 +103,7 @@ export function startWorker() {
log.warn(
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
);
- return res
- .status(400)
- .json({ error: "Invalid admin token for public game creation" });
+ return res.status(401).send("Unauthorized");
}
// Double-check this worker should host this game
@@ -138,9 +148,15 @@ export function startWorker() {
app.put(
"/api/game/:id",
gatekeeper.httpHandler(LimiterType.Put, async (req, res) => {
+ const result = GameInputSchema.safeParse(req.body);
+ if (!result.success) {
+ const error = z.prettifyError(result.error);
+ return res.status(400).json({ error });
+ }
+ const config = result.data;
// TODO: only update public game if from local host
const lobbyID = req.params.id;
- if (req.body.gameType === GameType.Public) {
+ if (config.gameType === GameType.Public) {
log.info(`cannot update game ${lobbyID} to public`);
return res.status(400).json({ error: "Cannot update public game" });
}
@@ -161,18 +177,7 @@ export function startWorker() {
.status(400)
.json({ error: "Cannot update game after it has started" });
}
- game.updateGameConfig({
- gameMap: req.body.gameMap,
- difficulty: req.body.difficulty,
- infiniteGold: req.body.infiniteGold,
- infiniteTroops: req.body.infiniteTroops,
- instantBuild: req.body.instantBuild,
- bots: req.body.bots,
- disableNPCs: req.body.disableNPCs,
- disabledUnits: req.body.disabledUnits,
- gameMode: req.body.gameMode,
- playerTeams: req.body.playerTeams,
- });
+ game.updateGameConfig(config);
res.status(200).json({ success: true });
}),
);
@@ -241,13 +246,14 @@ export function startWorker() {
app.post(
"/api/archive_singleplayer_game",
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
- const gameRecord: GameRecord = req.body;
-
- if (!gameRecord) {
- log.info("game record not found in request");
- res.status(404).json({ error: "Game record not found" });
- return;
+ const result = GameRecordSchema.safeParse(req.body);
+ if (!result.success) {
+ const error = z.prettifyError(result.error);
+ log.info(error);
+ return res.status(400).json({ error });
}
+
+ const gameRecord: GameRecord = result.data;
archive(gameRecord);
res.json({
success: true,
@@ -287,11 +293,17 @@ export function startWorker() {
: forwarded || req.socket.remoteAddress || "unknown";
try {
- // Process WebSocket messages as in your original code
// Parse and handle client messages
- const clientMsg = ClientMessageSchema.parse(
+ const parsed = ClientJoinMessageSchema.safeParse(
JSON.parse(message.toString()),
);
+ if (!parsed.success) {
+ const error = z.prettifyError(parsed.error);
+ log.warn("Error parsing join message client", error);
+ ws.close();
+ return;
+ }
+ const clientMsg = parsed.data;
if (clientMsg.type === "join") {
// Verify this worker should handle this game
@@ -308,11 +320,26 @@ export function startWorker() {
config,
);
+ let roles: string[] | undefined;
+
+ // Check user roles
+ if (claims !== null) {
+ const result = await getUserMe(clientMsg.token, config);
+ if (result === false) {
+ log.warn("Token is not valid", claims);
+ return;
+ }
+ roles = result.player.roles;
+ }
+
+ // TODO: Validate client settings based on roles
+
// Create client and add to game
const client = new Client(
clientMsg.clientID,
persistentId,
- claims ?? null,
+ claims,
+ roles,
ip,
clientMsg.username,
ws,
diff --git a/src/server/jwt.ts b/src/server/jwt.ts
index 150402a5f..c7896bd91 100644
--- a/src/server/jwt.ts
+++ b/src/server/jwt.ts
@@ -1,5 +1,10 @@
import { jwtVerify } from "jose";
-import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
+import {
+ TokenPayload,
+ TokenPayloadSchema,
+ UserMeResponse,
+ UserMeResponseSchema,
+} from "../core/ApiSchemas";
import { ServerConfig } from "../core/configuration/Config";
type TokenVerificationResult = {
@@ -27,3 +32,31 @@ export async function verifyClientToken(
const persistentId = claims.sub;
return { persistentId, claims };
}
+
+export async function getUserMe(
+ token: string,
+ config: ServerConfig,
+): Promise {
+ try {
+ // Get the user object
+ const response = await fetch(config.jwtIssuer() + "/users/@me", {
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ });
+ if (response.status !== 200) return false;
+ const body = await response.json();
+ const result = UserMeResponseSchema.safeParse(body);
+ if (!result.success) {
+ console.error(
+ "Invalid response",
+ JSON.stringify(body),
+ JSON.stringify(result.error),
+ );
+ return false;
+ }
+ return result.data;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/startup.sh b/startup.sh
index 052c3b200..28903d067 100644
--- a/startup.sh
+++ b/startup.sh
@@ -1,110 +1,5 @@
#!/bin/bash
set -e
-
-# Check if required environment variables are set
-if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$SUBDOMAIN" ] || [ -z "$DOMAIN" ]; then
- echo "Error: Required environment variables not set"
- echo "Please set CF_API_TOKEN, CF_ACCOUNT_ID, SUBDOMAIN, and DOMAIN"
- exit 1
-fi
-
-# Generate a unique tunnel name using timestamp
-TIMESTAMP=$(date +%Y%m%d%H%M%S)
-TUNNEL_NAME="${SUBDOMAIN}-tunnel-${TIMESTAMP}"
-echo "Using unique tunnel name: ${TUNNEL_NAME}"
-
-# Create a new tunnel
-echo "Creating Cloudflare tunnel for subdomain ${SUBDOMAIN}..."
-TUNNEL_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json" \
- --data "{\"name\":\"${TUNNEL_NAME}\"}")
-
-# Extract tunnel ID and token
-TUNNEL_ID=$(echo $TUNNEL_RESPONSE | jq -r '.result.id')
-TUNNEL_TOKEN=$(echo $TUNNEL_RESPONSE | jq -r '.result.token')
-
-if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" == "null" ]; then
- echo "Failed to create tunnel"
- echo $TUNNEL_RESPONSE
- exit 1
-fi
-
-echo "Tunnel created with ID: ${TUNNEL_ID}"
-
-# Configure the tunnel with hostname
-echo "Configuring tunnel to point to ${SUBDOMAIN}.${DOMAIN}..."
-curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json" \
- --data "{\"config\":{\"ingress\":[{\"hostname\":\"${SUBDOMAIN}.${DOMAIN}\",\"service\":\"http://localhost:80\"},{\"service\":\"http_status:404\"}]}}"
-
-# Update DNS record to point to the new tunnel
-echo "Updating DNS record to point to the new tunnel..."
-
-# First check if DNS record exists
-DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN}" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json")
-
-ZONE_ID=$(echo $DNS_RECORDS | jq -r '.result[0].id')
-
-if [ -z "$ZONE_ID" ] || [ "$ZONE_ID" == "null" ]; then
- echo "Could not find zone ID for domain ${DOMAIN}"
- exit 1
-fi
-
-# Check for existing record
-EXISTING_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}.${DOMAIN}" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json")
-
-RECORD_ID=$(echo $EXISTING_RECORDS | jq -r '.result[0].id')
-
-# Create or update the DNS record
-if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ]; then
- # Create new record
- echo "Creating new DNS record..."
- DNS_RESPONSE=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json" \
- --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
-else
- # Update existing record
- echo "Updating existing DNS record..."
- DNS_RESPONSE=$(curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
- -H "Authorization: Bearer ${CF_API_TOKEN}" \
- -H "Content-Type: application/json" \
- --data "{\"type\":\"CNAME\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${TUNNEL_ID}.cfargotunnel.com\",\"ttl\":1,\"proxied\":true}")
-fi
-
-# Log the tunnel information
-echo "Tunnel is set up! Site will be available at: https://${SUBDOMAIN}.${DOMAIN}"
-
-# Export the tunnel token for supervisord
-export CLOUDFLARE_TUNNEL_TOKEN=${TUNNEL_TOKEN}
-
-# Check if Basic Auth credentials are set
-if [ -z "$BASIC_AUTH_USER" ] || [ -z "$BASIC_AUTH_PASS" ]; then
- echo "HTTP Basic Authentication will be disabled"
-else
- # Create the htpasswd file
- echo "Creating basic auth credentials for user: ${BASIC_AUTH_USER}"
- # Ensure apache2-utils is installed for htpasswd
- command -v htpasswd > /dev/null 2>&1 || {
- echo "htpasswd not found, installing apache2-utils..."
- apt-get update && apt-get install -y apache2-utils
- }
- # Create the password file
- htpasswd -bc /etc/nginx/.htpasswd ${BASIC_AUTH_USER} ${BASIC_AUTH_PASS}
-
- # Update Nginx configuration to enable Basic Auth
- sed -i '1i auth_basic "Restricted Access";' /etc/nginx/conf.d/default.conf
- sed -i '2i auth_basic_user_file /etc/nginx/.htpasswd;' /etc/nginx/conf.d/default.conf
-
- echo "HTTP Basic Authentication enabled for user: ${BASIC_AUTH_USER}"
-fi
-
# Start supervisord
if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then
exec timeout 18h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
diff --git a/supervisord.conf b/supervisord.conf
index 61b2aec3a..c31d0429c 100644
--- a/supervisord.conf
+++ b/supervisord.conf
@@ -22,11 +22,4 @@ user=node
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
-stderr_logfile_maxbytes=0
-
-[program:cloudflared]
-command=cloudflared tunnel run --token %(ENV_CLOUDFLARE_TUNNEL_TOKEN)s
-autostart=true
-autorestart=true
-stdout_logfile=/var/log/cloudflared.log
-stderr_logfile=/var/log/cloudflared-err.log
\ No newline at end of file
+stderr_logfile_maxbytes=0
\ No newline at end of file
diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts
index 81f3b49d7..0fcd709c1 100644
--- a/tests/Attack.test.ts
+++ b/tests/Attack.test.ts
@@ -21,7 +21,7 @@ let attackerSpawn: TileRef;
function sendBoat(target: TileRef, source: TileRef, troops: number) {
game.addExecution(
- new TransportShipExecution(defender.id(), null, target, troops, source),
+ new TransportShipExecution(defender, null, target, troops, source),
);
}
@@ -64,7 +64,9 @@ describe("Attack", () => {
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
- game.addExecution(new AttackExecution(100, defender.id(), null));
+ game.addExecution(
+ new AttackExecution(100, defender, game.terraNullius().id()),
+ );
game.executeNextTick();
while (defender.outgoingAttacks().length > 0) {
game.executeNextTick();
@@ -76,10 +78,10 @@ describe("Attack", () => {
test("Nuke reduce attacking troop counts", async () => {
// Not building exactly spawn to it's better protected from attacks (but still
// on defender territory)
- constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo);
+ constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
- game.addExecution(new AttackExecution(100, attacker.id(), defender.id()));
- constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3);
+ game.addExecution(new AttackExecution(100, attacker, defender.id()));
+ constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];
expect(nuke.isActive()).toBe(true);
@@ -94,12 +96,12 @@ describe("Attack", () => {
});
test("Nuke reduce attacking boat troop count", async () => {
- constructionExecution(game, defender.id(), 1, 1, UnitType.MissileSilo);
+ constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
- constructionExecution(game, defender.id(), 0, 15, UnitType.AtomBomb, 3);
+ constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];
expect(nuke.isActive()).toBe(true);
diff --git a/tests/BotBehavior.test.ts b/tests/BotBehavior.test.ts
new file mode 100644
index 000000000..99df34764
--- /dev/null
+++ b/tests/BotBehavior.test.ts
@@ -0,0 +1,150 @@
+import { BotBehavior } from "../src/core/execution/utils/BotBehavior";
+import {
+ AllianceRequest,
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ Tick,
+} from "../src/core/game/Game";
+import { PseudoRandom } from "../src/core/PseudoRandom";
+import { setup } from "./util/Setup";
+
+let game: Game;
+let player: Player;
+let requestor: Player;
+let botBehavior: BotBehavior;
+
+describe("BotBehavior.handleAllianceRequests", () => {
+ beforeEach(async () => {
+ game = await setup("BigPlains", { infiniteGold: true, instantBuild: true });
+
+ const playerInfo = new PlayerInfo(
+ "us",
+ "player_id",
+ PlayerType.Bot,
+ null,
+ "player_id",
+ );
+ const requestorInfo = new PlayerInfo(
+ "fr",
+ "requestor_id",
+ PlayerType.Human,
+ null,
+ "requestor_id",
+ );
+
+ game.addPlayer(playerInfo);
+ game.addPlayer(requestorInfo);
+
+ player = game.player("player_id");
+ requestor = game.player("requestor_id");
+
+ const random = new PseudoRandom(42);
+
+ botBehavior = new BotBehavior(random, game, player, 0.5, 0.5);
+ });
+
+ function setupAllianceRequest({
+ isTraitor = false,
+ relationDelta = 2,
+ numTilesPlayer = 10,
+ numTilesRequestor = 10,
+ alliancesCount = 0,
+ } = {}) {
+ if (isTraitor) requestor.markTraitor();
+
+ player.updateRelation(requestor, relationDelta);
+ requestor.updateRelation(player, relationDelta);
+
+ game.map().forEachTile((tile) => {
+ if (game.map().isLand(tile)) {
+ if (numTilesPlayer > 0) {
+ player.conquer(tile);
+ numTilesPlayer--;
+ } else if (numTilesRequestor > 0) {
+ requestor.conquer(tile);
+ numTilesRequestor--;
+ }
+ }
+ });
+
+ jest
+ .spyOn(requestor, "alliances")
+ .mockReturnValue(new Array(alliancesCount));
+
+ const mockRequest = {
+ requestor: () => requestor,
+ recipient: () => player,
+ createdAt: () => 0 as unknown as Tick,
+ accept: jest.fn(),
+ reject: jest.fn(),
+ } as unknown as AllianceRequest;
+
+ jest
+ .spyOn(player, "incomingAllianceRequests")
+ .mockReturnValue([mockRequest]);
+
+ return mockRequest;
+ }
+
+ test("should accept alliance when all conditions are met", () => {
+ const request = setupAllianceRequest({});
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).toHaveBeenCalled();
+ expect(request.reject).not.toHaveBeenCalled();
+ });
+
+ test("should reject alliance if requestor is a traitor", () => {
+ const request = setupAllianceRequest({ isTraitor: true });
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).not.toHaveBeenCalled();
+ expect(request.reject).toHaveBeenCalled();
+ });
+
+ test("should reject alliance if relation is malicious", () => {
+ const request = setupAllianceRequest({ relationDelta: -2 });
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).not.toHaveBeenCalled();
+ expect(request.reject).toHaveBeenCalled();
+ });
+
+ test("should accept alliance if requestor is much larger (> 3 times size of recipient) and has too many alliances (>= 3)", () => {
+ const request = setupAllianceRequest({
+ numTilesRequestor: 40,
+ alliancesCount: 4,
+ });
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).toHaveBeenCalled();
+ expect(request.reject).not.toHaveBeenCalled();
+ });
+
+ test("should accept alliance if requestor is much larger (> 3 times size of recipient) and does not have too many alliances (< 3)", () => {
+ const request = setupAllianceRequest({
+ numTilesRequestor: 40,
+ alliancesCount: 2,
+ });
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).toHaveBeenCalled();
+ expect(request.reject).not.toHaveBeenCalled();
+ });
+
+ test("should reject alliance if requestor is acceptably small (<= 3 times size of recipient) and has too many alliances (>= 3)", () => {
+ const request = setupAllianceRequest({ alliancesCount: 3 });
+
+ botBehavior.handleAllianceRequests();
+
+ expect(request.accept).not.toHaveBeenCalled();
+ expect(request.reject).toHaveBeenCalled();
+ });
+});
diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts
index a80f14ae8..978253262 100644
--- a/tests/Disconnected.test.ts
+++ b/tests/Disconnected.test.ts
@@ -159,7 +159,7 @@ describe("Disconnected", () => {
executeTicks(game, 1);
expect(player1.isDisconnected()).toBe(true);
});
-
+
test("Breaking alliance with disconnected player doesn't make you a traitor", () => {
player1.createAllianceRequest(player2);
player2
diff --git a/tests/MissileSilo.test.ts b/tests/MissileSilo.test.ts
index d5930680b..04d708546 100644
--- a/tests/MissileSilo.test.ts
+++ b/tests/MissileSilo.test.ts
@@ -20,7 +20,7 @@ function attackerBuildsNuke(
initialize = true,
) {
game.addExecution(
- new NukeExecution(UnitType.AtomBomb, attacker.id(), target, source),
+ new NukeExecution(UnitType.AtomBomb, attacker, target, source),
);
if (initialize) {
game.executeNextTick();
@@ -50,7 +50,7 @@ describe("MissileSilo", () => {
attacker = game.player("attacker_id");
- constructionExecution(game, attacker.id(), 1, 1, UnitType.MissileSilo);
+ constructionExecution(game, attacker, 1, 1, UnitType.MissileSilo);
});
test("missilesilo should launch nuke", async () => {
diff --git a/tests/SAM.test.ts b/tests/SAM.test.ts
index d8eb2d325..d3f209673 100644
--- a/tests/SAM.test.ts
+++ b/tests/SAM.test.ts
@@ -1,3 +1,4 @@
+import { NukeExecution } from "../src/core/execution/NukeExecution";
import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import {
@@ -13,10 +14,11 @@ import { constructionExecution, executeTicks } from "./util/utils";
let game: Game;
let attacker: Player;
let defender: Player;
+let far_defender: Player;
describe("SAM", () => {
beforeEach(async () => {
- game = await setup("Plains", { infiniteGold: true, instantBuild: true });
+ game = await setup("BigPlains", { infiniteGold: true, instantBuild: true });
const defender_info = new PlayerInfo(
"us",
"defender_id",
@@ -24,6 +26,13 @@ describe("SAM", () => {
null,
"defender_id",
);
+ const far_defender_info = new PlayerInfo(
+ "us",
+ "far_defender_id",
+ PlayerType.Human,
+ null,
+ "far_defender_id",
+ );
const attacker_info = new PlayerInfo(
"fr",
"attacker_id",
@@ -32,10 +41,15 @@ describe("SAM", () => {
"attacker_id",
);
game.addPlayer(defender_info);
+ game.addPlayer(far_defender_info);
game.addPlayer(attacker_info);
game.addExecution(
new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)),
+ new SpawnExecution(
+ game.player(far_defender_info.id).info(),
+ game.ref(199, 1),
+ ),
new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)),
);
@@ -43,16 +57,19 @@ describe("SAM", () => {
game.executeNextTick();
}
- defender = game.player("defender_id");
attacker = game.player("attacker_id");
+ defender = game.player("defender_id");
+ far_defender = game.player("far_defender_id");
- constructionExecution(game, attacker.id(), 7, 7, UnitType.MissileSilo);
+ constructionExecution(game, attacker, 7, 7, UnitType.MissileSilo);
});
test("one sam should take down one nuke", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
- game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
- attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {});
+ game.addExecution(new SAMLauncherExecution(defender, null, sam));
+ attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), {
+ targetTile: game.ref(2, 1),
+ });
executeTicks(game, 3);
@@ -61,7 +78,7 @@ describe("SAM", () => {
test("sam should only get one nuke at a time", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
- game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
+ game.addExecution(new SAMLauncherExecution(defender, null, sam));
attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), {
targetTile: game.ref(2, 1),
});
@@ -77,7 +94,7 @@ describe("SAM", () => {
test("sam should cooldown as long as configured", async () => {
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
- game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
+ game.addExecution(new SAMLauncherExecution(defender, null, sam));
expect(sam.isInCooldown()).toBeFalsy();
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
targetTile: game.ref(1, 2),
@@ -100,9 +117,9 @@ describe("SAM", () => {
const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {
cooldownDuration: 10,
});
- game.addExecution(new SAMLauncherExecution(defender.id(), null, sam1));
+ game.addExecution(new SAMLauncherExecution(defender, null, sam1));
const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {});
- game.addExecution(new SAMLauncherExecution(defender.id(), null, sam2));
+ game.addExecution(new SAMLauncherExecution(defender, null, sam2));
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), {
targetTile: game.ref(2, 2),
});
@@ -112,4 +129,36 @@ describe("SAM", () => {
expect(nuke.isActive()).toBeFalsy();
expect([sam1, sam2].filter((s) => s.isInCooldown())).toHaveLength(1);
});
+
+ test("SAMs should target only nukes aimed at nearby targets", async () => {
+ const targetDistance = 199;
+ // Close SAM: should not intercept anything
+ const sam1 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
+ game.addExecution(new SAMLauncherExecution(defender, null, sam1));
+
+ // Far SAM: Should intercept the nuke. Use the far_defender so the SAM can be built
+ const sam2 = far_defender.buildUnit(
+ UnitType.SAMLauncher,
+ game.ref(targetDistance, 1),
+ {},
+ );
+ game.addExecution(new SAMLauncherExecution(far_defender, null, sam2));
+
+ const nukeExecution = new NukeExecution(
+ UnitType.AtomBomb,
+ attacker,
+ game.ref(targetDistance, 1),
+ null,
+ );
+ game.addExecution(nukeExecution);
+ // Long distance nuke: compute the proper number of ticks
+ const ticksToExecute = Math.ceil(
+ targetDistance / game.config().defaultNukeSpeed(),
+ );
+ executeTicks(game, ticksToExecute);
+
+ expect(nukeExecution.isActive()).toBeFalsy();
+ expect(sam1.isInCooldown()).toBeFalsy();
+ expect(sam2.isInCooldown()).toBeTruthy();
+ });
});
diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts
index 4c1f76eb4..b6f6f8442 100644
--- a/tests/util/TestServerConfig.ts
+++ b/tests/util/TestServerConfig.ts
@@ -4,6 +4,21 @@ import { GameMapType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
+ cloudflareConfigDir(): string {
+ throw new Error("Method not implemented.");
+ }
+ domain(): string {
+ throw new Error("Method not implemented.");
+ }
+ subdomain(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareAccountId(): string {
+ throw new Error("Method not implemented.");
+ }
+ cloudflareApiToken(): string {
+ throw new Error("Method not implemented.");
+ }
jwtAudience(): string {
throw new Error("Method not implemented.");
}
diff --git a/tests/util/utils.ts b/tests/util/utils.ts
index aa1b7a05d..921e5d102 100644
--- a/tests/util/utils.ts
+++ b/tests/util/utils.ts
@@ -4,18 +4,18 @@
// If you also need execution use function below. Does not work with things not
import { ConstructionExecution } from "../../src/core/execution/ConstructionExecution";
-import { Game, PlayerID, UnitType } from "../../src/core/game/Game";
+import { Game, Player, UnitType } from "../../src/core/game/Game";
// built via UI (e.g.: trade ships)
export function constructionExecution(
game: Game,
- playerID: PlayerID,
+ _owner: Player,
x: number,
y: number,
unit: UnitType,
ticks = 4,
) {
- game.addExecution(new ConstructionExecution(playerID, game.ref(x, y), unit));
+ game.addExecution(new ConstructionExecution(_owner, game.ref(x, y), unit));
// 4 ticks by default as it usually goes like this
// Init of construction execution
diff --git a/update.sh b/update.sh
index 91c516346..761c353f9 100755
--- a/update.sh
+++ b/update.sh
@@ -2,12 +2,25 @@
# update.sh - Script to update Docker container on Hetzner server
# Called by deploy.sh after uploading Docker image to Docker Hub
-# Load environment variables if .env exists
-if [ -f /home/openfront/.env ]; then
- echo "Loading environment variables from .env file..."
- export $(grep -v '^#' /home/openfront/.env | xargs)
+# Check if environment file is provided
+if [ $# -ne 1 ]; then
+ echo "Error: Environment file path is required"
+ echo "Usage: $0 "
+ exit 1
fi
+ENV_FILE="$1"
+
+# Check if environment file exists
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Error: Environment file '$ENV_FILE' not found"
+ exit 1
+fi
+
+# Load environment variables from the provided file
+echo "Loading environment variables from $ENV_FILE..."
+export $(grep -v '^#' "$ENV_FILE" | xargs)
+
echo "======================================================"
echo "๐ UPDATING SERVER: ${HOST} ENVIRONMENT"
echo "======================================================"
@@ -47,7 +60,7 @@ fi
echo "Starting new container for ${HOST} environment..."
docker run -d \
--restart="${RESTART}" \
- --env-file /home/openfront/.env \
+ --env-file "$ENV_FILE" \
--name "${CONTAINER_NAME}" \
"${DOCKER_IMAGE}"
@@ -60,6 +73,11 @@ if [ $? -eq 0 ]; then
docker image prune -a -f
docker container prune -f
echo "Cleanup complete."
+
+ # Remove the environment file
+ echo "Removing environment file ${ENV_FILE}..."
+ rm -f "$ENV_FILE"
+ echo "Environment file removed."
else
echo "Failed to start container"
exit 1
diff --git a/webpack.config.js b/webpack.config.js
index 4b4096141..3f6d28936 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,3 +1,4 @@
+import { execSync } from "child_process";
import CopyPlugin from "copy-webpack-plugin";
import ESLintPlugin from "eslint-webpack-plugin";
import HtmlWebpackPlugin from "html-webpack-plugin";
@@ -8,6 +9,9 @@ import webpack from "webpack";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
+const gitCommit =
+ process.env.GIT_COMMIT ?? execSync("git rev-parse HEAD").toString().trim();
+
export default async (env, argv) => {
const isProduction = argv.mode === "production";
@@ -116,9 +120,8 @@ export default async (env, argv) => {
"process.env.WEBSOCKET_URL": JSON.stringify(
isProduction ? "" : "localhost:3000",
),
- }),
- new webpack.DefinePlugin({
"process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"),
+ "process.env.GIT_COMMIT": JSON.stringify(gitCommit),
}),
new CopyPlugin({
patterns: [