diff --git a/resources/lang/en.json b/resources/lang/en.json
index bf2eb1cf6d..6806fd1071 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -98,6 +98,8 @@
"warship": "Captures trade ships, destroys ships and boats"
},
"not_enough_money": "Not enough money",
+ "select_upgrade_amount": "Select Upgrade Amount",
+ "upgrade_amount": "x{amount}",
"warship_shift_hint": "Hold Shift and drag to select multiple warships at once"
},
"chat": {
@@ -1339,7 +1341,8 @@
},
"radial_menu": {
"delete_unit_description": "Click to delete the nearest unit",
- "delete_unit_title": "Delete Unit"
+ "delete_unit_title": "Delete Unit",
+ "upgrade_x": "Upgrade x{amount}"
},
"relation": {
"default": "Default",
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index bcd99d26ca..2b4c6c0478 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -927,7 +927,18 @@ export class InputHandler {
}
private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) {
- this.uiState.ghostStructure = ghostStructure;
+ if (
+ this.uiState.ghostStructure === ghostStructure &&
+ ghostStructure !== null
+ ) {
+ const multipliers = [1, 5, 10, 25, 50];
+ const idx = multipliers.indexOf(this.uiState.upgradeMultiplier || 1);
+ this.uiState.upgradeMultiplier =
+ multipliers[(idx + 1) % multipliers.length];
+ } else {
+ this.uiState.upgradeMultiplier = 1;
+ this.uiState.ghostStructure = ghostStructure;
+ }
}
/**
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index f57e761c9f..6fdf8eecb2 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -57,6 +57,7 @@ export class SendUpgradeStructureIntentEvent implements GameEvent {
constructor(
public readonly unitId: number,
public readonly unitType: UnitType,
+ public readonly amount: number = 1,
) {}
}
@@ -518,6 +519,7 @@ export class Transport {
type: "upgrade_structure",
unit: event.unitType,
unitId: event.unitId,
+ amount: event.amount,
});
}
diff --git a/src/client/UIState.ts b/src/client/UIState.ts
index 90b8194fd5..2bb2307129 100644
--- a/src/client/UIState.ts
+++ b/src/client/UIState.ts
@@ -4,4 +4,5 @@ export interface UIState {
attackRatio: number;
ghostStructure: PlayerBuildableUnitType | null;
rocketDirectionUp: boolean;
+ upgradeMultiplier: number;
}
diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts
index cba6cb3972..be50a28d01 100644
--- a/src/client/controllers/BuildPreviewController.ts
+++ b/src/client/controllers/BuildPreviewController.ts
@@ -457,7 +457,9 @@ export class BuildPreviewController implements Controller {
radiusTileY = this.game.y(upgradeTargetTile);
}
- const cost = u.cost;
+ const multiplier =
+ u.canUpgrade !== false ? this.uiState.upgradeMultiplier || 1 : 1;
+ const cost = u.cost * BigInt(multiplier);
return {
ghostType: u.type,
tileX: this.game.x(tileRef),
@@ -467,6 +469,7 @@ export class BuildPreviewController implements Controller {
canBuild: u.canBuild !== false,
canUpgrade: u.canUpgrade !== false,
cost: Number(cost),
+ multiplier: multiplier,
showCost: this.userSettings.cursorCostLabel(),
canAfford: myPlayer.gold() >= cost,
ghostRailPaths: u.ghostRailPaths,
@@ -508,6 +511,7 @@ export class BuildPreviewController implements Controller {
new SendUpgradeStructureIntentEvent(
this.ghostUnit.buildableUnit.canUpgrade,
this.ghostUnit.buildableUnit.type,
+ this.uiState.upgradeMultiplier || 1,
),
);
this.removeGhostStructure();
diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts
index dc1f92b64b..1550886a92 100644
--- a/src/client/hud/GameRenderer.ts
+++ b/src/client/hud/GameRenderer.ts
@@ -59,6 +59,7 @@ export function createRenderer(
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
+ upgradeMultiplier: 1,
};
//hide when the game renders
diff --git a/src/client/hud/layers/BuildMenu.ts b/src/client/hud/layers/BuildMenu.ts
index 0094f3ec30..edfe4fed1d 100644
--- a/src/client/hud/layers/BuildMenu.ts
+++ b/src/client/hud/layers/BuildMenu.ts
@@ -356,6 +356,9 @@ export class BuildMenu extends LitElement implements Controller {
@state()
private _hidden = true;
+ @state()
+ private _selectedUpgradeUnitType: UnitType | null = null;
+
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() === null || this.playerBuildables === null) {
return false;
@@ -382,14 +385,10 @@ export class BuildMenu extends LitElement implements Controller {
return player.totalUnitLevels(item.unitType).toString();
}
- public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
+ public handleBuildClick(buildableUnit: BuildableUnit, tile: TileRef): void {
if (buildableUnit.canUpgrade !== false) {
- this.eventBus.emit(
- new SendUpgradeStructureIntentEvent(
- buildableUnit.canUpgrade,
- buildableUnit.type,
- ),
- );
+ this._selectedUpgradeUnitType = buildableUnit.type;
+ this.requestUpdate();
} else if (buildableUnit.canBuild) {
const rocketDirectionUp =
buildableUnit.type === UnitType.AtomBomb ||
@@ -399,81 +398,168 @@ export class BuildMenu extends LitElement implements Controller {
this.eventBus.emit(
new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp),
);
+ this.hideMenu();
+ }
+ }
+
+ public confirmUpgrade(amount: number): void {
+ if (!this._selectedUpgradeUnitType) {
+ this.hideMenu();
+ return;
}
+ const bu = this.playerBuildables?.find(
+ (u) => u.type === this._selectedUpgradeUnitType,
+ );
+ if (!bu || bu.canUpgrade === false) {
+ this.hideMenu();
+ return;
+ }
+ this.eventBus.emit(
+ new SendUpgradeStructureIntentEvent(bu.canUpgrade, bu.type, amount),
+ );
this.hideMenu();
}
+ renderAmountPanel() {
+ if (!this._selectedUpgradeUnitType) return html``;
+ const bu = this.playerBuildables?.find(
+ (u) => u.type === this._selectedUpgradeUnitType,
+ );
+ if (!bu) return html``;
+ const baseCost = bu.cost;
+ const playerGold = this.game?.myPlayer()?.gold() ?? 0n;
+
+ return html`
+
+
+ ${translateText("build_menu.select_upgrade_amount")}
+
+
+ ${[1, 5, 10, 25, 50].map((amount) => {
+ const cost = baseCost * BigInt(amount);
+ const canAfford = playerGold >= cost;
+ return html`
+
+ `;
+ })}
+
+
+ `;
+ }
+
render() {
return html`
`;
}
hideMenu() {
this._hidden = true;
+ this._selectedUpgradeUnitType = null;
this.requestUpdate();
}
diff --git a/src/client/hud/layers/RadialMenuElements.ts b/src/client/hud/layers/RadialMenuElements.ts
index f376c46819..f852298ef7 100644
--- a/src/client/hud/layers/RadialMenuElements.ts
+++ b/src/client/hud/layers/RadialMenuElements.ts
@@ -21,6 +21,7 @@ import { PlayerPanel } from "./PlayerPanel";
import { TooltipItem } from "./RadialMenu";
import { EventBus } from "../../../core/EventBus";
+import { SendUpgradeStructureIntentEvent } from "../../Transport";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
const boatIcon = assetUrl("images/BoatIconWhite.svg");
const buildIcon = assetUrl("images/BuildIconWhite.svg");
@@ -86,6 +87,7 @@ export const COLORS = {
build: "#e6c74a",
building: "#1e3a5f",
boat: "#2a82c9",
+ disabled: "#94a3b8",
ally: "#4ade80",
breakAlly: "#dc2626",
breakAllyNoDebuff: "#d97706",
@@ -450,6 +452,60 @@ function createMenuElements(
].filter(
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
),
+ subMenu: (params: MenuElementParams) => {
+ const buildableUnit = params.playerActions.buildableUnits.find(
+ (bu) => bu.type === item.unitType,
+ );
+ if (
+ !buildableUnit ||
+ buildableUnit.canUpgrade === false ||
+ !params.buildMenu.canBuildOrUpgrade(item)
+ ) {
+ return [];
+ }
+ return [1, 5, 10, 25, 50].map((amount) => {
+ const cost = buildableUnit.cost * BigInt(amount);
+ return {
+ id: `upgrade_${item.unitType}_${amount}`,
+ name: translateText("build_menu.upgrade_amount", {
+ amount: amount.toString(),
+ }),
+ text: translateText("build_menu.upgrade_amount", {
+ amount: amount.toString(),
+ }),
+ fontSize: "20px",
+ color: (p: MenuElementParams) =>
+ (p.game.myPlayer()?.gold() ?? 0n) >= cost
+ ? COLORS.building
+ : COLORS.disabled,
+ icon: "",
+ tooltipItems: [
+ {
+ text: translateText("radial_menu.upgrade_x", {
+ amount: amount.toString(),
+ }),
+ className: "title",
+ },
+ {
+ text: `${renderNumber(cost)} ${translateText("player_panel.gold")}`,
+ className: "cost",
+ },
+ ],
+ disabled: (p: MenuElementParams) =>
+ (p.game.myPlayer()?.gold() ?? 0n) < cost,
+ action: (p: MenuElementParams) => {
+ p.eventBus.emit(
+ new SendUpgradeStructureIntentEvent(
+ buildableUnit.canUpgrade as number,
+ buildableUnit.type,
+ amount,
+ ),
+ );
+ p.closeMenu();
+ },
+ };
+ });
+ },
action: (params: MenuElementParams) => {
const buildableUnit = params.playerActions.buildableUnits.find(
(bu) => bu.type === item.unitType,
@@ -458,9 +514,11 @@ function createMenuElements(
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
- params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
+ params.buildMenu.handleBuildClick(buildableUnit, params.tile);
+ params.closeMenu();
+ } else {
+ params.closeMenu();
}
- params.closeMenu();
},
};
});
diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts
index 7309139b1e..581fc00fb3 100644
--- a/src/client/render/gl/Renderer.ts
+++ b/src/client/render/gl/Renderer.ts
@@ -10,6 +10,7 @@
*/
import type { Config } from "../../../core/configuration/Config";
+import { translateText } from "../../Utils";
import type { SpiralRibbon } from "../frame/SpiralTrails";
import type {
AttackRingInput,
@@ -986,6 +987,12 @@ export class GPURenderer {
cost: data.cost,
canAfford: data.canAfford,
canPlace: data.canBuild || data.canUpgrade,
+ topText:
+ data.multiplier && data.multiplier > 1
+ ? translateText("build_menu.upgrade_amount", {
+ amount: data.multiplier.toString(),
+ })
+ : undefined,
}
: null,
);
diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts
index 6227f69e96..8748dd5e5f 100644
--- a/src/client/render/gl/passes/WorldTextPass.ts
+++ b/src/client/render/gl/passes/WorldTextPass.ts
@@ -131,6 +131,7 @@ export class WorldTextPass {
x: number;
y: number;
text: string;
+ topText?: string;
colorR: number;
colorG: number;
colorB: number;
@@ -331,6 +332,7 @@ export class WorldTextPass {
cost: number;
canAfford: boolean;
canPlace: boolean;
+ topText?: string;
} | null,
): void {
if (label === null) {
@@ -349,6 +351,7 @@ export class WorldTextPass {
g = 0.6;
b = 0.6;
}
+
// The vertex shader adds +0.5 to (x, y) for tile-center alignment, so we
// pass raw tile coords here — same convention as the other popup entries.
// Y offset is applied in rebuildInstances (zoom-relative).
@@ -356,6 +359,7 @@ export class WorldTextPass {
x: label.tileX,
y: label.tileY,
text: renderNumber(label.cost),
+ topText: label.topText,
colorR: r,
colorG: g,
colorB: b,
@@ -486,6 +490,36 @@ export class WorldTextPass {
const ghostScale = this.settings.ghostCost.screenScale * dpr * invZoom;
const ghostY =
label.y + this.settings.ghostCost.screenYOffset * dpr * invZoom;
+
+ if (label.topText) {
+ const topY = label.y - 30 * dpr * invZoom;
+ layoutString(
+ label.topText,
+ this.glyph,
+ this.kernTable,
+ this.charCodes,
+ this.cursors,
+ );
+ const len = Math.min(label.topText.length, MAX_CHARS);
+ for (let i = 0; i < len; i++) {
+ if (this.charCodes[i] === 0) continue;
+ if (count >= this.maxInstances) this.growBuffer();
+
+ const off = count * FLOATS_PER_INSTANCE;
+ this.instanceData[off + 0] = label.x;
+ this.instanceData[off + 1] = topY;
+ this.instanceData[off + 2] = this.cursors[i];
+ this.instanceData[off + 3] = this.charCodes[i];
+ this.instanceData[off + 4] = 1;
+ this.instanceData[off + 5] = label.colorR;
+ this.instanceData[off + 6] = label.colorG;
+ this.instanceData[off + 7] = label.colorB;
+ this.instanceData[off + 8] = ghostScale;
+ this.instanceData[off + 9] = GHOST_COST_OUTLINE_WIDTH;
+ count++;
+ }
+ }
+
layoutString(
label.text,
this.glyph,
diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts
index 12f5c35342..4f65809707 100644
--- a/src/client/render/types/Renderer.ts
+++ b/src/client/render/types/Renderer.ts
@@ -215,6 +215,7 @@ export interface GhostPreviewData {
canBuild: boolean; // Valid placement?
canUpgrade: boolean; // Upgrading existing structure?
cost: number; // Gold cost
+ multiplier?: number; // Upgrade multiplier (e.g., 5 for x5)
/** Whether to render the cost label under the ghost (user setting). */
showCost: boolean;
/** True if the player has enough gold to afford this build (drives label color). */
diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts
index 5faafeaa12..302c65b09c 100644
--- a/src/client/view/PlayerView.ts
+++ b/src/client/view/PlayerView.ts
@@ -412,6 +412,14 @@ export class PlayerView {
return owned.filter((u) => types.includes(u.type()));
}
+ unitsOwned(type: UnitType): number {
+ return this.units(type).length;
+ }
+
+ unitsConstructed(type: UnitType): number {
+ return this.units(type).filter((u) => !u.isUnderConstruction()).length;
+ }
+
nameLocation(): NameViewData | undefined {
return this.nameData;
}
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index f47dfa1961..e7184ed212 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -520,6 +520,7 @@ export const UpgradeStructureIntentSchema = z.object({
type: z.literal("upgrade_structure"),
unit: z.enum(UnitType),
unitId: z.number(),
+ amount: z.number().int().min(1).max(50).optional(),
});
export const CancelAttackIntentSchema = z.object({
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index ccdb792d69..f9c2260f78 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -107,7 +107,11 @@ export class Executor {
}
case "upgrade_structure":
- return new UpgradeStructureExecution(player, intent.unitId);
+ return new UpgradeStructureExecution(
+ player,
+ intent.unitId,
+ intent.amount,
+ );
case "delete_unit":
return new DeleteUnitExecution(player, intent.unitId);
case "quick_chat":
diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts
index bc1a58c0da..92c8e95c18 100644
--- a/src/core/execution/UpgradeStructureExecution.ts
+++ b/src/core/execution/UpgradeStructureExecution.ts
@@ -7,6 +7,7 @@ export class UpgradeStructureExecution implements Execution {
constructor(
private player: Player,
private unitId: number,
+ private amount: number = 1,
) {}
init(mg: Game, ticks: number): void {
@@ -21,13 +22,17 @@ export class UpgradeStructureExecution implements Execution {
return;
}
- if (!this.player.canUpgradeUnit(this.structure)) {
- console.warn(
- `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`,
- );
- return;
+ for (let i = 0; i < this.amount; i++) {
+ if (!this.player.canUpgradeUnit(this.structure)) {
+ if (i === 0) {
+ console.warn(
+ `[UpgradeStructureExecution] unit type ${this.structure.type()} cannot be upgraded`,
+ );
+ }
+ break;
+ }
+ this.player.upgradeUnit(this.structure);
}
- this.player.upgradeUnit(this.structure);
return;
}
diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts
index 3bf21fee78..440b810b7e 100644
--- a/tests/InputHandler.test.ts
+++ b/tests/InputHandler.test.ts
@@ -65,6 +65,7 @@ describe("InputHandler AutoUpgrade", () => {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
+ upgradeMultiplier: 1,
},
mockCanvas,
eventBus,
diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts
index 649c6fc50d..95bd75e255 100644
--- a/tests/client/graphics/RadialMenuElements.test.ts
+++ b/tests/client/graphics/RadialMenuElements.test.ts
@@ -115,6 +115,7 @@ describe("RadialMenuElements", () => {
cost: vi.fn(() => 100),
count: vi.fn(() => 5),
sendBuildOrUpgrade: vi.fn(),
+ handleBuildClick: vi.fn(),
};
mockPlayerActions = {
@@ -470,7 +471,7 @@ describe("RadialMenuElements", () => {
if (cityElement!.action) {
cityElement!.action(mockParams);
- expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
+ expect(mockBuildMenu.handleBuildClick).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
@@ -493,7 +494,7 @@ describe("RadialMenuElements", () => {
if (atomBombElement!.action) {
atomBombElement!.action(mockParams);
- expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
+ expect(mockBuildMenu.handleBuildClick).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
diff --git a/tests/core/executions/UpgradeStructureExecution.test.ts b/tests/core/executions/UpgradeStructureExecution.test.ts
new file mode 100644
index 0000000000..8557f46d1e
--- /dev/null
+++ b/tests/core/executions/UpgradeStructureExecution.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+import { UpgradeStructureExecution } from "../../../src/core/execution/UpgradeStructureExecution";
+import {
+ GameType,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../../../src/core/game/Game";
+import type { TileRef } from "../../../src/core/game/GameMap";
+import { setup } from "../../util/Setup";
+
+describe("UpgradeStructureExecution", () => {
+ it("upgrades a structure the specified amount of times", async () => {
+ const game = await setup(
+ "ocean_and_land",
+ { gameType: GameType.Singleplayer, instantBuild: true },
+ [],
+ undefined,
+ undefined,
+ false,
+ );
+ const playerInfo = new PlayerInfo(
+ "player1",
+ PlayerType.Human,
+ null,
+ "player1_id",
+ );
+ game.addPlayer(playerInfo);
+ const player = game.player("player1_id")!;
+
+ let landTile: TileRef | undefined = undefined;
+ for (let y = 0; y < game.map().height(); y++) {
+ for (let x = 0; x < game.map().width(); x++) {
+ const t = game.ref(x, y);
+ if (game.isLand(t)) {
+ landTile = t;
+ break;
+ }
+ }
+ if (landTile !== undefined) break;
+ }
+
+ player.conquer(landTile!);
+ const city = player.buildUnit(UnitType.City, landTile!, {});
+
+ game.endSpawnPhase();
+
+ player.addGold(10_000_000n);
+
+ expect(city.level()).toBe(1);
+
+ const execution = new UpgradeStructureExecution(player, city.id(), 5);
+ game.addExecution(execution);
+ game.executeNextTick();
+
+ expect(city.level()).toBe(6);
+ });
+
+ it("stops upgrading early if player cannot afford remaining amounts", async () => {
+ const game = await setup(
+ "ocean_and_land",
+ { gameType: GameType.Singleplayer, instantBuild: true },
+ [],
+ undefined,
+ undefined,
+ false,
+ );
+ const playerInfo = new PlayerInfo(
+ "player1",
+ PlayerType.Human,
+ null,
+ "player1_id",
+ );
+ game.addPlayer(playerInfo);
+ const player = game.player("player1_id")!;
+
+ let landTile: TileRef | undefined = undefined;
+ for (let y = 0; y < game.map().height(); y++) {
+ for (let x = 0; x < game.map().width(); x++) {
+ const t = game.ref(x, y);
+ if (game.isLand(t)) {
+ landTile = t;
+ break;
+ }
+ }
+ if (landTile !== undefined) break;
+ }
+
+ player.conquer(landTile!);
+ const city = player.buildUnit(UnitType.City, landTile!, {});
+
+ game.endSpawnPhase();
+
+ player.addGold(750_000n);
+
+ expect(city.level()).toBe(1);
+
+ const execution = new UpgradeStructureExecution(player, city.id(), 5);
+ game.addExecution(execution);
+ game.executeNextTick();
+
+ expect(city.level()).toBe(3);
+ });
+});