${unitTypes.map((unitType) => {
const iconSrc = unitIconMap[unitType];
if (!iconSrc) return null;
- // Use unitsOwned for upgraded structures (City, Port, Hospital, Academy)
- // so counts reflect summed levels + constructions, consistent with server.
+ // Use unitsOwned for all stackable structures
+ // so counts reflect summed stack counts + constructions, consistent with server.
const count =
unitType === UnitType.City ||
unitType === UnitType.Port ||
unitType === UnitType.Hospital ||
unitType === UnitType.Academy ||
unitType === UnitType.ResearchLab ||
- unitType === UnitType.Factory
+ unitType === UnitType.Factory ||
+ unitType === UnitType.SAMLauncher ||
+ unitType === UnitType.Airfield ||
+ unitType === UnitType.MissileSilo
? player.unitsOwned(unitType)
: player.units(unitType).length;
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts
index 5c21d0952..37bb1f3db 100644
--- a/src/client/graphics/layers/RadialMenu.ts
+++ b/src/client/graphics/layers/RadialMenu.ts
@@ -2,9 +2,9 @@ import * as d3 from "d3";
import doveIcon from "../../../../proprietary/images/dove.png";
import warIcon from "../../../../proprietary/images/waricon.png";
import airAttackIcon from "../../../../resources/images/AirAttackIconWhite.svg";
+import airfieldIcon from "../../../../resources/images/AirfieldIcon.svg";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import boatIcon from "../../../../resources/images/BoatIconWhite.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";
@@ -28,6 +28,7 @@ import {
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
+ SendBomberIntentEvent,
SendBreakAllianceIntentEvent,
SendDeclareWarIntentEvent,
SendParatrooperAttackIntentEvent,
@@ -47,6 +48,7 @@ enum Slot {
Ally,
Peace,
AirAttack,
+ Bomber,
}
export class RadialMenu implements Layer {
@@ -74,7 +76,7 @@ export class RadialMenu implements Layer {
disabled: true,
action: () => {},
color: null,
- icon: null,
+ icon: boatIcon,
},
],
[
@@ -84,7 +86,17 @@ export class RadialMenu implements Layer {
disabled: true,
action: () => {},
color: null,
- icon: null,
+ icon: airAttackIcon,
+ },
+ ],
+ [
+ Slot.Bomber,
+ {
+ name: "bomber",
+ disabled: true,
+ action: () => {},
+ color: null,
+ icon: airfieldIcon,
},
],
[
@@ -94,10 +106,19 @@ export class RadialMenu implements Layer {
disabled: true,
action: () => {},
color: null,
- icon: null,
+ icon: infoIcon,
+ },
+ ],
+ [
+ Slot.Ally,
+ {
+ name: "ally",
+ disabled: true,
+ action: () => {},
+ color: null,
+ icon: allianceIcon,
},
],
- [Slot.Ally, { name: "ally", disabled: true, action: () => {} }],
[
Slot.Peace,
{
@@ -116,7 +137,14 @@ export class RadialMenu implements Layer {
private readonly centerButtonSize = 30;
private readonly iconSize = 32;
private readonly centerIconSize = 48;
- private readonly disabledColor = d3.rgb(128, 128, 128).toString();
+ // When disabled, keep the hue but darken it slightly so the icon stays visible.
+ private darkenColor(color: string): string {
+ const parsed = d3.color(color);
+ if (!parsed) return d3.rgb(80, 80, 80).toString();
+ // darker(0.6) keeps the original hue while making it visibly inactive
+ const darker = (parsed as any).darker?.(0.6);
+ return (darker ?? parsed).toString();
+ }
// Scale factor specifically for the Peace (dove) icon relative to iconSize
private readonly peaceIconScale = 1.2;
@@ -189,12 +217,14 @@ export class RadialMenu implements Layer {
.append("path")
.attr("d", arc)
.attr("fill", (d) =>
- d.data.disabled ? this.disabledColor : d.data.color,
+ d.data.disabled
+ ? this.darkenColor(d.data.color ?? "#444")
+ : 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))
+ .style("opacity", (d) => (d.data.disabled ? 0.7 : 1))
.attr("data-name", (d) => d.data.name)
.on("mouseover", function (event, d) {
if (!d.data.disabled) {
@@ -476,13 +506,72 @@ export class RadialMenu implements Layer {
});
}
+ if (this.shouldShowBomber(myPlayer, tile)) {
+ this.activateMenuElement(Slot.Bomber, "#FF6B35", airfieldIcon, () => {
+ if (this.clickedCell === null) return;
+ const targetPlayer = this.g.owner(tile) as PlayerView;
+ // Target all structure types with closest-first priority
+ const allStructures = [
+ UnitType.City,
+ UnitType.DefensePost,
+ UnitType.SAMLauncher,
+ UnitType.MissileSilo,
+ UnitType.Port,
+ UnitType.Airfield,
+ UnitType.Hospital,
+ UnitType.Academy,
+ UnitType.ResearchLab,
+ UnitType.Factory,
+ UnitType.DoomsdayDevice,
+ ];
+ this.eventBus.emit(
+ new SendBomberIntentEvent(targetPlayer.id(), allStructures, true),
+ );
+ });
+ }
+
if (!this.g.hasOwner(tile)) {
return;
}
}
+ private shouldShowBomber(player: PlayerView, tile: TileRef): boolean {
+ // Check if player has at least one active airfield
+ if (player.units(UnitType.Airfield).length === 0) {
+ return false;
+ }
+ // Check if tile is land
+ if (!this.g.isLand(tile)) {
+ return false;
+ }
+ // Check if tile is owned by an enemy
+ const owner = this.g.owner(tile);
+ if (owner === player || !owner.isPlayer()) {
+ return false;
+ }
+ // Check if player is at war with owner
+ if (!player.isAtWarWith?.(owner as PlayerView)) {
+ return false;
+ }
+ // Check if any airfield can reach this tile
+ const airfields = player.units(UnitType.Airfield);
+ for (const airfield of airfields) {
+ if (!airfield.isActive()) continue;
+ const range = this.g
+ .config()
+ .bomberTargetRange(airfield.bomberLevel?.() ?? 1);
+ const dist = Math.sqrt(
+ this.g.euclideanDistSquared(airfield.tile(), tile),
+ );
+ if (dist <= range) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private shouldShowAirAttack(player: PlayerView, tile: TileRef): boolean {
- if (!player.hasUpgrade(UpgradeType.AirUpgrade1)) {
+ if (!player.hasUpgrade(UpgradeType.JetEngines)) {
return false;
}
if (player.units(UnitType.Airfield).length === 0) {
@@ -616,14 +705,17 @@ export class RadialMenu implements Layer {
private updateMenuItemState(item: any) {
const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`);
menuItem
- .attr("fill", item.disabled ? this.disabledColor : item.color)
+ .attr(
+ "fill",
+ item.disabled ? this.darkenColor(item.color ?? "#444") : item.color,
+ )
.style("cursor", item.disabled ? "not-allowed" : "pointer")
- .style("opacity", item.disabled ? 0.5 : 1);
+ .style("opacity", item.disabled ? 0.7 : 1);
this.menuElement
.select(`image[data-name="${item.name}"]`)
- .attr("xlink:href", item.disabled ? disabledIcon : item.icon)
- .attr("fill", item.disabled ? "#999999" : "white");
+ .attr("xlink:href", item.icon)
+ .style("opacity", item.disabled ? 0.7 : 1);
}
private onCenterButtonHover(isHovering: boolean) {
diff --git a/src/client/graphics/layers/RangeOverlayLayer.ts b/src/client/graphics/layers/RangeOverlayLayer.ts
index 246422be8..6172bb2a8 100644
--- a/src/client/graphics/layers/RangeOverlayLayer.ts
+++ b/src/client/graphics/layers/RangeOverlayLayer.ts
@@ -3,6 +3,7 @@ import { EventBus } from "../../../core/EventBus";
import { Cell, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
+import { playerMaxStructureTechLevel } from "../../../core/game/Upgradeables";
import { MouseOverEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
@@ -404,7 +405,8 @@ export class RangeOverlayLayer implements Layer {
if (u.type() === UnitType.SAMLauncher) {
const base = this.game.config().defaultSamRange();
const bonus = this.game.config().samRangeUpgradePercent();
- const lvl = u.level();
+ // Use player's SAM tech level, not unit level (which is stack count)
+ const lvl = playerMaxStructureTechLevel(u.owner(), UnitType.SAMLauncher);
if (lvl <= 1) return base;
const factor = Math.pow(1 + bonus, lvl - 1);
return Math.round(base * factor);
diff --git a/src/client/graphics/layers/ResearchToggleButton.ts b/src/client/graphics/layers/ResearchToggleButton.ts
index 5d751e34e..b1ae56d3b 100644
--- a/src/client/graphics/layers/ResearchToggleButton.ts
+++ b/src/client/graphics/layers/ResearchToggleButton.ts
@@ -19,9 +19,6 @@ export class ResearchToggleButton extends LitElement implements Layer {
@state()
private _isModalOpen = false;
- @state()
- private _hasUnseenPolicyDirectives = false;
-
private modalRef: ResearchTreeModal | null = null;
createRenderRoot() {
@@ -42,12 +39,6 @@ export class ResearchToggleButton extends LitElement implements Layer {
this._isVisible = shouldShow;
this.requestUpdate();
}
- // Check for unseen policy directives
- const hasUnseen = player?.hasUnseenPolicyDirectives?.() ?? false;
- if (hasUnseen !== this._hasUnseenPolicyDirectives) {
- this._hasUnseenPolicyDirectives = hasUnseen;
- this.requestUpdate();
- }
this.updateModalState();
}
@@ -188,9 +179,6 @@ export class ResearchToggleButton extends LitElement implements Layer {
@click=${this.toggleModal}
style="position: relative;"
>
- ${this._hasUnseenPolicyDirectives
- ? html`
!`
- : ""}
${["R", "E", "S", "E", "A", "R", "C", "H"].map(
(letter) => html`
${letter}`,
)}
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index 00abf74b5..5632f99e3 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -22,6 +22,7 @@ import { getUnitUpgradeCost } from "../../../core/game/UnitUpgrades";
import {
isUpgradeableStructure,
playerMaxStructureLevel,
+ playerMaxStructureTechLevel,
playerMaxUnitLevel,
} from "../../../core/game/Upgradeables";
import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent";
@@ -564,16 +565,16 @@ export class StructureLayer implements Layer {
this.ensureStructureLevels(unit);
const record = this.structureLevels.get(unit.id());
if (record) {
- // Sync primary level from server value.
+ // Sync primary from server stack count.
const prevLevel = record.primary;
- const serverLevel = unit.level();
- record.primary = serverLevel;
- // If the hovered structure's level changed, refresh the label immediately.
+ const serverStackCount = unit.stackCount?.() ?? 1;
+ record.primary = serverStackCount;
+ // If the hovered structure's stack count changed, refresh the label immediately.
if (this.hoveredStructure && this.hoveredStructure.id() === unit.id()) {
this.updateLabels();
}
- // If level changed and we're in upgrade mode, re-render texture so highlight state updates
- if (prevLevel !== serverLevel && this.upgradeMode) {
+ // If stack count changed and we're in upgrade mode, re-render texture so highlight state updates
+ if (prevLevel !== serverStackCount && this.upgradeMode) {
// Refresh texture so highlight state updates based on new level
const target = this.renders.find((r) => r.unit.id() === unit.id());
if (target) {
@@ -1043,18 +1044,37 @@ export class StructureLayer implements Layer {
!this.structureLevels.has(id) &&
unit.type() !== UnitType.Construction
) {
- // Initialize with server level (typically 1 unless upgraded before client joined)
+ // Initialize with stack count (for display) instead of level
// For airfields, set secondary to bomber upgrade level
- const secondary =
- unit.type() === UnitType.Airfield ? unit.bomberLevel() : 0;
- this.structureLevels.set(id, { primary: unit.level(), secondary });
+ // For SAMs, set secondary to SAM tech level
+ let secondary = 0;
+ if (unit.type() === UnitType.Airfield) {
+ secondary = unit.bomberLevel();
+ } else if (unit.type() === UnitType.SAMLauncher) {
+ const player = this.game.myPlayer();
+ if (player && unit.owner().id() === player.id()) {
+ secondary = playerMaxStructureTechLevel(player, UnitType.SAMLauncher);
+ }
+ }
+ this.structureLevels.set(id, {
+ primary: unit.stackCount?.() ?? 1,
+ secondary,
+ });
} else if (this.structureLevels.has(id)) {
- // Keep in sync with authoritative server level each tick/render cycle
+ // Keep in sync with authoritative server stack count each tick/render cycle
const rec = this.structureLevels.get(id)!;
- rec.primary = unit.level();
+ rec.primary = unit.stackCount?.() ?? 1;
// For airfields, update secondary to bomber upgrade level
if (unit.type() === UnitType.Airfield) {
rec.secondary = unit.bomberLevel();
+ } else if (unit.type() === UnitType.SAMLauncher) {
+ const player = this.game.myPlayer();
+ if (player && unit.owner().id() === player.id()) {
+ rec.secondary = playerMaxStructureTechLevel(
+ player,
+ UnitType.SAMLauncher,
+ );
+ }
}
}
}
diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts
index 95aaf5dcc..0c1ed838f 100644
--- a/src/client/graphics/layers/TechUnlockNotification.ts
+++ b/src/client/graphics/layers/TechUnlockNotification.ts
@@ -87,10 +87,11 @@ export class TechUnlockNotification extends LitElement implements Layer {
const meta = getTechMeta(techId, { strict: false });
if (!meta) continue;
this.seenTechs.add(techId);
+ const body = meta.shortDescription ?? meta.description ?? "";
this.enqueue({
id: techId,
name: meta.name ?? techId,
- description: meta.description ?? "",
+ description: body,
});
}
for (const techId of filtered) this.seenTechs.add(techId);
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 94e3a01f9..9d8347826 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -412,28 +412,24 @@ export class TerritoryLayer implements Layer {
// Only call putImageData if something actually changed
if (this.isDirty && this.dirtyRect) {
- const [topLeft, bottomRight] =
- this.transformHandler.screenBoundingRect();
- // Intersect dirty rect with visible viewport
- const vx0 = Math.max(0, topLeft.x, this.dirtyRect.x0);
- const vy0 = Math.max(0, topLeft.y, this.dirtyRect.y0);
- const vx1 = Math.min(this._width - 1, bottomRight.x, this.dirtyRect.x1);
- const vy1 = Math.min(
- this._height - 1,
- bottomRight.y,
- this.dirtyRect.y1,
- );
+ // Apply the dirty rect directly without viewport clipping
+ // The canvas needs to stay in sync with ImageData even for off-screen areas
+ // so that when the user zooms out, those areas are already rendered
+ const x0 = Math.max(0, this.dirtyRect.x0);
+ const y0 = Math.max(0, this.dirtyRect.y0);
+ const x1 = Math.min(this._width - 1, this.dirtyRect.x1);
+ const y1 = Math.min(this._height - 1, this.dirtyRect.y1);
- const w = vx1 - vx0 + 1;
- const h = vy1 - vy0 + 1;
+ const w = x1 - x0 + 1;
+ const h = y1 - y0 + 1;
if (w > 0 && h > 0) {
this.context.putImageData(
this.alternativeView ? this.alternativeImageData : this.imageData,
0,
0,
- vx0,
- vy0,
+ x0,
+ y0,
w,
h,
);
diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts
index b1c8fc177..40cdf5a4e 100644
--- a/src/client/graphics/layers/UILayer.ts
+++ b/src/client/graphics/layers/UILayer.ts
@@ -70,7 +70,8 @@ export class UILayer implements Layer {
this.selectedUnit &&
(this.selectedUnit.type() === UnitType.Warship ||
this.selectedUnit.type() === UnitType.FighterJet ||
- this.selectedUnit.type() === UnitType.Submarine)
+ this.selectedUnit.type() === UnitType.Submarine ||
+ this.selectedUnit.type() === UnitType.Artillery)
) {
this.drawSelectionBox(this.selectedUnit);
}
@@ -195,7 +196,8 @@ export class UILayer implements Layer {
event.unit &&
(event.unit.type() === UnitType.Warship ||
event.unit.type() === UnitType.FighterJet ||
- event.unit.type() === UnitType.Submarine)
+ event.unit.type() === UnitType.Submarine ||
+ event.unit.type() === UnitType.Artillery)
) {
this.drawSelectionBox(event.unit);
}
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts
index 0a539192f..c8bbe05ee 100644
--- a/src/client/graphics/layers/UnitLayer.ts
+++ b/src/client/graphics/layers/UnitLayer.ts
@@ -13,6 +13,8 @@ import {
UnitSelectionEvent,
} from "../../InputHandler";
import {
+ ArtilleryOutOfRangeEvent,
+ MoveArtilleryIntentEvent,
MoveFighterJetIntentEvent,
MoveSubmarineIntentEvent,
MoveWarshipIntentEvent,
@@ -25,6 +27,7 @@ import type { UIState } from "../UIState";
import type { Layer } from "./Layer";
import { GameUpdateType } from "../../../core/game/GameUpdates";
+import { getArtilleryMaxDistance } from "../../../core/game/UnitUpgrades";
import {
getColoredSprite,
isSpriteReady,
@@ -44,6 +47,7 @@ const UNIT_LAYER_TYPES = new Set
([
UnitType.Paratrooper,
UnitType.Submarine,
UnitType.Warship,
+ // Artillery is rendered by ArtilleryLayer
UnitType.Shell,
UnitType.SAMMissile,
UnitType.TradeShip,
@@ -255,6 +259,28 @@ export class UnitLayer implements Layer {
});
}
+ private findArtilleryNearCell(cell: { x: number; y: number }): UnitView[] {
+ if (!this.game.isValidCoord(cell.x, cell.y)) {
+ return [];
+ }
+ const clickRef = this.game.ref(cell.x, cell.y);
+
+ return this.game
+ .units(UnitType.Artillery)
+ .filter(
+ (unit) =>
+ unit.isActive() &&
+ unit.owner() === this.game.myPlayer() &&
+ this.game.manhattanDist(unit.tile(), clickRef) <=
+ this.WARSHIP_SELECTION_RADIUS, // Reuse warship radius for artillery
+ )
+ .sort((a, b) => {
+ const distA = this.game.manhattanDist(a.tile(), clickRef);
+ const distB = this.game.manhattanDist(b.tile(), clickRef);
+ return distA - distB;
+ });
+ }
+
private onMouseUp(event: MouseUpEvent) {
// Convert screen coordinates to world coordinates
const cell = this.transformHandler.screenToWorldCoordinates(
@@ -266,6 +292,7 @@ export class UnitLayer implements Layer {
const nearbyWarships = this.findWarshipsNearCell(cell);
const nearbySubmarines = this.findSubmarinesNearCell(cell);
const nearbyFighterJets = this.findFighterJetsNearCell(cell);
+ const nearbyArtillery = this.findArtilleryNearCell(cell);
// unit upgrade mode removed: proceed with selection/move logic only
@@ -289,6 +316,24 @@ export class UnitLayer implements Layer {
this.eventBus.emit(
new MoveSubmarineIntentEvent(this.selectedUnit.id(), clickRef),
);
+ } else if (
+ this.selectedUnit.type() === UnitType.Artillery &&
+ !this.game.isOcean(clickRef)
+ ) {
+ // Check distance cap before sending move intent
+ const lvl = this.selectedUnit.level ? this.selectedUnit.level() : 1;
+ const maxDist = getArtilleryMaxDistance(lvl);
+ const distSq = this.game.euclideanDistSquared(
+ this.selectedUnit.tile(),
+ clickRef,
+ );
+ if (distSq > maxDist * maxDist) {
+ this.eventBus.emit(new ArtilleryOutOfRangeEvent(lvl, maxDist));
+ } else {
+ this.eventBus.emit(
+ new MoveArtilleryIntentEvent(this.selectedUnit.id(), clickRef),
+ );
+ }
}
// Mark click as consumed whenever a unit was selected, so other handlers don't also treat it as an attack
event.consumed = true;
@@ -306,6 +351,9 @@ export class UnitLayer implements Layer {
} else if (nearbyFighterJets.length > 0) {
const clickedUnit = nearbyFighterJets[0];
this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
+ } else if (nearbyArtillery.length > 0) {
+ const clickedUnit = nearbyArtillery[0];
+ this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
}
}
@@ -789,6 +837,9 @@ export class UnitLayer implements Layer {
case UnitType.Warship:
this.handleWarShipEvent(unit, angleByUnit);
break;
+ case UnitType.Artillery:
+ this.handleArtilleryEvent(unit, angleByUnit);
+ break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
@@ -830,6 +881,13 @@ export class UnitLayer implements Layer {
}
}
+ private handleArtilleryEvent(
+ unit: UnitView,
+ angleByUnit?: Map,
+ ) {
+ // Artillery is now rendered by StructureLayer (GPU-accelerated PIXI)
+ }
+
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
diff --git a/src/client/stats/StatDefinitions.ts b/src/client/stats/StatDefinitions.ts
index f03b6a4ed..c1fe48a27 100644
--- a/src/client/stats/StatDefinitions.ts
+++ b/src/client/stats/StatDefinitions.ts
@@ -184,14 +184,12 @@ export function computeStatValue(
case "Land Techs":
case "Sea Techs":
case "Air Techs":
- case "Nuclear Techs":
- case "Economy Techs": {
+ case "Nuclear Techs": {
const labelToCat: Record = {
"Land Techs": "Land",
"Sea Techs": "Sea",
"Air Techs": "Air",
"Nuclear Techs": "Nuclear",
- "Economy Techs": "Economy",
};
const cat = labelToCat[label];
const nodes = getTechNodes();
diff --git a/src/client/styles.css b/src/client/styles.css
index 5ce775705..690058e83 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -605,6 +605,12 @@ label.option-card:hover {
cover;
}
+#helpModal .artillery-icon {
+ mask: url("../../proprietary/images/artillery-battery.png") no-repeat center /
+ cover;
+ background-size: contain;
+}
+
@media screen and (max-width: 768px) {
#helpModal .modal-content {
max-height: 90vh;
diff --git a/src/client/utilities/RenderUnitTypeOptions.ts b/src/client/utilities/RenderUnitTypeOptions.ts
index c244453de..f7b7e68ee 100644
--- a/src/client/utilities/RenderUnitTypeOptions.ts
+++ b/src/client/utilities/RenderUnitTypeOptions.ts
@@ -14,6 +14,7 @@ const unitOptions: { type: UnitType; translationKey: string }[] = [
{ type: UnitType.Port, translationKey: "unit_type.port" },
{ type: UnitType.Airfield, translationKey: "unit_type.airfield" },
{ type: UnitType.Warship, translationKey: "unit_type.warship" },
+ { type: UnitType.Artillery, translationKey: "unit_type.artillery" },
{ type: UnitType.FighterJet, translationKey: "unit_type.fighter_jet" },
{ type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
{ type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index dcf8a0d3b..6c22439e4 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -47,13 +47,13 @@ export type Intent =
| RoadInvestmentIntent
| ResearchInvestmentIntent
| BuildUnitIntent
- | ScorchedEarthIntent
| ResearchTreeSelectIntent
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
| MoveSubmarineIntent
| MoveFighterJetIntent
+ | MoveArtilleryIntent
| BomberIntent
| ParatrooperAttackIntent
| CancelParatrooperIntent
@@ -61,9 +61,7 @@ export type Intent =
| SetAutoBombingIntent
| KickPlayerIntent
| UpgradeStructureIntent
- | UpgradeBomberIntent
- | PolicyDirectiveSelectIntent
- | MarkPolicyDirectivesSeenIntent;
+ | UpgradeBomberIntent;
export type AttackIntent = z.infer;
export type CancelAttackIntent = z.infer;
@@ -91,13 +89,13 @@ export type ResearchInvestmentIntent = z.infer<
typeof ResearchInvestmentIntentSchema
>;
export type BuildUnitIntent = z.infer;
-export type ScorchedEarthIntent = z.infer;
export type ResearchTreeSelectIntent = z.infer<
typeof ResearchTreeSelectIntentSchema
>;
export type MoveWarshipIntent = z.infer;
export type MoveSubmarineIntent = z.infer;
export type MoveFighterJetIntent = z.infer;
+export type MoveArtilleryIntent = z.infer;
export type BomberIntent = z.infer;
export type SetAutoBombingIntent = z.infer;
export type ParatrooperAttackIntent = z.infer<
@@ -117,12 +115,6 @@ export type UpgradeStructureIntent = z.infer<
typeof UpgradeStructureIntentSchema
>;
export type UpgradeBomberIntent = z.infer;
-export type PolicyDirectiveSelectIntent = z.infer<
- typeof PolicyDirectiveSelectIntentSchema
->;
-export type MarkPolicyDirectivesSeenIntent = z.infer<
- typeof MarkPolicyDirectivesSeenIntentSchema
->;
export type Turn = z.infer;
export enum PeaceTimerDuration {
@@ -425,16 +417,12 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
// Optional desired starting level for upgradeable structures.
// Server will clamp based on type and game rules.
- targetLevel: z.number().int().min(1).max(99).optional(),
+ targetLevel: z.number().int().min(1).max(25).optional(),
// Optional desired bomber upgrade level for airfields.
// Server will clamp based on maxUnitLevel(UnitType.Bomber).
bomberLevel: z.number().int().min(1).max(99).optional(),
});
-export const ScorchedEarthIntentSchema = BaseIntentSchema.extend({
- type: z.literal("activate_scorched_earth"),
-});
-
export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({
type: z.literal("upgrade_structure"),
unitId: z.number(),
@@ -451,16 +439,6 @@ export const ResearchTreeSelectIntentSchema = BaseIntentSchema.extend({
techId: z.string().max(128),
});
-export const PolicyDirectiveSelectIntentSchema = BaseIntentSchema.extend({
- type: z.literal("policy_directive_select"),
- directiveId: z.string().max(128),
- optionId: z.string().max(128),
-});
-
-export const MarkPolicyDirectivesSeenIntentSchema = BaseIntentSchema.extend({
- type: z.literal("mark_policy_directives_seen"),
-});
-
export const CancelAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("cancel_attack"),
attackID: z.string(),
@@ -489,6 +467,12 @@ export const MoveFighterJetIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
});
+export const MoveArtilleryIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("move_artillery"),
+ unitId: z.number(),
+ tile: z.number(),
+});
+
export const BomberIntentSchema = BaseIntentSchema.extend({
type: z.literal("bomber_intent"),
targetID: ID.nullable(), // who to attack
@@ -551,16 +535,14 @@ const IntentSchema = z.discriminatedUnion("type", [
RoadInvestmentIntentSchema,
ResearchInvestmentIntentSchema,
BuildUnitIntentSchema,
- ScorchedEarthIntentSchema,
UpgradeStructureIntentSchema,
UpgradeBomberIntentSchema,
ResearchTreeSelectIntentSchema,
- PolicyDirectiveSelectIntentSchema,
- MarkPolicyDirectivesSeenIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
MoveSubmarineIntentSchema,
MoveFighterJetIntentSchema,
+ MoveArtilleryIntentSchema,
BomberIntentSchema,
ParatrooperAttackIntentSchema,
CancelParatrooperIntentSchema,
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 745082997..a0e954d13 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -144,7 +144,6 @@ export interface Config {
donateCooldown(): Tick;
defaultDonationAmount(sender: Player): number;
unitInfo(type: UnitType): UnitInfo;
- scorchedEarthActivationCost(player: Player | PlayerView): Gold;
tradeShipGold(dist: number): Gold;
tradeShipSpawnRate(numberOfPorts: number): number;
// Trade rework: gravity-based demand and port-supplied ships
@@ -173,8 +172,6 @@ export interface Config {
internationalCargoTruckGoldSplitRatio(): number;
urbanPlanningPopulationBonusNum(): number;
urbanPlanningPopulationBonusDen(): number;
- structureInsuranceRefundNum(): number;
- structureInsuranceRefundDen(): number;
// Structure upgrade cost multiplier per structure type (e.g., 0.8 for 80%)
structureUpgradeCostMultiplier(type: UnitType): number;
@@ -239,6 +236,12 @@ export interface Config {
submarineLevelMaxHealth(level: number): number;
// Submarine: per-level damage range
submarineDamageRange(level: number): { min: number; max: number };
+ // Artillery: per-level max health
+ artilleryLevelMaxHealth(level: number): number;
+ // Artillery: per-level damage range
+ artilleryDamageRange(level: number): { min: number; max: number };
+ artilleryPatrolRange(): number;
+ artilleryShellAttackRate(): number;
warshipAARange(): number;
warshipAACooldown(): number;
warshipAAScanInterval(): number;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 30d627bc2..c9da9142a 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -23,6 +23,7 @@ import {
import { TileRef } from "../game/GameMap";
import { PlayerView } from "../game/GameView";
import {
+ getArtilleryLevelData,
getBomberLevelData,
getFighterLevelData,
getSubmarineLevelData,
@@ -550,6 +551,15 @@ export class DefaultConfig implements Config {
return { min: data.damageMin, max: data.damageMax };
}
+ // Artillery per-level stats
+ artilleryLevelMaxHealth(level: number): number {
+ return getArtilleryLevelData(level).maxHealth;
+ }
+ artilleryDamageRange(level: number): { min: number; max: number } {
+ const data = getArtilleryLevelData(level);
+ return { min: data.damageMin, max: data.damageMax };
+ }
+
// Paratroopers/Air attack
paratrooperMaxNumber(): number {
return 3;
@@ -812,6 +822,15 @@ export class DefaultConfig implements Config {
territoryBound: false,
maxHealth: 750,
};
+ case UnitType.Artillery:
+ return {
+ cost: (p: Player) =>
+ p.type() === PlayerType.Human && this.infiniteGold()
+ ? 0n
+ : 500_000n,
+ territoryBound: false,
+ maxHealth: 1000,
+ };
case UnitType.Paratrooper:
return {
cost: () => 0n,
@@ -831,12 +850,6 @@ export class DefaultConfig implements Config {
assertNever(type);
}
}
- scorchedEarthActivationCost(player: Player | PlayerView): Gold {
- if (player.type() === PlayerType.Human && this.infiniteGold()) {
- return 0n;
- }
- return 3_000_000n;
- }
defaultDonationAmount(sender: Player): number {
return Math.floor(sender.troops() / 3);
}
@@ -1298,6 +1311,14 @@ export class DefaultConfig implements Config {
return 75;
}
+ artilleryPatrolRange(): number {
+ return 35;
+ }
+
+ artilleryShellAttackRate(): number {
+ return 20;
+ }
+
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
@@ -1343,12 +1364,6 @@ export class DefaultConfig implements Config {
urbanPlanningPopulationBonusDen(): number {
return 4;
}
- structureInsuranceRefundNum(): number {
- return 1;
- }
- structureInsuranceRefundDen(): number {
- return 3;
- }
// --- Structure upgrade cost multipliers ---
structureUpgradeCostMultiplier(type: UnitType): number {
switch (type) {
diff --git a/src/core/execution/AirfieldExecution.ts b/src/core/execution/AirfieldExecution.ts
index de815a160..20ee4da48 100644
--- a/src/core/execution/AirfieldExecution.ts
+++ b/src/core/execution/AirfieldExecution.ts
@@ -1,6 +1,6 @@
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
-import { maxUnitLevel, playerMaxStructureLevel } from "../game/Upgradeables";
+import { maxUnitLevel } from "../game/Upgradeables";
import { PseudoRandom } from "../PseudoRandom";
import { BomberExecution } from "./BomberExecution";
import { CargoPlaneExecution } from "./CargoPlaneExecution";
@@ -11,13 +11,13 @@ export class AirfieldExecution implements Execution {
private airfield: Unit | null = null;
private random: PseudoRandom | null = null;
private checkOffset: number | null = null;
- private lastLevel = 0; // Track airfield level to detect upgrades
+ private lastStackCount = 0; // Track airfield stack count to detect upgrades
constructor(
private player: Player,
private tile: TileRef,
- private initialBomberLevel: number = 1, // Bomber upgrade level
- private desiredLevel: number = 1, // Structure upgrade level
+ private initialBomberLevel: number = 1, // Bomber tech upgrade level
+ private stackCount: number = 1, // Stack count (how many bombers to spawn/maintain)
) {}
init(mg: Game, ticks: number): void {
@@ -43,13 +43,15 @@ export class AirfieldExecution implements Execution {
}
this.airfield = this.player.buildUnit(UnitType.Airfield, spawn, {});
- // Apply structure upgrades if requested
- const structureLevel = this.computeDesiredLevel(
- UnitType.Airfield,
- this.desiredLevel,
- );
- this.applyUpgrades(this.airfield, structureLevel);
- this.lastLevel = this.airfield.level?.() ?? 1;
+ // Set stack count on the airfield
+ if (this.stackCount > 1) {
+ (this.airfield as any).setStackCount(this.stackCount);
+ // Apply HP bonuses for stacking (one upgrade per extra stack)
+ for (let i = 1; i < this.stackCount; i++) {
+ (this.airfield as any).upgradeStructure();
+ }
+ }
+ this.lastStackCount = this.stackCount;
// Set initial bomber upgrade level if specified (clamped to max)
const bomberLvl = Math.min(
@@ -60,8 +62,8 @@ export class AirfieldExecution implements Execution {
this.airfield.setBomberLevel?.(bomberLvl);
}
- // Spawn initial bombers when airfield is built
- this.spawnBombersForLevel(mg);
+ // Spawn initial bombers based on stack count
+ this.spawnBombersForStackCount(mg);
}
if (!this.airfield.isActive()) {
@@ -73,14 +75,14 @@ export class AirfieldExecution implements Execution {
this.player = this.airfield.owner();
}
- // Check if airfield was upgraded - spawn additional bombers
- const currentLevel = this.airfield.level?.() ?? 1;
- if (currentLevel > this.lastLevel) {
- const bombersToAdd = currentLevel - this.lastLevel;
+ // Check if airfield was upgraded (stack count increased) - spawn additional bombers
+ const currentStackCount = this.airfield.stackCount?.() ?? 1;
+ if (currentStackCount > this.lastStackCount) {
+ const bombersToAdd = currentStackCount - this.lastStackCount;
for (let i = 0; i < bombersToAdd; i++) {
mg.addExecution(new BomberExecution(this.player, this.airfield));
}
- this.lastLevel = currentLevel;
+ this.lastStackCount = currentStackCount;
}
if ((mg.ticks() + this.checkOffset) % 10 !== 0) {
@@ -110,9 +112,9 @@ export class AirfieldExecution implements Execution {
}
}
- private spawnBombersForLevel(mg: Game): void {
- const level = this.airfield?.level?.() ?? 1;
- for (let i = 0; i < level; i++) {
+ private spawnBombersForStackCount(mg: Game): void {
+ const count = this.airfield?.stackCount?.() ?? 1;
+ for (let i = 0; i < count; i++) {
mg.addExecution(new BomberExecution(this.player, this.airfield!));
}
}
@@ -124,19 +126,4 @@ export class AirfieldExecution implements Execution {
activeDuringSpawnPhase(): boolean {
return false;
}
-
- private computeDesiredLevel(type: UnitType, target?: number): number {
- if (target === undefined || target < 1) return 1;
- const cap = playerMaxStructureLevel(this.player, type);
- return Math.max(1, Math.min(cap, target));
- }
-
- private applyUpgrades(unit: Unit, desiredLevel: number): void {
- const steps = Math.max(0, desiredLevel - 1);
- if (steps <= 0) return;
- const impl = unit as any;
- if (typeof impl.upgradeStructure === "function") {
- for (let i = 0; i < steps; i++) impl.upgradeStructure();
- }
- }
}
diff --git a/src/core/execution/ArtilleryExecution.ts b/src/core/execution/ArtilleryExecution.ts
new file mode 100644
index 000000000..8a90176f1
--- /dev/null
+++ b/src/core/execution/ArtilleryExecution.ts
@@ -0,0 +1,460 @@
+import {
+ Execution,
+ Game,
+ isStructureType,
+ isUnit,
+ OwnerComp,
+ TerrainType,
+ Unit,
+ UnitParams,
+ UnitType,
+} from "../game/Game";
+import { GameImpl } from "../game/GameImpl";
+import { TileRef } from "../game/GameMap";
+import { PriorityQueue } from "../game/PriorityQueue";
+import { getArtilleryLevelData } from "../game/UnitUpgrades";
+import { PseudoRandom } from "../PseudoRandom";
+import { ShellExecution } from "./ShellExecution";
+
+export class ArtilleryExecution implements Execution {
+ private random: PseudoRandom;
+ private artillery: Unit;
+ private mg: GameImpl;
+ private allowedOwners: Set;
+ private lastShellAttack = 0;
+ private alreadySentShell = new Set();
+ private lastTargetScan = 0;
+ private lastMove = 0; // Track last movement tick for 50% speed reduction
+ private shellsFiredInBarrage = 0; // Track shells fired in current barrage (0-3)
+ private barrageStartTick = 0; // Track when current barrage started
+
+ // Path caching to avoid A* every move tick
+ private cachedPath: TileRef[] = [];
+ private cachedPathTarget: TileRef | null = null;
+
+ constructor(
+ private input: (UnitParams & OwnerComp) | Unit,
+ private desiredLevel: number = 1,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ this.mg = mg as GameImpl;
+ this.random = new PseudoRandom(mg.ticks());
+ this.allowedOwners = new Set();
+ if (isUnit(this.input)) {
+ this.artillery = this.input;
+ } else {
+ const spawn = this.input.owner.canBuild(
+ UnitType.Artillery,
+ this.input.patrolTile,
+ );
+ if (spawn === false) {
+ return;
+ }
+ this.artillery = this.input.owner.buildUnit(UnitType.Artillery, spawn, {
+ patrolTile: this.input.patrolTile,
+ });
+ const lvl = Math.max(1, this.desiredLevel | 0);
+ if (lvl > 1) {
+ (this.artillery as any)._level = lvl;
+ // Apply per-level max health boost
+ const base =
+ this.mg.config().unitInfo(UnitType.Artillery).maxHealth ?? 1000;
+ const desired = this.mg.config().artilleryLevelMaxHealth(lvl);
+ const bonus = Math.max(0, desired - base);
+ (this.artillery as any)._bonusMaxHealth = bonus;
+ (this.artillery as any)._health = BigInt(desired);
+ this.mg.addUpdate(this.artillery.toUpdate());
+ }
+ }
+ // Build allowed owner set (own + friendly) like road pathing
+ const owner = this.artillery.owner();
+ this.allowedOwners.add(owner.smallID());
+ for (const p of this.mg.players()) {
+ if (p.smallID() !== owner.smallID() && owner.isFriendly(p)) {
+ this.allowedOwners.add(p.smallID());
+ }
+ }
+ }
+
+ tick(ticks: number): void {
+ if (this.artillery.health() <= 0) {
+ this.artillery.delete();
+ return;
+ }
+
+ // Destroy artillery if its tile is conquered by another player
+ const tileOwner = this.mg.owner(this.artillery.tile());
+ if (tileOwner !== this.artillery.owner()) {
+ // Set health to 0 to trigger deletion in next tick
+ this.artillery.modifyHealth(-this.artillery.health());
+ return;
+ }
+
+ // Healing: +1 HP per tick if owner has at least one factory
+ const hasFactory = this.artillery.owner().unitCount(UnitType.Factory) > 0;
+ if (hasFactory) {
+ this.artillery.modifyHealth(1);
+ }
+
+ // Target scanning with interval optimization (every 10 ticks)
+ if (ticks - this.lastTargetScan > 10) {
+ this.lastTargetScan = ticks;
+ this.artillery.setTargetUnit(this.findTargetStructure());
+ }
+
+ // Skip patrol when firing at a target
+ if (this.artillery.targetUnit() === undefined) {
+ this.patrol();
+ }
+
+ if (this.artillery.targetUnit() !== undefined) {
+ this.shootTarget();
+ return;
+ }
+ }
+
+ private findTargetStructure(): Unit | undefined {
+ const level = this.artillery.level ? this.artillery.level() : 1;
+ const levelData = getArtilleryLevelData(level);
+ const targetingRange = levelData.targetRange;
+
+ // Get all structure types for filtering
+ const structureTypes = Object.values(UnitType).filter((type) =>
+ isStructureType(type),
+ );
+
+ const structures = this.mg.nearbyUnits(
+ this.artillery.tile()!,
+ targetingRange,
+ structureTypes,
+ );
+
+ // Also check for nearby enemy artillery
+ const allArtillery = this.mg.units(UnitType.Artillery);
+ const nearbyArtillery = allArtillery.filter((art) => {
+ if (art === this.artillery || art.owner() === this.artillery.owner()) {
+ return false;
+ }
+ if (art.owner().isFriendly(this.artillery.owner())) {
+ return false;
+ }
+ if (!this.artillery.owner().isAtWarWith(art.owner())) {
+ return false;
+ }
+ const distSquared = this.mg.euclideanDistSquared(
+ this.artillery.tile(),
+ art.tile(),
+ );
+ return distSquared <= targetingRange * targetingRange;
+ });
+
+ const enemyArtillery: { unit: Unit; distSquared: number }[] = [];
+ const defensePosts: { unit: Unit; distSquared: number }[] = [];
+ const otherTargets: { unit: Unit; distSquared: number }[] = [];
+
+ for (const { unit, distSquared } of structures) {
+ if (
+ unit.owner() === this.artillery.owner() ||
+ unit === this.artillery ||
+ unit.owner().isFriendly(this.artillery.owner()) ||
+ this.alreadySentShell.has(unit)
+ ) {
+ continue;
+ }
+
+ // Only target enemy structures
+ if (!this.artillery.owner().isAtWarWith(unit.owner())) {
+ continue;
+ }
+
+ // Must have health to target
+ if (!unit.hasHealth()) {
+ continue;
+ }
+
+ // Prioritize enemy artillery (highest priority)
+ if (unit.type() === UnitType.Artillery) {
+ enemyArtillery.push({ unit, distSquared });
+ }
+ // Then defense posts
+ else if (unit.type() === UnitType.DefensePost) {
+ defensePosts.push({ unit, distSquared });
+ } else {
+ otherTargets.push({ unit, distSquared });
+ }
+ }
+
+ // Add nearby artillery to enemy artillery list
+ for (const art of nearbyArtillery) {
+ const distSquared = this.mg.euclideanDistSquared(
+ this.artillery.tile(),
+ art.tile(),
+ );
+ enemyArtillery.push({ unit: art, distSquared });
+ }
+
+ // Priority 1: Enemy artillery (closest first)
+ if (enemyArtillery.length > 0) {
+ return enemyArtillery.sort((a, b) => a.distSquared - b.distSquared)[0]
+ .unit;
+ }
+
+ // Priority 2: Defense posts (closest first)
+ if (defensePosts.length > 0) {
+ return defensePosts.sort((a, b) => a.distSquared - b.distSquared)[0].unit;
+ }
+
+ // Priority 3: Other structures (closest first)
+ return otherTargets.sort((a, b) => a.distSquared - b.distSquared)[0]?.unit;
+ }
+
+ private shootTarget() {
+ const isPeaceTimerActive =
+ this.mg.peaceTimerEndsAtTick !== null &&
+ this.mg.ticks() < this.mg.peaceTimerEndsAtTick;
+
+ if (isPeaceTimerActive) {
+ this.artillery.setTargetUnit(undefined);
+ return; // Block attack
+ }
+
+ const shellAttackRate = this.mg.config().artilleryShellAttackRate();
+
+ // Check if we're starting a new barrage (enough time has passed since last barrage)
+ if (this.mg.ticks() - this.lastShellAttack >= shellAttackRate) {
+ this.lastShellAttack = this.mg.ticks();
+ this.shellsFiredInBarrage = 0;
+ this.barrageStartTick = this.mg.ticks();
+ }
+
+ // Fire one shell every 2 ticks if we haven't fired all 3 yet
+ const ticksSinceBarrageStart = this.mg.ticks() - this.barrageStartTick;
+ const shellsToFire = Math.floor(ticksSinceBarrageStart / 2) + 1;
+
+ if (
+ shellsToFire > this.shellsFiredInBarrage &&
+ this.shellsFiredInBarrage < 3
+ ) {
+ this.mg.addExecution(
+ new ShellExecution(
+ this.artillery.tile(),
+ this.artillery.owner(),
+ this.artillery,
+ this.artillery.targetUnit()!,
+ ),
+ );
+ this.shellsFiredInBarrage++;
+
+ if (!this.artillery.targetUnit()!.hasHealth()) {
+ // Don't send multiple shells to target that can be oneshotted
+ this.alreadySentShell.add(this.artillery.targetUnit()!);
+ this.artillery.setTargetUnit(undefined);
+ }
+ }
+ }
+
+ private patrol() {
+ if (this.artillery.targetTile() === undefined) {
+ this.artillery.setTargetTile(this.randomTile());
+ this.clearCachedPath(); // New target, need fresh path
+ if (this.artillery.targetTile() === undefined) {
+ return;
+ }
+ }
+
+ // Use level-based move interval
+ const level = this.artillery.level ? this.artillery.level() : 1;
+ const levelData = getArtilleryLevelData(level);
+ const moveInterval = levelData.moveInterval;
+
+ if (this.mg.ticks() - this.lastMove < moveInterval) {
+ this.artillery.touch();
+ return;
+ }
+
+ const step = this.getNextStep();
+ if (step === null) {
+ this.artillery.setTargetTile(undefined);
+ this.clearCachedPath();
+ return;
+ }
+ if (step === this.artillery.tile()) {
+ this.artillery.setTargetTile(undefined);
+ this.clearCachedPath();
+ return;
+ }
+ this.artillery.move(step);
+ this.lastMove = this.mg.ticks();
+ }
+
+ /** Clear cached path when target changes or path becomes invalid */
+ private clearCachedPath(): void {
+ this.cachedPath = [];
+ this.cachedPathTarget = null;
+ }
+
+ /** Get next step from cached path, computing new path only when needed */
+ private getNextStep(): TileRef | null {
+ const currentTile = this.artillery.tile();
+ const targetTile = this.artillery.targetTile()!;
+
+ // Check if we need to recompute path:
+ // 1. No cached path
+ // 2. Target changed
+ // 3. We're not on the expected position (got displaced somehow)
+ const needsRecompute =
+ this.cachedPath.length === 0 ||
+ this.cachedPathTarget !== targetTile ||
+ (this.cachedPath.length > 0 &&
+ this.cachedPath[this.cachedPath.length - 1] !== currentTile);
+
+ if (needsRecompute) {
+ const fullPath = this.computeFullPath(currentTile, targetTile);
+ if (fullPath === null) {
+ this.clearCachedPath();
+ return null;
+ }
+ this.cachedPath = fullPath;
+ this.cachedPathTarget = targetTile;
+ }
+
+ // Pop and return the next step (path is stored destination->source, so pop from end)
+ if (this.cachedPath.length <= 1) {
+ // Already at destination or path exhausted
+ return currentTile;
+ }
+
+ // Remove current position from path and return next step
+ this.cachedPath.pop(); // Remove current tile
+ return this.cachedPath[this.cachedPath.length - 1]; // Return next tile
+ }
+
+ isActive(): boolean {
+ return this.artillery?.isActive();
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+
+ randomTile(): TileRef | undefined {
+ let artilleryPatrolRange = this.mg.config().artilleryPatrolRange();
+ const maxAttemptBeforeExpand: number = 500;
+ let attempts: number = 0;
+ let expandCount: number = 0;
+ while (expandCount < 3) {
+ const x =
+ this.mg.x(this.artillery.patrolTile()!) +
+ this.random.nextInt(
+ -artilleryPatrolRange / 2,
+ artilleryPatrolRange / 2,
+ );
+ const y =
+ this.mg.y(this.artillery.patrolTile()!) +
+ this.random.nextInt(
+ -artilleryPatrolRange / 2,
+ artilleryPatrolRange / 2,
+ );
+ if (!this.mg.isValidCoord(x, y)) {
+ continue;
+ }
+ const tile = this.mg.ref(x, y);
+ // Artillery must patrol on owned land only (not ocean, not water, not barrier, must be owned by player)
+ if (
+ this.mg.isOcean(tile) ||
+ this.mg.isWater(tile) ||
+ this.mg.terrainType(tile) === TerrainType.Barrier ||
+ this.mg.owner(tile) !== this.artillery.owner()
+ ) {
+ attempts++;
+ if (attempts === maxAttemptBeforeExpand) {
+ expandCount++;
+ attempts = 0;
+ artilleryPatrolRange =
+ artilleryPatrolRange + Math.floor(artilleryPatrolRange / 2);
+ }
+ continue;
+ }
+ return tile;
+ }
+ return undefined;
+ }
+
+ // A* over friendly/own land; water/shore allowed (like roads), barrier blocked
+ // Returns full path from dst to src (reversed order for easy pop), or null if no path
+ private computeFullPath(src: TileRef, dst: TileRef): TileRef[] | null {
+ if (src === dst) return [src];
+
+ const ok = (t: TileRef) => {
+ if (this.mg.terrainType(t) === TerrainType.Barrier) return false;
+ // Artillery is land-only: block ocean and water tiles
+ if (this.mg.isOcean(t) || this.mg.isWater(t)) return false;
+ const oid = this.mg.ownerID(t);
+ if (oid === 0) return false;
+ return this.allowedOwners.has(oid);
+ };
+
+ if (!ok(src) || !ok(dst)) return null;
+
+ const DX = [0, 0, -1, 1, -1, 1, -1, 1];
+ const DY = [-1, 1, 0, 0, -1, -1, 1, 1];
+ const SCALE = [1, 1, 1, 1, 1.4142, 1.4142, 1.4142, 1.4142];
+ const pq = new PriorityQueue();
+ const cameFrom = new Map();
+ const g = new Map();
+
+ const gx = this.mg.x(dst);
+ const gy = this.mg.y(dst);
+ const h = (t: TileRef) => {
+ const dx = Math.abs(this.mg.x(t) - gx);
+ const dy = Math.abs(this.mg.y(t) - gy);
+ const m = Math.min(dx, dy);
+ return dx + dy - m + m * 1.4142; // octile
+ };
+
+ const enqueue = (t: TileRef, cost: number) => {
+ g.set(t, cost);
+ pq.enqueue(cost + h(t), t);
+ };
+
+ enqueue(src, 0);
+ let expansions = 0;
+ const MAX_EXP = 10000;
+
+ while (pq.size > 0) {
+ const current = pq.dequeue()!;
+ if (current === dst) break;
+ if (++expansions > MAX_EXP) return null;
+
+ const cx = this.mg.x(current);
+ const cy = this.mg.y(current);
+ for (let dir = 0; dir < 8; dir++) {
+ const nx = cx + DX[dir];
+ const ny = cy + DY[dir];
+ if (!this.mg.isValidCoord(nx, ny)) continue;
+ const nt = this.mg.ref(nx, ny);
+ if (!ok(nt)) continue;
+ const step = this.mg.cost(nt) * SCALE[dir];
+ const tentative = (g.get(current) ?? Infinity) + step;
+ if (tentative < (g.get(nt) ?? Infinity)) {
+ cameFrom.set(nt, current);
+ enqueue(nt, tentative);
+ }
+ }
+ }
+
+ if (!cameFrom.has(dst)) return null;
+
+ // Reconstruct path back to src (stored as dst->src for easy pop from end)
+ const path: TileRef[] = [dst];
+ let cur = dst;
+ while (cameFrom.has(cur)) {
+ cur = cameFrom.get(cur)!;
+ path.push(cur);
+ if (cur === src) break;
+ }
+ if (path[path.length - 1] !== src) return null;
+ return path;
+ }
+}
diff --git a/src/core/execution/BomberExecution.ts b/src/core/execution/BomberExecution.ts
index f88760eea..383df24fe 100644
--- a/src/core/execution/BomberExecution.ts
+++ b/src/core/execution/BomberExecution.ts
@@ -1,6 +1,7 @@
import type { Execution, Game, Player, Unit } from "../game/Game";
import { UnitType } from "../game/Game";
import type { TileRef } from "../game/GameMap";
+import { playerMaxStructureTechLevel } from "../game/Upgradeables";
import { StraightPathFinder } from "../pathfinding/PathFinding";
import { roadEffectModifiers } from "../tech/TechEffects";
@@ -371,11 +372,17 @@ export class BomberExecution implements Execution {
) {
const targetPlayer = this.mg.player(intent.targetPlayerID);
if (targetPlayer && this.origOwner.isAtWarWith(targetPlayer)) {
- return this.findTargetFromQueue(
+ const target = this.findTargetFromQueue(
targetPlayer,
intent.structures,
intent.preferClosest,
);
+ // If we found a target in manual mode, use it
+ if (target) {
+ return target;
+ }
+ // If no targets remain, clear the manual intent and fall through to auto-bombing
+ this.origOwner.setBomberIntent(null);
}
} // Auto-bombing mode
if (!this.origOwner.isAutoBombingEnabled()) {
@@ -384,6 +391,7 @@ export class BomberExecution implements Execution {
const range = this.mg.config().bomberTargetRange(this.getBomberLevel());
const priority: UnitType[] = [
+ UnitType.Artillery,
UnitType.SAMLauncher,
UnitType.Airfield,
UnitType.MissileSilo,
@@ -546,7 +554,8 @@ export class BomberExecution implements Execution {
private getEffectiveSAMRange(sam: Unit): number {
const base = this.mg.config().defaultSamRange();
const bonus = this.mg.config().samRangeUpgradePercent();
- const lvl = sam.level?.() ?? 1;
+ // Use player's SAM tech level, not unit level (which is stack count)
+ const lvl = playerMaxStructureTechLevel(sam.owner(), UnitType.SAMLauncher);
if (lvl <= 1) return base;
// Apply per-upgrade multiplicative increase
const factor = Math.pow(1 + bonus, lvl - 1);
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index 67cf40bc3..37ae46a47 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -15,12 +15,16 @@ import {
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import {
+ isStackableStructure,
+ isTechUpgradeableStructure,
isUpgradeableUnit,
- playerMaxStructureLevel,
+ maxStackCount,
+ playerMaxStructureTechLevel,
playerMaxUnitLevel,
} from "../game/Upgradeables";
import { constructionSpeedModifiers } from "../tech/TechEffects";
import { AirfieldExecution } from "./AirfieldExecution";
+import { ArtilleryExecution } from "./ArtilleryExecution";
import { DefensePostExecution } from "./DefensePostExecution";
import { FighterJetExecution } from "./FighterJetExecution";
import { MirvExecution } from "./MIRVExecution";
@@ -39,13 +43,14 @@ export class ConstructionExecution implements Execution {
private reservedTotalCost: Gold = 0n;
private baseCost: Gold = 0n;
- private desiredLevel: number = 1;
+ private desiredStackCount: number = 1; // How many stacked instances
+ private desiredTechLevel: number = 1; // Tech upgrade level (for SAM, Airfield)
constructor(
private player: Player,
private constructionType: UnitType,
private tile: TileRef,
- private targetLevel?: number,
+ private stackCount?: number, // User-selected stack count (renamed from targetLevel)
private bomberLevel?: number, // Bomber upgrade level for airfields
) {}
@@ -70,16 +75,17 @@ export class ConstructionExecution implements Execution {
tick(ticks: number): void {
if (this.construction === null) {
const info = this.mg.unitInfo(this.constructionType);
+
+ // Compute stack count and tech level
+ this.desiredStackCount = this.computeStackCount(this.constructionType);
+ this.desiredTechLevel = this.computeTechLevel(this.constructionType);
+
if (info.constructionDuration === undefined) {
// No construction phase; treat as instant build path
// Compute and reserve total aggregated cost first
this.baseCost = this.mg
.unitInfo(this.constructionType)
.cost(this.player);
- this.desiredLevel = this.computeDesiredLevel(
- this.constructionType,
- this.targetLevel,
- );
// Validate build feasibility BEFORE charging any gold
const canSpawnInstant = this.player.canBuild(
this.constructionType,
@@ -90,28 +96,10 @@ export class ConstructionExecution implements Execution {
this.active = false;
return;
}
- const total =
- aggregateStructureBuildCost(
- this.mg,
- this.player,
- this.constructionType,
- this.desiredLevel,
- // For upgradeable units, aggregateStructureBuildCost ignores multiplier
- this.mg
- .config()
- .structureUpgradeCostMultiplier(this.constructionType),
- ) +
- (this.constructionType === UnitType.Airfield
- ? computeBomberUpgradeCost(
- this.mg,
- this.player,
- this.bomberLevel ?? 1,
- this.desiredLevel,
- )
- : 0n);
+ const total = this.computeTotalCost();
if (this.player.gold() < total) {
console.warn(
- `cannot afford construction ${this.constructionType} at level ${this.desiredLevel}`,
+ `cannot afford construction ${this.constructionType} stack=${this.desiredStackCount} techLevel=${this.desiredTechLevel}`,
);
this.active = false;
return;
@@ -128,32 +116,10 @@ export class ConstructionExecution implements Execution {
}
// Timed construction path: compute and reserve aggregate cost upfront
this.baseCost = this.mg.unitInfo(this.constructionType).cost(this.player);
- this.desiredLevel = this.computeDesiredLevel(
- this.constructionType,
- this.targetLevel,
- );
- const totalCost =
- aggregateStructureBuildCost(
- this.mg,
- this.player,
- this.constructionType,
- this.desiredLevel,
- // For upgradeable units, aggregateStructureBuildCost ignores multiplier
- this.mg
- .config()
- .structureUpgradeCostMultiplier(this.constructionType),
- ) +
- (this.constructionType === UnitType.Airfield
- ? computeBomberUpgradeCost(
- this.mg,
- this.player,
- this.bomberLevel ?? 1,
- this.desiredLevel,
- )
- : 0n);
+ const totalCost = this.computeTotalCost();
if (this.player.gold() < totalCost) {
console.warn(
- `cannot afford construction ${this.constructionType} at level ${this.desiredLevel}`,
+ `cannot afford construction ${this.constructionType} stack=${this.desiredStackCount} techLevel=${this.desiredTechLevel}`,
);
this.active = false;
return;
@@ -173,7 +139,7 @@ export class ConstructionExecution implements Execution {
// Reserve total aggregated cost upfront so funds are locked during construction
this.player.removeGold(this.reservedTotalCost);
this.construction.setConstructionType(this.constructionType);
- this.construction.setConstructionTargetLevel(this.desiredLevel);
+ this.construction.setConstructionTargetLevel(this.desiredStackCount);
// Apply construction speed modifier from tech effects
const speedMods = constructionSpeedModifiers(this.player);
this.ticksUntilComplete = Math.ceil(
@@ -224,7 +190,7 @@ export class ConstructionExecution implements Execution {
this.mg.addExecution(
new WarshipExecution(
{ owner: player, patrolTile: this.tile },
- this.desiredLevel,
+ this.desiredTechLevel,
),
);
break;
@@ -232,7 +198,7 @@ export class ConstructionExecution implements Execution {
this.mg.addExecution(
new SubmarineExecution(
{ owner: player, patrolTile: this.tile },
- this.desiredLevel,
+ this.desiredTechLevel,
),
);
break;
@@ -240,7 +206,15 @@ export class ConstructionExecution implements Execution {
this.mg.addExecution(
new FighterJetExecution(
{ owner: player, patrolTile: this.tile },
- this.desiredLevel,
+ this.desiredTechLevel,
+ ),
+ );
+ break;
+ case UnitType.Artillery:
+ this.mg.addExecution(
+ new ArtilleryExecution(
+ { owner: player, patrolTile: this.tile },
+ this.desiredTechLevel,
),
);
break;
@@ -259,15 +233,21 @@ export class ConstructionExecution implements Execution {
canSpawn,
{},
);
- this.applyUpgradesIfNeeded(built, this.desiredLevel);
+ this.applyStackingIfNeeded(built, this.desiredStackCount);
}
break;
case UnitType.MissileSilo:
this.mg.addExecution(
- new MissileSiloExecution(player, this.tile, this.desiredLevel),
+ new MissileSiloExecution(
+ player,
+ this.tile,
+ this.desiredTechLevel,
+ this.desiredStackCount,
+ ),
);
break;
case UnitType.DefensePost:
+ // DefensePost does not support stacking
this.mg.addExecution(new DefensePostExecution(player, this.tile));
break;
case UnitType.SAMLauncher:
@@ -277,8 +257,15 @@ export class ConstructionExecution implements Execution {
) {
player.addUpgrade(UpgradeType.CityAntiAir);
}
+ // SAM uses tech level for capability AND stack count for multiple missiles
this.mg.addExecution(
- new SAMLauncherExecution(player, this.tile, null, this.desiredLevel),
+ new SAMLauncherExecution(
+ player,
+ this.tile,
+ null,
+ this.desiredTechLevel,
+ this.desiredStackCount,
+ ),
);
break;
case UnitType.City:
@@ -300,16 +287,17 @@ export class ConstructionExecution implements Execution {
canSpawn,
{},
);
- this.applyUpgradesIfNeeded(built, this.desiredLevel);
+ this.applyStackingIfNeeded(built, this.desiredStackCount);
}
break;
case UnitType.Airfield:
+ // Airfield uses bomber level for capability AND stack count for multiple bombers
this.mg.addExecution(
new AirfieldExecution(
player,
this.tile,
- this.bomberLevel,
- this.desiredLevel,
+ this.bomberLevel ?? this.desiredTechLevel,
+ this.desiredStackCount,
),
);
break;
@@ -328,7 +316,7 @@ export class ConstructionExecution implements Execution {
canSpawn,
{},
);
- this.applyUpgradesIfNeeded(built, this.desiredLevel);
+ this.applyStackingIfNeeded(built, this.desiredStackCount);
}
break;
}
@@ -342,22 +330,75 @@ export class ConstructionExecution implements Execution {
return false;
}
- private computeDesiredLevel(type: UnitType, target?: number): number {
- if (target === undefined || target < 1) return 1;
- // For units, use player-specific max level based on researched techs
- // For structures, use player-specific max (e.g., SAM launchers depend on SAM tech level)
- const cap = isUpgradeableUnit(type)
- ? playerMaxUnitLevel(this.player, type)
- : playerMaxStructureLevel(this.player, type);
- return Math.max(1, Math.min(cap, target));
+ // Compute the stack count (how many instances in one tile)
+ private computeStackCount(type: UnitType): number {
+ // Use client-provided stack count, clamped to valid range
+ if (isStackableStructure(type) && this.stackCount && this.stackCount > 1) {
+ return Math.min(maxStackCount(type), this.stackCount);
+ }
+ return 1;
+ }
+
+ // Compute the tech level for upgradeable units/structures
+ private computeTechLevel(type: UnitType): number {
+ if (isUpgradeableUnit(type)) {
+ return playerMaxUnitLevel(this.player, type);
+ }
+ if (isTechUpgradeableStructure(type)) {
+ return playerMaxStructureTechLevel(this.player, type);
+ }
+ return 1;
}
- // step cost is centralized in ../game/Costs
+ // Compute total cost including stacking and tech upgrades
+ private computeTotalCost(): Gold {
+ // For combat units, use hardcoded tech-based costs
+ if (isUpgradeableUnit(this.constructionType)) {
+ return aggregateStructureBuildCost(
+ this.mg,
+ this.player,
+ this.constructionType,
+ this.desiredTechLevel,
+ 0, // multiplier ignored for upgradeable units
+ );
+ }
- private applyUpgradesIfNeeded(unit: Unit, desiredLevel: number) {
- const steps = Math.max(0, desiredLevel - 1);
+ // For structures, compute stacking cost
+ const stackCost = aggregateStructureBuildCost(
+ this.mg,
+ this.player,
+ this.constructionType,
+ this.desiredStackCount,
+ this.mg.config().structureUpgradeCostMultiplier(this.constructionType),
+ );
+
+ // Add bomber upgrade cost for airfields
+ if (this.constructionType === UnitType.Airfield) {
+ const bomberLvl = this.bomberLevel ?? this.desiredTechLevel;
+ return (
+ stackCost +
+ computeBomberUpgradeCost(
+ this.mg,
+ this.player,
+ bomberLvl,
+ this.desiredStackCount,
+ )
+ );
+ }
+
+ return stackCost;
+ }
+
+ // Apply stacking upgrades (HP bonus) for non-tech structures
+ private applyStackingIfNeeded(unit: Unit, stackCount: number) {
+ const steps = Math.max(0, stackCount - 1);
if (steps <= 0) return;
const impl = unit as any; // UnitImpl
+ // Set the stack count on the unit
+ if (typeof impl.setStackCount === "function") {
+ impl.setStackCount(stackCount);
+ }
+ // Apply HP bonuses via upgradeStructure
if (typeof impl.upgradeStructure === "function") {
for (let i = 0; i < steps; i++) {
impl.upgradeStructure();
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index cf9c4bc35..65599e929 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -1,4 +1,5 @@
import { Execution, Game, UnitType } from "../game/Game";
+import { getArtilleryMaxDistance } from "../game/UnitUpgrades";
import { isUpgradeableStructure } from "../game/Upgradeables";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
@@ -19,7 +20,7 @@ import { EmbargoExecution } from "./EmbargoExecution";
import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
-import { MarkPolicyDirectivesSeenExecution } from "./MarkPolicyDirectivesSeenExecution";
+import { MoveArtilleryExecution } from "./MoveArtilleryExecution";
import { MoveFighterJetExecution } from "./MoveFighterJetExecution";
import { MoveSubmarineExecution } from "./MoveSubmarineExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
@@ -27,11 +28,9 @@ import { NoOpExecution } from "./NoOpExecution";
import { ParatrooperAttackExecution } from "./ParatrooperAttackExecution";
import { ParatrooperRetreatExecution } from "./ParatrooperRetreatExecution";
import { PeaceRequestExecution } from "./PeaceRequestExecution";
-import { PolicyDirectiveSelectExecution } from "./PolicyDirectiveSelectExecution";
import { QuickChatExecution } from "./QuickChatExecution";
import { ResearchTreeSelectExecution } from "./ResearchTreeSelectExecution";
import { RetreatExecution } from "./RetreatExecution";
-import { ScorchedEarthExecution } from "./ScorchedEarthExecution";
import { SetAutoBombingExecution } from "./SetAutoBombingExecution";
import { SetInvestmentRateExecution } from "./SetInvestmentRateExecution";
import { SetResearchInvestmentExecution } from "./SetResearchInvestmentExecution";
@@ -89,6 +88,8 @@ export class Executor {
return new MoveSubmarineExecution(player, intent.unitId, intent.tile);
case "move_fighter_jet":
return new MoveFighterJetExecution(player, intent.unitId, intent.tile);
+ case "move_artillery":
+ return new MoveArtilleryExecution(player, intent.unitId, intent.tile);
case "bomber_intent":
return new BomberTargetExecution(
player,
@@ -151,7 +152,29 @@ export class Executor {
return new SetResearchInvestmentExecution(player, intent.rate);
case "embargo":
return new EmbargoExecution(player, intent.targetID, intent.action);
- case "build_unit":
+ case "build_unit": {
+ // Enforce distance cap for artillery construction
+ if (intent.unit === UnitType.Artillery) {
+ const nearest = player
+ .units(UnitType.Factory)
+ .sort(
+ (a, b) =>
+ this.mg.euclideanDistSquared(a.tile(), intent.tile) -
+ this.mg.euclideanDistSquared(b.tile(), intent.tile),
+ )[0];
+ if (nearest) {
+ const lvl = intent.targetLevel ?? 1;
+ const maxDist = getArtilleryMaxDistance(lvl);
+ const distSq = this.mg.euclideanDistSquared(
+ nearest.tile(),
+ intent.tile,
+ );
+ if (distSq > maxDist * maxDist) {
+ // Out of range; silently reject
+ return new NoOpExecution();
+ }
+ }
+ }
return new ConstructionExecution(
player,
intent.unit,
@@ -159,8 +182,7 @@ export class Executor {
intent.targetLevel,
intent.bomberLevel,
);
- case "activate_scorched_earth":
- return new ScorchedEarthExecution(player);
+ }
case "upgrade_structure": {
const unit = player.units().find((u) => u.id() === intent.unitId);
if (!unit || unit.owner() !== player) return new NoOpExecution();
@@ -175,14 +197,6 @@ export class Executor {
}
case "research_tree_select":
return new ResearchTreeSelectExecution(player, intent.techId);
- case "policy_directive_select":
- return new PolicyDirectiveSelectExecution(
- player,
- intent.directiveId,
- intent.optionId,
- );
- case "mark_policy_directives_seen":
- return new MarkPolicyDirectivesSeenExecution(player);
case "quick_chat":
return new QuickChatExecution(
diff --git a/src/core/execution/FighterJetExecution.ts b/src/core/execution/FighterJetExecution.ts
index 5a8e80f29..d626f1607 100644
--- a/src/core/execution/FighterJetExecution.ts
+++ b/src/core/execution/FighterJetExecution.ts
@@ -101,6 +101,7 @@ export class FighterJetExecution implements Execution {
UnitType.FighterJet,
UnitType.CargoPlane,
UnitType.Paratrooper,
+ UnitType.Artillery,
];
if (ownerHasUpgrade) {
@@ -131,12 +132,14 @@ export class FighterJetExecution implements Execution {
return 3;
case UnitType.CargoPlane:
return 4;
- case UnitType.TransportShip:
+ case UnitType.Artillery:
return 5;
- case UnitType.Warship:
+ case UnitType.TransportShip:
return 6;
- case UnitType.TradeShip:
+ case UnitType.Warship:
return 7;
+ case UnitType.TradeShip:
+ return 8;
default:
return 99;
}
diff --git a/src/core/execution/MarkPolicyDirectivesSeenExecution.ts b/src/core/execution/MarkPolicyDirectivesSeenExecution.ts
deleted file mode 100644
index e42868c13..000000000
--- a/src/core/execution/MarkPolicyDirectivesSeenExecution.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Execution, Game, Player } from "../game/Game";
-
-/**
- * Execution to mark all policy directives as seen for the player.
- */
-export class MarkPolicyDirectivesSeenExecution implements Execution {
- private _active = true;
-
- constructor(private readonly player: Player) {}
-
- isActive(): boolean {
- return this._active;
- }
-
- activeDuringSpawnPhase(): boolean {
- return true;
- }
-
- init(_mg: Game, _ticks: number): void {}
-
- tick(_ticks: number): void {
- this.player.markPolicyDirectivesSeen();
- this._active = false;
- }
-}
diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts
index 34d98fe72..5bf9e72fc 100644
--- a/src/core/execution/MissileSiloExecution.ts
+++ b/src/core/execution/MissileSiloExecution.ts
@@ -10,6 +10,7 @@ export class MissileSiloExecution implements Execution {
private player: Player,
private tile: TileRef,
private desiredLevel?: number,
+ private stackCount: number = 1,
) {}
init(mg: Game, ticks: number): void {
@@ -28,6 +29,20 @@ export class MissileSiloExecution implements Execution {
}
this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {});
+ // Apply stack count (multiple silos in one tile)
+ if (this.stackCount > 1) {
+ const impl = this.silo as any;
+ if (typeof impl.setStackCount === "function") {
+ impl.setStackCount(this.stackCount);
+ }
+ // Apply HP bonuses for stacking via upgradeStructure
+ if (typeof impl.upgradeStructure === "function") {
+ for (let i = 0; i < this.stackCount - 1; i++) {
+ impl.upgradeStructure();
+ }
+ }
+ }
+
// Apply upgrades up to cap 3 if requested
const level = this.computeDesiredLevel(
UnitType.MissileSilo,
diff --git a/src/core/execution/MoveArtilleryExecution.ts b/src/core/execution/MoveArtilleryExecution.ts
new file mode 100644
index 000000000..0a7a33474
--- /dev/null
+++ b/src/core/execution/MoveArtilleryExecution.ts
@@ -0,0 +1,47 @@
+import { Execution, Game, Player, UnitType } from "../game/Game";
+import { TileRef } from "../game/GameMap";
+import { getArtilleryMaxDistance } from "../game/UnitUpgrades";
+
+export class MoveArtilleryExecution implements Execution {
+ constructor(
+ private readonly owner: Player,
+ private readonly unitId: number,
+ private readonly position: TileRef,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ const artillery = this.owner
+ .units(UnitType.Artillery)
+ .find((u) => u.id() === this.unitId);
+ if (!artillery) {
+ return;
+ }
+ if (!artillery.isActive()) {
+ return;
+ }
+
+ // Clamp send distance by artillery level to avoid expensive long-range paths
+ const lvl = artillery.level ? artillery.level() : 1;
+ const maxDist = getArtilleryMaxDistance(lvl);
+ const distSq = mg.euclideanDistSquared(artillery.tile(), this.position);
+ if (distSq > maxDist * maxDist) {
+ return;
+ }
+ // Move intent should immediately head toward the clicked tile, while
+ // also updating the patrol anchor so future roaming centers there.
+ artillery.setPatrolTile(this.position);
+ artillery.setTargetTile(this.position);
+ // Clear any current target unit so movement isn't preempted by combat.
+ artillery.setTargetUnit(undefined);
+ }
+
+ tick(ticks: number): void {}
+
+ isActive(): boolean {
+ return false;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/execution/ParatrooperAttackExecution.ts b/src/core/execution/ParatrooperAttackExecution.ts
index a8ccc0a80..4eb2ca607 100644
--- a/src/core/execution/ParatrooperAttackExecution.ts
+++ b/src/core/execution/ParatrooperAttackExecution.ts
@@ -45,7 +45,7 @@ export class ParatrooperAttackExecution implements Execution {
init(game: Game, ticks: number): void {
this.mg = game;
- if (!this.attacker.hasUpgrade(UpgradeType.AirUpgrade1)) {
+ if (!this.attacker.hasUpgrade(UpgradeType.JetEngines)) {
return;
}
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index 8680f64ba..0ae68705e 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -182,11 +182,12 @@ export class PlayerExecution implements Execution {
);
if (available.length === 0) return;
- // Allocation: 50% to priority, 50% split among remaining; if no valid priority, split evenly
- const priorityId: string | null =
- (this.player as any).researchPriority?.() ?? null;
- const priorityInSet =
- priorityId !== null && available.some((n) => n.id === priorityId);
+ // Get all priorities and check which are available
+ const allPriorities: Set =
+ (this.player as any).researchPriorities?.() ?? new Set();
+ const availablePriorities = available.filter((n) =>
+ allPriorities.has(n.id),
+ );
const k = this.config.researchK();
const bMin = this.config.researchBeakerMin();
@@ -240,16 +241,20 @@ export class PlayerExecution implements Execution {
};
const alloc: Record = {};
- if (priorityId && !priorityInSet) {
- // Priority target not available: allocate 60% to the frontier of its missing prereqs
- const pathSet = buildMissingPrereqPath(priorityId);
- const frontier = available.filter((n) => pathSet.has(n.id));
+ if (allPriorities.size > 0 && availablePriorities.length === 0) {
+ // Has priorities but none available: allocate 60% to the frontier of their missing prereqs
+ const allPathSets = new Set();
+ for (const priorityId of allPriorities) {
+ const pathSet = buildMissingPrereqPath(priorityId);
+ pathSet.forEach((id) => allPathSets.add(id));
+ }
+ const frontier = available.filter((n) => allPathSets.has(n.id));
if (frontier.length > 0) {
const priorityShare = 0.6 * xTotal;
const shareFrontier = priorityShare / frontier.length;
for (const n of frontier)
alloc[n.id] = (alloc[n.id] ?? 0) + shareFrontier;
- const others = available.filter((n) => !pathSet.has(n.id));
+ const others = available.filter((n) => !allPathSets.has(n.id));
const remaining = xTotal - priorityShare;
const shareOthers = others.length > 0 ? remaining / others.length : 0;
for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + shareOthers;
@@ -258,14 +263,22 @@ export class PlayerExecution implements Execution {
const share = xTotal / available.length;
for (const n of available) alloc[n.id] = share;
}
- } else if (priorityInSet && available.length > 1) {
+ } else if (
+ availablePriorities.length > 0 &&
+ available.length > availablePriorities.length
+ ) {
+ // Some priorities available: 60% split among priorities, 40% among others
const priorityShare = 0.6 * xTotal;
- alloc[priorityId!] = (alloc[priorityId!] ?? 0) + priorityShare;
- const others = available.filter((n) => n.id !== priorityId);
+ const sharePriority = priorityShare / availablePriorities.length;
+ for (const n of availablePriorities) {
+ alloc[n.id] = sharePriority;
+ }
+ const others = available.filter((n) => !allPriorities.has(n.id));
const share =
others.length > 0 ? (xTotal - priorityShare) / others.length : 0;
for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + share;
} else {
+ // No priorities or all available are prioritized: even split
const share = xTotal / available.length;
for (const n of available) alloc[n.id] = share;
}
diff --git a/src/core/execution/PolicyDirectiveSelectExecution.ts b/src/core/execution/PolicyDirectiveSelectExecution.ts
deleted file mode 100644
index bf14c7cfc..000000000
--- a/src/core/execution/PolicyDirectiveSelectExecution.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { Execution, Game, Player, UpgradeType } from "../game/Game";
-import {
- getPolicyDirective,
- isDirectiveUnlocked,
- type PolicyDirectiveId,
-} from "../tech/PolicyDirectives";
-
-/**
- * Execution to set the player's policy directive choice.
- */
-export class PolicyDirectiveSelectExecution implements Execution {
- private _active = true;
- private mg: Game | null = null;
-
- constructor(
- private readonly player: Player,
- private readonly directiveId: string,
- private readonly optionId: string,
- ) {}
-
- isActive(): boolean {
- return this._active;
- }
-
- activeDuringSpawnPhase(): boolean {
- return true;
- }
-
- init(_mg: Game, _ticks: number): void {
- this.mg = _mg;
- }
-
- tick(_ticks: number): void {
- // Validate the directive exists
- const directive = getPolicyDirective(this.directiveId as PolicyDirectiveId);
- if (!directive) {
- console.warn(
- `[PolicyDirectiveSelectExecution] Unknown directive: ${this.directiveId}`,
- );
- this._active = false;
- return;
- }
-
- // Validate the directive is unlocked for this player
- if (
- !isDirectiveUnlocked(
- this.directiveId as PolicyDirectiveId,
- (techId) => this.player.hasResearchedTech?.(techId) ?? false,
- )
- ) {
- console.warn(
- `[PolicyDirectiveSelectExecution] Directive not unlocked: ${this.directiveId}`,
- );
- this._active = false;
- return;
- }
-
- // Validate the option exists
- const option = directive.options.find((o) => o.id === this.optionId);
- if (!option) {
- console.warn(
- `[PolicyDirectiveSelectExecution] Unknown option: ${this.optionId} for directive ${this.directiveId}`,
- );
- this._active = false;
- return;
- }
-
- // Check if a choice has already been made (policy directives are one-time choices)
- const existingChoice = this.player.getPolicyChoice(this.directiveId);
- if (existingChoice !== null) {
- console.warn(
- `[PolicyDirectiveSelectExecution] Choice already made for directive: ${this.directiveId}`,
- );
- this._active = false;
- return;
- }
-
- // Set the policy choice
- this.player.setPolicyChoice(this.directiveId, this.optionId);
-
- // Apply upgrade effects from the chosen option
- if (option.effects.grantsInternationalTrade) {
- this.player.addUpgrade(UpgradeType.InternationalTrade);
- }
-
- this._active = false;
- }
-}
diff --git a/src/core/execution/ResearchTreeSelectExecution.ts b/src/core/execution/ResearchTreeSelectExecution.ts
index 4b15407d1..ce893ff7e 100644
--- a/src/core/execution/ResearchTreeSelectExecution.ts
+++ b/src/core/execution/ResearchTreeSelectExecution.ts
@@ -39,18 +39,19 @@ export class ResearchTreeSelectExecution implements Execution {
// Complete the research immediately; side-effects are handled by addResearchedTech()
(this.player as any).addResearchedTech?.(this.techId);
- // Clear any existing priority since research is completed
- (this.player as any).setResearchPriority?.(null);
+ // Remove from priorities since research is completed
+ const priorities = (this.player as any).researchPriorities?.();
+ if (priorities?.has(this.techId)) {
+ (this.player as any).setResearchPriority?.(this.techId); // Toggle off
+ }
this._active = false;
return;
}
// Fall through to priority toggle if not available or already researched
}
- // Default behavior: toggle research priority on click
- const current = (this.player as any).researchPriority?.() ?? null;
- const next = current === this.techId ? null : this.techId;
- (this.player as any).setResearchPriority?.(next);
+ // Default behavior: toggle research priority (add/remove from set)
+ (this.player as any).setResearchPriority?.(this.techId);
this._active = false;
}
}
diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts
index 379561bf0..e21c79cb5 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -8,6 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
+import { playerMaxStructureTechLevel } from "../game/Upgradeables";
import { PseudoRandom } from "../PseudoRandom";
import { SAMMissileExecution } from "./SAMMissileExecution";
@@ -45,7 +46,11 @@ class SAMTargetingSystem {
private effectiveSamRange(): number {
const base = this.mg.config().defaultSamRange();
const bonus = this.mg.config().samRangeUpgradePercent();
- const lvl = this.sam.level?.() ?? 1;
+ // Use player's SAM tech level, not unit level (which is stack count)
+ const lvl = playerMaxStructureTechLevel(
+ this.sam.owner(),
+ UnitType.SAMLauncher,
+ );
if (lvl <= 1) return base;
// Apply per-upgrade multiplicative increase
const factor = Math.pow(1 + bonus, lvl - 1);
@@ -83,6 +88,12 @@ class SAMTargetingSystem {
}
public getSingleTarget(): Target | null {
+ const targets = this.getMultipleTargets(1);
+ return targets.length > 0 ? targets[0] : null;
+ }
+
+ // Get multiple targets for stacked SAMs - each SAM can target a different nuke
+ public getMultipleTargets(maxCount: number): Target[] {
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.effectiveSamRange() * 1.5;
const nukes = this.mg.nearbyUnits(
@@ -104,6 +115,10 @@ class SAMTargetingSystem {
if (this.nukesToIgnore.has(nuke.unit.id())) {
continue;
}
+ // Skip nukes already being targeted by a SAM missile
+ if (nuke.unit.targetedBySAM()) {
+ continue;
+ }
const interceptionTile = this.computeInterceptionTile(nuke.unit);
if (interceptionTile !== undefined) {
targets.push({ unit: nuke.unit, tile: interceptionTile });
@@ -113,8 +128,9 @@ class SAMTargetingSystem {
}
}
- return (
- targets.sort((a: Target, b: Target) => {
+ // Sort by priority (H-bombs first) and return up to maxCount
+ return targets
+ .sort((a: Target, b: Target) => {
// Prioritize Hydrogen Bombs
if (
a.unit.type() === UnitType.HydrogenBomb &&
@@ -128,8 +144,8 @@ class SAMTargetingSystem {
return 1;
return 0;
- })[0] ?? null
- );
+ })
+ .slice(0, maxCount);
}
}
@@ -153,6 +169,7 @@ export class SAMLauncherExecution implements Execution {
private tile: TileRef | null,
private sam: Unit | null = null,
private desiredLevel?: number,
+ private stackCount: number = 1, // Number of stacked SAMs (fires multiple missiles)
) {
if (sam !== null) {
this.tile = sam.tile();
@@ -203,12 +220,20 @@ export class SAMLauncherExecution implements Execution {
return;
}
this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {});
- // Apply upgrades up to cap 3 if requested
+ // Apply tech level upgrades
const level = this.computeDesiredLevel(
UnitType.SAMLauncher,
this.desiredLevel,
);
this.applyUpgrades(this.sam, level);
+ // Set stack count for multiple missiles
+ if (this.stackCount > 1) {
+ (this.sam as any).setStackCount(this.stackCount);
+ // Apply HP bonuses for stacking (one upgrade per extra stack)
+ for (let i = 1; i < this.stackCount; i++) {
+ (this.sam as any).upgradeStructure();
+ }
+ }
}
this.targetingSystem ??= new SAMTargetingSystem(
this.mg,
@@ -249,6 +274,7 @@ export class SAMLauncherExecution implements Execution {
},
);
+ // Get a single target - stacked SAMs use launchesRemaining to fire multiple times before cooldown
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.targetingSystem.getSingleTarget();
@@ -259,11 +285,8 @@ export class SAMLauncherExecution implements Execution {
this.sam.touch();
}
- const isSingleTarget = !!(target && !target.unit.targetedBySAM());
- if (
- (isSingleTarget || mirvWarheadTargets.length > 0) &&
- !isPeaceTimerActive
- ) {
+ const hasTarget = target !== null;
+ if ((hasTarget || mirvWarheadTargets.length > 0) && !isPeaceTimerActive) {
this.sam.launch();
const type =
mirvWarheadTargets.length > 0
@@ -302,6 +325,7 @@ export class SAMLauncherExecution implements Execution {
mirvWarheadTargets.length,
);
} else if (target !== null) {
+ // Fire one missile at the target
target.unit.setTargetedBySAM(true);
this.mg.addExecution(
new SAMMissileExecution(
@@ -329,7 +353,11 @@ export class SAMLauncherExecution implements Execution {
const effectiveRange = (() => {
const base = this.mg.config().defaultSamRange();
const bonus = this.mg.config().samRangeUpgradePercent();
- const lvl = this.sam!.level?.() ?? 1;
+ // Use player's SAM tech level, not unit level (which is stack count)
+ const lvl = playerMaxStructureTechLevel(
+ this.sam!.owner(),
+ UnitType.SAMLauncher,
+ );
if (lvl <= 1) return base;
const factor = Math.pow(1 + bonus, lvl - 1);
return Math.round(base * factor);
diff --git a/src/core/execution/ScorchedEarthExecution.ts b/src/core/execution/ScorchedEarthExecution.ts
deleted file mode 100644
index 3c2215c33..000000000
--- a/src/core/execution/ScorchedEarthExecution.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { Execution, Player, UpgradeType } from "../game/Game";
-import { GameImpl } from "../game/GameImpl";
-import { RESEARCH_TECH_IDS } from "../tech/TechEffects";
-
-export class ScorchedEarthExecution implements Execution {
- private mg: GameImpl;
- private _isActive = true;
-
- constructor(private player: Player) {}
-
- public static fromIntent(
- game: GameImpl,
- intent: {
- type: "activate_scorched_earth";
- clientID: string;
- },
- ): ScorchedEarthExecution {
- const player = game.playerByClientID(intent.clientID);
- if (!player) {
- throw new Error(`Player with clientID ${intent.clientID} not found`);
- }
- return new ScorchedEarthExecution(player);
- }
-
- public isActive(): boolean {
- return this._isActive;
- }
-
- public activeDuringSpawnPhase(): boolean {
- return false;
- }
-
- init(mg: GameImpl, ticks: number): void {
- this.mg = mg;
-
- // Already activated
- if (this.player.hasUpgrade(UpgradeType.ScorchedEarth)) {
- this._isActive = false;
- return;
- }
-
- // Must have researched Mechanized Warfare Doctrine to unlock Scorched Earth
- if (
- !this.player.hasResearchedTech(
- RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE,
- )
- ) {
- this._isActive = false;
- return;
- }
-
- // Check gold cost
- const cost = this.mg.config().scorchedEarthActivationCost(this.player);
- if (this.player.gold() < cost) {
- this._isActive = false;
- return;
- }
-
- // Deduct gold and activate
- this.player.removeGold(cost);
- this.player.addUpgrade(UpgradeType.ScorchedEarth);
-
- // Destroy roads only (keep techs and upgrades)
- this.mg.destroyPlayerRoads(this.player);
- this.player.setRoadInvestmentRate(0);
- this.mg.markPlayerNodesForReconnection(this.player);
-
- this._isActive = false;
- }
-
- public tick(ticks: number): void {
- // Logic is in init()
- }
-}
diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts
index ff02f7579..4c94f3fc4 100644
--- a/src/core/execution/ShellExecution.ts
+++ b/src/core/execution/ShellExecution.ts
@@ -93,6 +93,12 @@ export class ShellExecution implements Execution {
const roll = this.random.nextInt(0, 5);
const step = (range.max - range.min) / 5;
return Math.round(range.min + roll * step);
+ } else if (this.ownerUnit.type() === UnitType.Artillery) {
+ const level = this.ownerUnit.level ? this.ownerUnit.level() : 1;
+ const range = this.mg.config().artilleryDamageRange(level);
+ const roll = this.random.nextInt(0, 5);
+ const step = (range.max - range.min) / 5;
+ return Math.round(range.min + roll * step);
}
// Default: shell damage based on base value and 5-step multiplier
diff --git a/src/core/execution/UnitCreationHelper.ts b/src/core/execution/UnitCreationHelper.ts
index cef394386..2900eec5e 100644
--- a/src/core/execution/UnitCreationHelper.ts
+++ b/src/core/execution/UnitCreationHelper.ts
@@ -1,4 +1,4 @@
-import { Game, Gold, Player, UnitType } from "../game/Game";
+import { Game, Gold, Player, TerrainType, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { ConstructionExecution } from "./ConstructionExecution";
@@ -84,6 +84,7 @@ export class UnitCreationHelper {
return (
this.maybeSpawnStructure(UnitType.Airfield, 1) ||
this.maybeSpawnNavalUnit() ||
+ this.maybeSpawnArtillery() ||
this.maybeSpawnSAMLauncher() ||
this.maybeSpawnStructure(UnitType.MissileSilo, 1) ||
this.maybeSpawnDefensePost()
@@ -259,6 +260,61 @@ export class UnitCreationHelper {
return false;
}
+ private maybeSpawnArtillery(): boolean {
+ const artilleryCount = this.player.units(UnitType.Artillery).length;
+
+ const factories = this.player.units(UnitType.Factory);
+ if (factories.length > 0 && artilleryCount === 0) {
+ if (this.player.gold() > this.cost(UnitType.Artillery)) {
+ const factory = this.random.randElement(factories);
+ const targetTile = this.landUnitSpawnTile(factory.tile());
+ if (targetTile === null) {
+ return false;
+ }
+ const canBuild = this.player.canBuild(UnitType.Artillery, targetTile);
+ if (canBuild === false) {
+ return false;
+ }
+ this.mg.addExecution(
+ new ConstructionExecution(
+ this.player,
+ UnitType.Artillery,
+ targetTile,
+ ),
+ );
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private landUnitSpawnTile(factoryTile: TileRef): TileRef | null {
+ const radius = 100;
+ for (let attempts = 0; attempts < 50; attempts++) {
+ const randX = this.random.nextInt(
+ this.mg.x(factoryTile) - radius,
+ this.mg.x(factoryTile) + radius,
+ );
+ const randY = this.random.nextInt(
+ this.mg.y(factoryTile) - radius,
+ this.mg.y(factoryTile) + radius,
+ );
+ if (!this.mg.isValidCoord(randX, randY)) {
+ continue;
+ }
+ const tile = this.mg.ref(randX, randY);
+ // Must be land and not barrier
+ if (
+ this.mg.isOcean(tile) ||
+ this.mg.terrainType(tile) === TerrainType.Barrier
+ ) {
+ continue;
+ }
+ return tile;
+ }
+ return null;
+ }
+
private navalUnitSpawnTile(portTile: TileRef): TileRef | null {
const radius = 250;
for (let attempts = 0; attempts < 50; attempts++) {
diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts
index 5492f4d17..31dc28a4a 100644
--- a/src/core/execution/UpgradeStructureExecution.ts
+++ b/src/core/execution/UpgradeStructureExecution.ts
@@ -59,6 +59,9 @@ export class UpgradeStructureExecution implements Execution {
return;
}
this.player.removeGold(upgradeCost);
+ // Increment stack count first, then apply HP bonus
+ const currentStack = this.unit.stackCount?.() ?? 1;
+ (this.unit as UnitImpl).setStackCount(currentStack + 1);
(this.unit as UnitImpl).upgradeStructure();
this._isActive = false;
return;
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index 7e40ff7be..3e110499f 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -107,6 +107,7 @@ export class WarshipExecution implements Execution {
UnitType.Warship,
UnitType.TradeShip,
UnitType.Submarine,
+ UnitType.Artillery,
],
);
const potentialTargets: { unit: Unit; distSquared: number }[] = [];
@@ -192,6 +193,18 @@ export class WarshipExecution implements Execution {
)
return 1;
+ // Then Artillery (coastal land-based threat)
+ if (
+ unitA.type() === UnitType.Artillery &&
+ unitB.type() !== UnitType.Artillery
+ )
+ return -1;
+ if (
+ unitA.type() !== UnitType.Artillery &&
+ unitB.type() === UnitType.Artillery
+ )
+ return 1;
+
// Then Warships
if (
unitA.type() === UnitType.Warship &&
diff --git a/src/core/game/Costs.ts b/src/core/game/Costs.ts
index 4789e7efa..ef1dd80c2 100644
--- a/src/core/game/Costs.ts
+++ b/src/core/game/Costs.ts
@@ -36,9 +36,15 @@ export function aggregateStructureBuildCost(
desiredLevel: number,
multiplier: number,
): Gold {
- // For upgradeable units (Bomber, Fighter, Warship, Submarine), use hardcoded total costs
+ // For upgradeable units (Bomber, Fighter, Warship, Submarine, Artillery), use hardcoded total costs
const unitUpgrades = getUnitUpgradeData(type);
if (unitUpgrades) {
+ // Check if player has infinite gold enabled (returns 0 for base cost)
+ const baseCost = unitInfoProvider.unitInfo(type).cost(player);
+ if (baseCost === 0n) {
+ // Infinite gold mode - all upgrade costs are also 0
+ return 0n;
+ }
// UnitUpgrades now stores total cost at each level, just return it directly
return getUnitLevelCost(type, desiredLevel);
}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 262214d2b..7a8b0bd4f 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -176,27 +176,21 @@ export enum UnitType {
FighterJet = "Fighter Jet", // Represents a Fighter Jet unit.
DoomsdayDevice = "Doomsday Device",
AABullet = "AA Bullet", // City anti-aircraft bullet for targeting planes
+ Artillery = "Artillery", // Land-based artillery unit
}
export enum UpgradeType {
Roads = "Roads",
- // Land Upgrades
- ScorchedEarth = "ScorchedEarth",
-
// Economy Upgrades
InternationalTrade = "InternationalTrade",
- // TEMPORARILY DISABLED: StructureInsurance = "StructureInsurance",
HospitalResearch = "HospitalResearch",
ResearchLabResearch = "ResearchLabResearch",
// Water Upgrades
SubmarineResearch = "SubmarineResearch",
NuclearSubmarineResearch = "NuclearSubmarineResearch",
- WaterUpgrade1 = "WaterUpgrade1",
- WaterUpgrade2 = "WaterUpgrade2",
WarshipAntiAir = "WarshipAntiAir",
- WaterUpgrade3 = "WaterUpgrade3",
// Warship level upgrades (Early Cold War Cruisers gives level 1)
WarshipLevel1 = "WarshipLevel1",
WarshipLevel2 = "WarshipLevel2",
@@ -210,7 +204,6 @@ export enum UpgradeType {
JetEngines = "JetEngines",
AirUpgrade1 = "AirUpgrade1",
CityAntiAir = "CityAntiAir",
- AirUpgrade3 = "AirUpgrade3",
FighterJetNavalTargeting = "FighterJetNavalTargeting",
// Fighter level upgrades (Jet Engines gives level 1 by default)
FighterLevel2 = "FighterLevel2",
@@ -226,17 +219,16 @@ export enum UpgradeType {
// Land Upgrades
MilitaryAcademy = "MilitaryAcademy",
+ // Artillery upgrades (Land-2 tech unlocks Artillery)
+ ArtilleryResearch = "ArtilleryResearch",
+ ArtilleryLevel2 = "ArtilleryLevel2",
+ ArtilleryLevel3 = "ArtilleryLevel3",
// Nuclear Upgrades
NuclearFission = "NuclearFission",
ThermonuclearStaging = "ThermonuclearStaging",
MIRVTechnology = "MIRVTechnology",
DoomsdayDeviceResearch = "DoomsdayDeviceResearch",
-
- // Dummy Economy Upgrades
- EconomyUpgrade1 = "EconomyUpgrade1",
- EconomyUpgrade2 = "EconomyUpgrade2",
- EconomyUpgrade3 = "EconomyUpgrade3",
}
const _structureTypes: ReadonlySet = new Set([
@@ -280,6 +272,10 @@ export interface UnitParamsMap {
patrolTile: TileRef;
};
+ [UnitType.Artillery]: {
+ patrolTile: TileRef;
+ };
+
[UnitType.Shell]: Record;
[UnitType.AABullet]: Record;
@@ -572,7 +568,8 @@ export interface Unit {
isPeriodicallyVisible(): boolean;
// Upgrades
- level(): number; // Current upgrade level (>=1)
+ level(): number; // Current upgrade/tech level (>=1) - for SAM/Airfield this is the tech tier
+ stackCount(): number; // Number of stacked instances (>=1) - for all structures
upgradeStructure(): void; // Applies structure-specific upgrade effects (currently City only)
effectiveMaxHealth(): number; // Base max health + bonuses from upgrades
}
@@ -694,13 +691,6 @@ export interface Player {
addResearchedTech(techId: string): void;
removeResearchedTechsByCategory(category: Category): void;
- // Policy Directives (player choices linked to research)
- getPolicyChoice(directiveId: string): string | null;
- setPolicyChoice(directiveId: string, optionId: string): void;
- getAllPolicyChoices(): ReadonlyMap;
- hasUnseenPolicyDirectives(): boolean;
- markPolicyDirectivesSeen(): void;
-
captureUnit(unit: Unit): void;
// Relations & Diplomacy
@@ -980,7 +970,6 @@ export enum MessageType {
SENT_TROOPS_TO_PLAYER,
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
- INSURANCE_REFUND,
WARN,
PEACE_TIMER_BLOCKED,
DOOMSDAY_DEVICE_ACTIVATED,
@@ -1030,7 +1019,6 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.SENT_TROOPS_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_TROOPS_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.CHAT]: MessageCategory.CHAT,
- [MessageType.INSURANCE_REFUND]: MessageCategory.FINANCIAL,
[MessageType.DOOMSDAY_DEVICE_ACTIVATED]: MessageCategory.ATTACK,
} as const;
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 019163c79..4c446a403 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -151,6 +151,10 @@ export interface UnitUpdate {
ghostExpiresAt?: Tick;
// Structure upgrade level (>=1). Cities increase level by 1 per upgrade.
level?: number;
+ // Stack count (>=1). Number of stacked instances for stackable structures.
+ stackCount?: number;
+ // Missile silo specific: remaining launches before cooldown (for stacked silos)
+ launchesRemaining?: number;
// Trade-ship specific, for precise UI without heuristics
tradeRouteStartOwnerID?: number; // smallID of start port owner
tradeRouteEndOwnerID?: number; // smallID of end port owner
@@ -232,8 +236,10 @@ export interface PlayerUpdate {
researchTreeTechs: string[];
// Research progress (beakers) per tech id (optional; omitted if none)
researchTreeBeakers?: Record;
- // Currently selected research priority tech id (optional)
+ // Currently selected research priority tech id (optional, legacy single priority)
researchPriorityTech?: string | null;
+ // All selected research priority tech ids (optional; omitted if none)
+ researchPriorities?: string[];
// Policy directive choices: directiveId -> optionId (optional; omitted if none)
policyChoices?: Record;
// Whether the player has unseen policy directives to review
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index d2d65c382..eb5547b2f 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -193,6 +193,17 @@ export class UnitView {
return (this.data as any).level ?? 1;
}
+ // Stack count (>=1). Number of stacked instances for stackable structures.
+ stackCount(): number {
+ return (this.data as any).stackCount ?? 1;
+ }
+
+ // Missile silo specific: remaining launches before cooldown (for stacked silos)
+ launchesRemaining(): number | null {
+ const v = (this.data as any).launchesRemaining as number | undefined;
+ return v ?? null;
+ }
+
// Airfield-specific: bomber upgrade level. Defaults to 1.
bomberLevel(): number {
return (this.data as any).bomberLevel ?? 1;
@@ -333,13 +344,8 @@ export class PlayerView {
researchPriorityTech(): string | null {
return this.data.researchPriorityTech ?? null;
}
-
- // Policy Directive access
- getPolicyChoice(directiveId: string): string | null {
- return this.data.policyChoices?.[directiveId] ?? null;
- }
- hasUnseenPolicyDirectives(): boolean {
- return this.data.hasUnseenPolicyDirectives ?? false;
+ researchPriorities(): Set {
+ return new Set(this.data.researchPriorities ?? []);
}
// Aggregate research progress across levels in [0, L] (L = max level in tree)
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index f13485330..2e99cf2f5 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1,7 +1,6 @@
import { renderNumber, renderTroops } from "../../client/Utils";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID } from "../Schemas";
-import { getDirectivesUnlockedByTech } from "../tech/PolicyDirectives";
import { Category, findTech } from "../tech/ResearchTree";
import {
applyTechCompletionEffects,
@@ -40,6 +39,7 @@ import {
PlayerType,
Relation,
Team,
+ TerrainType,
TerraNullius,
Tick,
Unit,
@@ -55,6 +55,7 @@ import {
canBuildTransportShip,
} from "./TransportShipUtils";
import { UnitImpl } from "./UnitImpl";
+import { playerMaxUnitLevel } from "./Upgradeables";
interface Target {
tick: Tick;
@@ -100,12 +101,8 @@ export class PlayerImpl implements Player {
private _researchTreeTechs: Set = new Set();
// Per-match research progress (beakers) per tech
private _researchBeakers: Map = new Map();
- // Currently selected research priority tech id
- private _researchPriority: string | null = null;
- // Policy directive choices: directiveId -> optionId
- private _policyChoices: Map = new Map();
- // Track unseen policy directives (based on newly unlocked techs)
- private _unseenPolicyDirectives: Set = new Set();
+ // Currently selected research priority tech ids (can have multiple)
+ private _researchPriorities: Set = new Set();
private _flag: string | undefined;
private _name: string;
@@ -139,7 +136,7 @@ export class PlayerImpl implements Player {
structures: UnitType[];
preferClosest: boolean;
} | null = null;
- private _autoBombingEnabled: boolean = false;
+ private _autoBombingEnabled: boolean = true;
public bombersOnTarget = new Map();
// Cached capital (geographic center) of player's territory
@@ -253,12 +250,12 @@ export class PlayerImpl implements Player {
this._researchBeakers.size > 0
? Object.fromEntries(this._researchBeakers)
: undefined,
- researchPriorityTech: this._researchPriority,
- policyChoices:
- this._policyChoices.size > 0
- ? Object.fromEntries(this._policyChoices)
+ researchPriorityTech:
+ this._researchPriorities.values().next().value ?? null,
+ researchPriorities:
+ this._researchPriorities.size > 0
+ ? Array.from(this._researchPriorities)
: undefined,
- hasUnseenPolicyDirectives: this._unseenPolicyDirectives.size > 0,
};
}
@@ -347,19 +344,26 @@ export class PlayerImpl implements Player {
// Count of units owned by the player, including construction
unitsOwned(type: UnitType): number {
let total = 0;
+ // All stackable structure types
+ const stackableTypes = new Set([
+ UnitType.City,
+ UnitType.Port,
+ UnitType.Hospital,
+ UnitType.Academy,
+ UnitType.ResearchLab,
+ UnitType.Factory,
+ UnitType.SAMLauncher,
+ UnitType.Airfield,
+ UnitType.MissileSilo,
+ ]);
+ const isStackable = stackableTypes.has(type);
+
for (const unit of this._units) {
if (unit.type() === type) {
- if (
- type === UnitType.City ||
- type === UnitType.Port ||
- type === UnitType.Hospital ||
- type === UnitType.Academy ||
- type === UnitType.ResearchLab ||
- type === UnitType.Factory
- ) {
- // Upgraded cities, ports, hospitals, and academies count toward totals
+ if (isStackable) {
+ // Stacked structures count their stackCount toward totals
// (affects scaling like new build cost and display counts)
- total += (unit as any).level?.() ?? 1;
+ total += unit.stackCount?.() ?? 1;
} else {
total++;
}
@@ -367,15 +371,8 @@ export class PlayerImpl implements Player {
}
if (unit.type() !== UnitType.Construction) continue;
if (unit.constructionType() !== type) continue;
- // For upgradeable structures, count the target level instead of just 1
- if (
- type === UnitType.City ||
- type === UnitType.Port ||
- type === UnitType.Hospital ||
- type === UnitType.Academy ||
- type === UnitType.ResearchLab ||
- type === UnitType.Factory
- ) {
+ // For stackable structures, count the target level instead of just 1
+ if (isStackable) {
total += unit.constructionTargetLevel();
} else {
total++;
@@ -390,12 +387,137 @@ export class PlayerImpl implements Player {
addUpgrade(upgrade: UpgradeType): void {
this._upgrades.add(upgrade);
+ this.applyAutoUnitUpgrades(upgrade);
}
removeUpgrade(upgrade: UpgradeType): void {
this._upgrades.delete(upgrade);
}
+ private applyAutoUnitUpgrades(upgrade: UpgradeType): void {
+ const unitTypes: UnitType[] = [];
+ switch (upgrade) {
+ case UpgradeType.FighterLevel2:
+ case UpgradeType.FighterLevel3:
+ case UpgradeType.FighterLevel4:
+ unitTypes.push(UnitType.FighterJet);
+ break;
+ case UpgradeType.BomberLevel2:
+ case UpgradeType.BomberLevel3:
+ unitTypes.push(UnitType.Bomber);
+ break;
+ case UpgradeType.WarshipLevel2:
+ case UpgradeType.WarshipLevel3:
+ unitTypes.push(UnitType.Warship);
+ break;
+ case UpgradeType.SubmarineLevel2:
+ case UpgradeType.SubmarineLevel3:
+ unitTypes.push(UnitType.Submarine);
+ break;
+ case UpgradeType.ArtilleryLevel2:
+ case UpgradeType.ArtilleryLevel3:
+ unitTypes.push(UnitType.Artillery);
+ break;
+ default:
+ return;
+ }
+
+ for (const type of unitTypes) {
+ if (type === UnitType.Bomber) {
+ this.upgradeBombersAndAirfields();
+ } else {
+ this.upgradeCombatUnits(type);
+ }
+ }
+ }
+
+ private upgradeCombatUnits(type: UnitType): void {
+ const targetLevel = playerMaxUnitLevel(this, type);
+ if (targetLevel <= 1) return;
+
+ const desiredMaxHealth = (() => {
+ switch (type) {
+ case UnitType.FighterJet:
+ return this.mg.config().fighterJetLevelMaxHealth(targetLevel);
+ case UnitType.Warship:
+ return this.mg.config().warshipLevelMaxHealth(targetLevel);
+ case UnitType.Submarine:
+ return this.mg.config().submarineLevelMaxHealth(targetLevel);
+ case UnitType.Artillery:
+ return this.mg.config().artilleryLevelMaxHealth(targetLevel);
+ default:
+ return this.mg.unitInfo(type).maxHealth ?? 0;
+ }
+ })();
+ const baseMax = this.mg.unitInfo(type).maxHealth ?? desiredMaxHealth;
+
+ for (const unit of this.units(type)) {
+ const impl = unit as any;
+ const currentLevel = typeof impl.level === "function" ? impl.level() : 1;
+ if (currentLevel >= targetLevel) continue;
+
+ const oldMax =
+ typeof impl.effectiveMaxHealth === "function"
+ ? impl.effectiveMaxHealth()
+ : baseMax;
+ const healthRatio = oldMax > 0 ? Math.min(1, unit.health() / oldMax) : 1;
+
+ impl._level = targetLevel;
+ impl._bonusMaxHealth = Math.max(0, desiredMaxHealth - baseMax);
+ const newHealth = Math.max(0, Math.round(desiredMaxHealth * healthRatio));
+ impl._health = BigInt(
+ Math.min(desiredMaxHealth, newHealth || desiredMaxHealth),
+ );
+ this.mg.addUpdate(unit.toUpdate());
+ }
+
+ this.invalidateEffectiveUnitsCache(type);
+ }
+
+ private upgradeBombersAndAirfields(): void {
+ const targetLevel = playerMaxUnitLevel(this, UnitType.Bomber);
+ if (targetLevel <= 1) return;
+
+ // Sync airfields so new and existing bombers inherit the latest level
+ for (const airfield of this.units(UnitType.Airfield)) {
+ if (airfield.bomberLevel?.() !== undefined) {
+ const current = airfield.bomberLevel();
+ if (current < targetLevel) {
+ airfield.setBomberLevel?.(targetLevel);
+ }
+ } else {
+ airfield.setBomberLevel?.(targetLevel);
+ }
+ }
+
+ const desiredMaxHealth = this.mg.config().bomberMaxHealth(targetLevel);
+ const baseMax =
+ this.mg.unitInfo(UnitType.Bomber).maxHealth ?? desiredMaxHealth;
+
+ for (const bomber of this.units(UnitType.Bomber)) {
+ const impl = bomber as any;
+ const currentLevel = typeof impl.level === "function" ? impl.level() : 1;
+ if (currentLevel < targetLevel) {
+ impl._level = targetLevel;
+ }
+ const oldMax =
+ typeof impl.effectiveMaxHealth === "function"
+ ? impl.effectiveMaxHealth()
+ : baseMax;
+ const healthRatio =
+ oldMax > 0 ? Math.min(1, bomber.health() / oldMax) : 1;
+ impl._bonusMaxHealth = Math.max(0, desiredMaxHealth - baseMax);
+ const newHealth = Math.max(0, Math.round(desiredMaxHealth * healthRatio));
+ impl._health = BigInt(
+ Math.min(desiredMaxHealth, newHealth || desiredMaxHealth),
+ );
+ this.mg.addUpdate(bomber.toUpdate());
+ }
+
+ this.invalidateEffectiveUnitsCache(UnitType.Airfield);
+ this.invalidateEffectiveUnitsCache(UnitType.Bomber);
+ }
+
// Research tree (standalone) API
addResearchedTech(techId: string): void {
// Add tech to researched set
@@ -403,12 +525,6 @@ export class PlayerImpl implements Player {
// Apply centralized side-effects upon research completion
applyTechCompletionEffects(this, this.mg, techId);
-
- // Check if this tech unlocks any policy directives
- const unlockedDirectives = getDirectivesUnlockedByTech(techId);
- for (const directive of unlockedDirectives) {
- this._markPolicyDirectiveUnseen(directive.id);
- }
}
removeResearchedTechsByCategory(category: Category): void {
const toRemove: string[] = [];
@@ -435,7 +551,6 @@ export class PlayerImpl implements Player {
}
if (toRemove.length === 0 && progressToClear.length === 0) return;
- const priority = this._researchPriority;
const cleared = new Set([...toRemove, ...progressToClear]);
for (const techId of toRemove) {
@@ -445,8 +560,9 @@ export class PlayerImpl implements Player {
this._researchBeakers.delete(techId);
}
- if (priority && cleared.has(priority)) {
- this._researchPriority = null;
+ // Remove cleared techs from priorities
+ for (const techId of cleared) {
+ this._researchPriorities.delete(techId);
}
}
hasResearchedTech(techId: string): boolean {
@@ -475,36 +591,20 @@ export class PlayerImpl implements Player {
return { completed, newBeakers: total };
}
setResearchPriority(techId: string | null): void {
- this._researchPriority = techId;
+ if (techId === null) {
+ this._researchPriorities.clear();
+ } else if (this._researchPriorities.has(techId)) {
+ this._researchPriorities.delete(techId);
+ } else {
+ this._researchPriorities.add(techId);
+ }
}
researchPriority(): string | null {
- return this._researchPriority;
- }
-
- // Policy Directive methods
- getPolicyChoice(directiveId: string): string | null {
- return this._policyChoices.get(directiveId) ?? null;
+ // Return first priority for backward compatibility
+ return this._researchPriorities.values().next().value ?? null;
}
- setPolicyChoice(directiveId: string, optionId: string): void {
- this._policyChoices.set(directiveId, optionId);
- // Mark as seen once a choice is made
- this._unseenPolicyDirectives.delete(directiveId);
- }
- getAllPolicyChoices(): ReadonlyMap {
- return this._policyChoices;
- }
- hasUnseenPolicyDirectives(): boolean {
- return this._unseenPolicyDirectives.size > 0;
- }
- markPolicyDirectivesSeen(): void {
- this._unseenPolicyDirectives.clear();
- }
- // Internal: mark a directive as unseen (called when tech unlocks it)
- _markPolicyDirectiveUnseen(directiveId: string): void {
- // Only mark as unseen if no choice has been made yet
- if (!this._policyChoices.has(directiveId)) {
- this._unseenPolicyDirectives.add(directiveId);
- }
+ researchPriorities(): Set {
+ return this._researchPriorities;
}
invalidateEffectiveUnitsCache(type: UnitType): void {
@@ -1400,6 +1500,8 @@ export class PlayerImpl implements Player {
case UnitType.Submarine:
case UnitType.Warship:
return this.warshipSpawn(targetTile);
+ case UnitType.Artillery:
+ return this.artillerySpawn(targetTile);
case UnitType.Shell:
case UnitType.SAMMissile:
case UnitType.AABullet:
@@ -1505,6 +1607,32 @@ export class PlayerImpl implements Player {
return waterNeighbors[0];
}
+ artillerySpawn(tile: TileRef): TileRef | false {
+ if (this.mg.isOcean(tile)) {
+ return false;
+ }
+ const spawns = this.units(UnitType.Factory).sort(
+ (a, b) =>
+ this.mg.manhattanDist(a.tile(), tile) -
+ this.mg.manhattanDist(b.tile(), tile),
+ );
+ if (spawns.length === 0) {
+ return false;
+ }
+ const closestFactory = spawns[0];
+ const landNeighbors = this.mg
+ .neighbors(closestFactory.tile())
+ .filter(
+ (t) =>
+ !this.mg.isOcean(t) && this.mg.terrainType(t) !== TerrainType.Barrier,
+ );
+ if (landNeighbors.length === 0) {
+ // Factory has no adjacent pathable land
+ return false;
+ }
+ return landNeighbors[0];
+ }
+
landBasedStructureSpawn(
tile: TileRef,
validTiles: TileRef[] | null = null,
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index 344ee13ef..d217ec16d 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -1,4 +1,3 @@
-import { renderNumber } from "../../client/Utils";
import { simpleHash, toInt, withinInt } from "../Util";
import {
AllUnitParams,
@@ -16,6 +15,7 @@ import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { GameUpdateType, UnitUpdate } from "./GameUpdates";
import { PlayerImpl } from "./PlayerImpl";
+import { maxStackCount } from "./Upgradeables";
export class UnitImpl implements Unit {
private _active = true;
@@ -36,6 +36,8 @@ export class UnitImpl implements Unit {
private _returning: boolean = false;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
+ private _stackCount: number = 1; // Number of stacked instances (for stackable structures)
+ private _launchesRemaining: number | null = null; // For stacked silos: remaining launches before cooldown
private _bonusMaxHealth: number = 0; // Extra max health from upgrades (e.g. city upgrades)
private _targetable: boolean = true;
private _accumulatedRegen: number = 0;
@@ -100,13 +102,6 @@ export class UnitImpl implements Unit {
"sourceAirfield" in params
? (params.sourceAirfield ?? undefined)
: undefined;
- // TEMPORARILY DISABLED: Structure insurance
- // if (
- // isStructureType(this._type) &&
- // this._owner.hasUpgrade(UpgradeType.StructureInsurance)
- // ) {
- // this._insuredBy = this._owner;
- // }
switch (this._type) {
case UnitType.Warship:
@@ -176,6 +171,11 @@ export class UnitImpl implements Unit {
health: this.hasHealth() ? Number(this._health) : undefined,
maxHealth: this.hasHealth() ? this.effectiveMaxHealth() : undefined,
level: this._level > 1 ? this._level : undefined,
+ stackCount: this._stackCount > 1 ? this._stackCount : undefined,
+ launchesRemaining:
+ this._type === UnitType.MissileSilo && this._launchesRemaining !== null
+ ? this._launchesRemaining
+ : undefined,
constructionType: this._constructionType,
constructionTargetLevel:
this._type === UnitType.Construction &&
@@ -292,6 +292,16 @@ export class UnitImpl implements Unit {
return this._level;
}
+ stackCount(): number {
+ return this._stackCount;
+ }
+
+ setStackCount(count: number): void {
+ const cap = maxStackCount(this._type);
+ this._stackCount = Math.max(1, Math.min(cap, count));
+ this.mg.addUpdate(this.toUpdate());
+ }
+
// Port-specific accessor/mutator for scheduled trade ship construction (single legacy)
setPendingTradeShipDueTick(due: Tick | null): void {
if (this._pendingTradeShipDueTick !== due) {
@@ -337,11 +347,12 @@ export class UnitImpl implements Unit {
return;
}
case UnitType.MissileSilo: {
- // Cap silo upgrades at level 3
- if (this._level >= 3) {
- return;
- }
+ // No cap for silo stacking
this._level += 1;
+ // Reset launches remaining to allow more launches
+ if (this._launchesRemaining !== null) {
+ this._launchesRemaining += 1;
+ }
this._bonusMaxHealth += 250;
const healed = Number(this._health) + 250;
const capped = Math.min(healed, this.effectiveMaxHealth());
@@ -352,10 +363,6 @@ export class UnitImpl implements Unit {
return;
}
case UnitType.SAMLauncher: {
- // Cap SAM upgrades at level 3
- if (this._level >= 3) {
- return;
- }
this._level += 1;
// Small durability boost per upgrade, aligned with MissileSilo behavior
this._bonusMaxHealth += 250;
@@ -439,23 +446,6 @@ export class UnitImpl implements Unit {
}
setOwner(newOwner: PlayerImpl): void {
- if (this._insuredBy) {
- const baseCost = this.info().cost(this._insuredBy);
- if (baseCost > 0n) {
- const num = BigInt(this.mg.config().structureInsuranceRefundNum());
- const den = BigInt(this.mg.config().structureInsuranceRefundDen());
- const refundAmount = (baseCost * num) / den;
- this._insuredBy.addGold(refundAmount);
- this.mg.displayMessage(
- "messages.insurance_refund_conquest",
- MessageType.INSURANCE_REFUND,
- this._insuredBy.id(),
- refundAmount,
- { amount: renderNumber(refundAmount) },
- );
- }
- }
- this._insuredBy = null;
switch (this._type) {
case UnitType.Warship:
case UnitType.FighterJet:
@@ -525,23 +515,6 @@ export class UnitImpl implements Unit {
if (!this.isActive()) {
throw new Error(`cannot delete ${this} not active`);
}
- if (this._insuredBy) {
- const baseCost = this.info().cost(this._insuredBy);
- if (baseCost > 0n) {
- const num = BigInt(this.mg.config().structureInsuranceRefundNum());
- const den = BigInt(this.mg.config().structureInsuranceRefundDen());
- const refundAmount = (baseCost * num) / den;
- this._insuredBy.addGold(refundAmount);
- this.mg.displayMessage(
- "messages.insurance_refund",
- MessageType.INSURANCE_REFUND,
- this._insuredBy.id(),
- refundAmount,
- { amount: renderNumber(refundAmount) },
- );
- }
- }
- this._insuredBy = null;
this._owner._units = this._owner._units.filter((b) => b !== this);
this._active = false;
this.mg.addUpdate(this.toUpdate());
@@ -648,17 +621,39 @@ export class UnitImpl implements Unit {
}
launch(duration?: Tick): void {
+ // For stacked missile silos and SAMs: allow multiple launches before cooldown
+ if (
+ (this.type() === UnitType.MissileSilo ||
+ this.type() === UnitType.SAMLauncher) &&
+ this._stackCount > 1
+ ) {
+ // Initialize launches remaining on first launch
+ if (this._launchesRemaining === null) {
+ this._launchesRemaining = this._stackCount - 1; // First launch uses one
+ this.mg.addUpdate(this.toUpdate());
+ return; // Don't start cooldown yet
+ }
+ // If we have remaining launches, use one
+ if (this._launchesRemaining > 0) {
+ this._launchesRemaining--;
+ this.mg.addUpdate(this.toUpdate());
+ if (this._launchesRemaining > 0) {
+ return; // Still have more launches, don't start cooldown
+ }
+ // Fall through to start cooldown when all launches used
+ }
+ // Reset launches for next cycle
+ this._launchesRemaining = null;
+ }
+
this._cooldownStartTick = this.mg.ticks();
if (duration !== undefined) {
this._cooldownDuration = duration;
} else {
// Choose default by unit type
if (this.type() === UnitType.MissileSilo) {
- // Reduce cooldown by 20% per upgrade level beyond 1: L1=100%, L2=80%, L3=60%
- const base = this.mg.config().SiloCooldown();
- const levelsAboveOne = Math.max(0, this._level - 1);
- const multiplier = Math.max(0, 1 - 0.2 * levelsAboveOne);
- this._cooldownDuration = Math.floor(base * multiplier);
+ // Use base cooldown - stacking doesn't affect cooldown duration
+ this._cooldownDuration = this.mg.config().SiloCooldown();
} else if (this.type() === UnitType.SAMLauncher) {
this._cooldownDuration = this.mg.config().SAMNukeCooldown();
} else if (this.type() === UnitType.City) {
@@ -684,7 +679,7 @@ export class UnitImpl implements Unit {
) {
if (this.hasHealth()) {
const healthPercentage =
- Number(this.health()) / (this.info().maxHealth ?? 1);
+ Number(this.health()) / this.effectiveMaxHealth();
if (healthPercentage > 0) {
cooldownDuration /= healthPercentage;
}
diff --git a/src/core/game/UnitUpgrades.ts b/src/core/game/UnitUpgrades.ts
index 7b6fa26c4..6f1e27916 100644
--- a/src/core/game/UnitUpgrades.ts
+++ b/src/core/game/UnitUpgrades.ts
@@ -45,6 +45,14 @@ export type WarshipLevelData = UnitLevelData;
/** Submarine level data (uses base UnitLevelData, no additional stats) */
export type SubmarineLevelData = UnitLevelData;
+/** Extended artillery level data with artillery-specific stats */
+export interface ArtilleryLevelData extends UnitLevelData {
+ /** Target acquisition range at this level */
+ targetRange: number;
+ /** Movement speed (ticks between moves) at this level */
+ moveInterval: number;
+}
+
// ============================================================================
// BOMBER UPGRADES (3 levels)
// Bombers have no base cost (spawned from airfields)
@@ -187,6 +195,44 @@ export const SUBMARINE_UPGRADES: readonly SubmarineLevelData[] = [
},
] as const;
+// ============================================================================
+// ARTILLERY UPGRADES (3 levels)
+// Base cost: 500,000
+// ============================================================================
+
+export const ARTILLERY_UPGRADES: readonly ArtilleryLevelData[] = [
+ // Level 1 (base)
+ {
+ cost: 500_000n,
+ maintenance: 0n,
+ maxHealth: 1000,
+ damageMin: 12,
+ damageMax: 22,
+ targetRange: 40,
+ moveInterval: 4, // moves every 4 ticks (75% slower)
+ },
+ // Level 2
+ {
+ cost: 600_000n,
+ maintenance: 0n,
+ maxHealth: 1250,
+ damageMin: 17,
+ damageMax: 27,
+ targetRange: 50,
+ moveInterval: 3, // moves every 3 ticks (66% slower)
+ },
+ // Level 3
+ {
+ cost: 700_000n,
+ maintenance: 0n,
+ maxHealth: 1500,
+ damageMin: 22,
+ damageMax: 32,
+ targetRange: 60,
+ moveInterval: 2, // moves every 2 ticks (50% slower)
+ },
+] as const;
+
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
@@ -207,6 +253,8 @@ export function getUnitUpgradeData(
return WARSHIP_UPGRADES;
case UnitType.Submarine:
return SUBMARINE_UPGRADES;
+ case UnitType.Artillery:
+ return ARTILLERY_UPGRADES;
default:
return undefined;
}
@@ -282,3 +330,19 @@ export function getSubmarineLevelData(level: number): SubmarineLevelData {
const idx = Math.max(0, Math.min(level - 1, SUBMARINE_UPGRADES.length - 1));
return SUBMARINE_UPGRADES[idx];
}
+
+/**
+ * Get artillery-specific upgrade data.
+ */
+export function getArtilleryLevelData(level: number): ArtilleryLevelData {
+ const idx = Math.max(0, Math.min(level - 1, ARTILLERY_UPGRADES.length - 1));
+ return ARTILLERY_UPGRADES[idx];
+}
+
+/**
+ * Get the maximum spawn/redirect distance for artillery based on level.
+ * Level 1: 60 tiles, Level 2: 75 tiles, Level 3: 90 tiles
+ */
+export function getArtilleryMaxDistance(level: number): number {
+ return level >= 3 ? 90 : level === 2 ? 75 : 60;
+}
diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts
index 9d4891a08..b0495066c 100644
--- a/src/core/game/Upgradeables.ts
+++ b/src/core/game/Upgradeables.ts
@@ -5,7 +5,9 @@ interface HasUpgrade {
hasUpgrade(type: UpgradeType): boolean;
}
-export const UPGRADEABLE_STRUCTURES: ReadonlySet = new Set([
+// STACKABLE structures: can have multiple "instances" in one tile (user-controlled stack count)
+// Stacking adds HP and counts as multiple buildings.
+export const STACKABLE_STRUCTURES: ReadonlySet = new Set([
UnitType.City,
UnitType.Port,
UnitType.Airfield,
@@ -17,27 +19,58 @@ export const UPGRADEABLE_STRUCTURES: ReadonlySet = new Set([
UnitType.SAMLauncher,
]);
-// Units that can be upgraded
+// TECH-UPGRADEABLE structures: level is determined by researched techs (auto-applied)
+// SAM and Airfield have tech-based upgrade levels that affect their capabilities.
+export const TECH_UPGRADEABLE_STRUCTURES: ReadonlySet =
+ new Set([UnitType.SAMLauncher, UnitType.Airfield]);
+
+// Legacy alias for backwards compatibility
+export const UPGRADEABLE_STRUCTURES: ReadonlySet =
+ STACKABLE_STRUCTURES;
+
+// Units that can be upgraded via tech
export const UPGRADEABLE_UNITS: ReadonlySet = new Set([
UnitType.Warship,
UnitType.FighterJet,
UnitType.Submarine,
UnitType.Bomber, // Bomber level affects airfield construction cost
+ UnitType.Artillery,
]);
+export function isStackableStructure(type: UnitType): boolean {
+ return STACKABLE_STRUCTURES.has(type);
+}
+
+export function isTechUpgradeableStructure(type: UnitType): boolean {
+ return TECH_UPGRADEABLE_STRUCTURES.has(type);
+}
+
export function isUpgradeableStructure(type: UnitType): boolean {
- return UPGRADEABLE_STRUCTURES.has(type);
+ return STACKABLE_STRUCTURES.has(type);
}
export function isUpgradeableUnit(type: UnitType): boolean {
return UPGRADEABLE_UNITS.has(type);
}
+const MAX_STACK_COUNT = 25;
+
+// Maximum TECH upgrade level for structures (SAM, Airfield)
+// This is NOT the stack count - it's the quality tier from research.
+export function maxStructureTechLevel(type: UnitType): number {
+ if (type === UnitType.SAMLauncher) return 3;
+ if (type === UnitType.Airfield) return 3; // Based on bomber level
+ return 1;
+}
+
+// Maximum stack count for stackable structures
+export function maxStackCount(type: UnitType): number {
+ return isStackableStructure(type) ? MAX_STACK_COUNT : 1;
+}
+
+// Legacy function - returns max stack count (25 for all stackable structures)
export function maxStructureLevel(type: UnitType): number {
- if (type === UnitType.MissileSilo || type === UnitType.SAMLauncher) {
- return 3;
- }
- return isUpgradeableStructure(type) ? 99 : 1;
+ return isStackableStructure(type) ? MAX_STACK_COUNT : 1;
}
// Return maximum upgrade level for upgradeable combat units.
@@ -48,6 +81,7 @@ export function maxUnitLevel(type: UnitType): number {
return 4;
case UnitType.Warship:
case UnitType.Submarine:
+ case UnitType.Artillery:
case UnitType.Bomber:
return 3;
default:
@@ -105,28 +139,60 @@ export function playerMaxUnitLevel(player: HasUpgrade, type: UnitType): number {
return 1;
}
+ if (type === UnitType.Artillery) {
+ if (player.hasUpgrade(UpgradeType.ArtilleryLevel3))
+ return Math.min(3, globalMax);
+ if (player.hasUpgrade(UpgradeType.ArtilleryLevel2))
+ return Math.min(2, globalMax);
+ if (player.hasUpgrade(UpgradeType.ArtilleryResearch))
+ return Math.min(1, globalMax);
+ // Artillery not unlocked yet
+ return 0;
+ }
+
// For other unit types, return global max
return globalMax;
}
-// Return maximum upgrade level for a structure based on player's researched techs.
-// For SAMLauncher: Surface-to-Air Missiles = level 1, Radar-Guided SAMs = level 2,
-// Strategic SAM Systems = level 3.
+// Return maximum level for a structure based on stacking capability.
+// All stackable structures (including SAM, Airfield, MissileSilo) can stack up to 25.
+// Note: SAM and Airfield have separate tech upgrades (SAMLevel1-3, BomberLevel1-3)
+// that affect the quality/stats, but stacking is independent.
export function playerMaxStructureLevel(
- player: HasUpgrade,
+ _player: HasUpgrade,
type: UnitType,
): number {
- const globalMax = maxStructureLevel(type);
+ // All stackable structures can go up to 25 stacks
+ if (isUpgradeableStructure(type)) {
+ return MAX_STACK_COUNT;
+ }
+
+ // Non-stackable structures have max level 1
+ return 1;
+}
+// Return the maximum TECH level for a structure based on player's researched techs.
+// For SAMLauncher: 1-3 based on SAM upgrades.
+// For Airfield: 1-3 based on bomber upgrades.
+// This is for quality/stats upgrades, NOT stacking.
+export function playerMaxStructureTechLevel(
+ player: HasUpgrade,
+ type: UnitType,
+): number {
if (type === UnitType.SAMLauncher) {
- if (player.hasUpgrade(UpgradeType.SAMLevel3)) return Math.min(3, globalMax);
- if (player.hasUpgrade(UpgradeType.SAMLevel2)) return Math.min(2, globalMax);
- // SAM Level 1 is available by default at game start
- return Math.min(1, globalMax);
+ if (player.hasUpgrade(UpgradeType.SAMLevel3)) return 3;
+ if (player.hasUpgrade(UpgradeType.SAMLevel2)) return 2;
+ return 1;
}
- // For other structures, return global max
- return globalMax;
+ if (type === UnitType.Airfield) {
+ if (player.hasUpgrade(UpgradeType.BomberLevel3)) return 3;
+ if (player.hasUpgrade(UpgradeType.BomberLevel2)) return 2;
+ return 1;
+ }
+
+ // Non-tech-upgradeable structures always have tech level 1
+ return 1;
}
// Resolve a UnitType value from a stored string value (String(UnitType.X))
@@ -142,9 +208,14 @@ export function tryParseUnitType(value: string): UnitType | null {
export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean {
switch (type) {
case UnitType.Warship:
- case UnitType.Submarine:
- // Warship and Submarine Level 1 are available by default at game start
+ // Warship Level 1 is available by default at game start
return true;
+ case UnitType.Submarine:
+ // Diesel Sub unlocks with Sea Level 1 (Submarine research)
+ return (
+ player.hasUpgrade(UpgradeType.SubmarineResearch) ||
+ player.hasUpgrade(UpgradeType.SubmarineLevel1)
+ );
case UnitType.Airfield:
case UnitType.FighterJet:
case UnitType.Bomber:
@@ -167,7 +238,8 @@ export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean {
case UnitType.Hospital:
return player.hasUpgrade(UpgradeType.HospitalResearch);
case UnitType.ResearchLab:
- return player.hasUpgrade(UpgradeType.ResearchLabResearch);
+ // Research Lab is available without a tech gate
+ return true;
default:
return true;
}
diff --git a/src/core/tech/PolicyDirectives.ts b/src/core/tech/PolicyDirectives.ts
deleted file mode 100644
index 6eb2ac427..000000000
--- a/src/core/tech/PolicyDirectives.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-/**
- * Policy Directives are optional player choices that unlock when certain techs are researched.
- * Each directive offers a choice between two or more policy options, each with distinct effects.
- */
-
-import { RESEARCH_TECH_IDS } from "./TechIds";
-
-// Policy directive identifiers
-export const POLICY_DIRECTIVE_IDS = {
- NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "policy_research_industrial",
- TRADE_POLICY_FRAMEWORK: "policy_trade_policy",
- DIGITAL_ADMINISTRATION_SYSTEMS: "policy_digital_administration",
- MECHANIZED_WARFARE_DOCTRINE: "policy_mechanized_warfare",
- NIGHT_VISION_THERMAL_C3I: "policy_night_vision_thermal",
-} as const;
-
-export type PolicyDirectiveId =
- (typeof POLICY_DIRECTIVE_IDS)[keyof typeof POLICY_DIRECTIVE_IDS];
-
-// Option identifiers within a directive
-export type PolicyOptionId = string;
-
-export interface PolicyOption {
- id: PolicyOptionId;
- name: string;
- description: string;
- effects: PolicyEffects;
-}
-
-export interface PolicyEffects {
- // Multiplier for construction speed (e.g., 1.03 = +3% faster)
- constructionSpeedMul?: number;
- // Multiplier for trade income from roads and trade ships (e.g., 1.05 = +5%)
- tradeIncomeMul?: number;
- // Multiplier for trade ship income specifically (stacks with tradeIncomeMul)
- tradeShipIncomeMul?: number;
- // Multiplier for domestic income (non-trade income from population/industry)
- domesticIncomeMul?: number;
- // If true, grants the InternationalTrade upgrade (enables international road/sea trade)
- grantsInternationalTrade?: boolean;
- // Multiplier for road effects (e.g., 1.2 = +20% stronger road bonuses)
- roadEffectMul?: number;
- // Multiplier for infrastructure spending effectiveness (e.g., 1.2 = +20% more roads per gold)
- infrastructureSpendingEffectivenessMul?: number;
- // Multiplier for research spending effectiveness (e.g., 1.3 = +30% research effectiveness)
- researchEffectivenessMul?: number;
- // Multiplier for attack speed (e.g., 1.1 = +10% faster offensive speed)
- attackSpeedMul?: number;
- // Multiplier for attacker losses when attacking (e.g., 0.9 = -10% losses)
- attackerLossMul?: number;
- // Multiplier for defender losses when defending (e.g., 0.9 = -10% losses)
- defenderLossMul?: number;
- // Multiplier for enemy (defender) losses when you attack (e.g., 1.1 = +10% enemy losses)
- enemyLossMulOnAttack?: number;
- // Multiplier for attacker (enemy) losses when you defend (e.g., 1.1 = +10% enemy losses when they attack you)
- attackerLossMulOnDefense?: number;
- // Multiplier for maintenance cost reduction (e.g., 0.90 = -10% maintenance)
- // TODO: Commented out until maintenance is implemented
- // maintenanceCostMul?: number;
-}
-
-export interface PolicyDirective {
- id: PolicyDirectiveId;
- name: string;
- description: string;
- // Tech that must be researched to unlock this directive
- unlockedByTech: string;
- // Available options to choose from
- options: PolicyOption[];
-}
-
-// Central registry of all policy directives
-export const POLICY_DIRECTIVES: Readonly<
- Record
-> = Object.freeze({
- [POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: {
- id: POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- name: "National Research & Industrial Foundations",
- description:
- "Choose your nation's priority between industrial expansion and scientific institutions.",
- unlockedByTech: RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- options: [
- {
- id: "industrial_expansion",
- name: "Industrial Expansion Priority",
- description: "+5% domestic income, +20% construction speed",
- effects: {
- domesticIncomeMul: 1.05,
- constructionSpeedMul: 1.2,
- },
- },
- {
- id: "scientific_institution",
- name: "Scientific Institution Priority",
- description: "+30% research spending effectiveness",
- effects: {
- researchEffectivenessMul: 1.3,
- },
- },
- ],
- },
- [POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK]: {
- id: POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK,
- name: "Trade Policy Framework",
- description:
- "Choose your nation's approach to international commerce and trade relations.",
- unlockedByTech: RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK,
- options: [
- {
- id: "open_trade",
- name: "Open Trade Policy",
- description: "+5% trade income, +5% income from owned trade ships",
- effects: {
- grantsInternationalTrade: true,
- tradeIncomeMul: 1.05,
- tradeShipIncomeMul: 1.05,
- },
- },
- {
- id: "autarky",
- name: "Autarky Doctrine",
- description: "Disables international trade, +20% domestic income",
- effects: {
- domesticIncomeMul: 1.2,
- },
- },
- ],
- },
- [POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: {
- id: POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS,
- name: "Digital Administration & Economic Coordination Systems",
- description:
- "Choose your nation's approach to digital administration and economic coordination.",
- unlockedByTech: RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS,
- options: [
- {
- id: "market_optimization",
- name: "Market Optimization Systems",
- description: "+10% domestic income, -10% maintenance costs",
- effects: {
- domesticIncomeMul: 1.1,
- // TODO: maintenanceCostMul: 0.90, // 10% reduction when maintenance is implemented
- },
- },
- {
- id: "central_planning",
- name: "Central Planning Automation",
- description:
- "+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed",
- effects: {
- domesticIncomeMul: 1.05,
- infrastructureSpendingEffectivenessMul: 1.2,
- constructionSpeedMul: 1.1,
- },
- },
- ],
- },
- [POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE]: {
- id: POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE,
- name: "Mechanized Warfare Doctrine",
- description:
- "Choose your tactical doctrine emphasis for mechanized operations.",
- unlockedByTech: RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE,
- options: [
- {
- id: "mobile_infantry",
- name: "Mobile Infantry Tactics",
- description:
- "-10% your losses when attacking, +10% enemy losses when they attack you",
- effects: {
- attackerLossMul: 0.9,
- attackerLossMulOnDefense: 1.1,
- },
- },
- {
- id: "armored_breakthrough",
- name: "Armored Breakthrough Doctrine",
- description:
- "+10% enemy losses when you attack, -10% your losses when defending",
- effects: {
- enemyLossMulOnAttack: 1.1,
- defenderLossMul: 0.9,
- },
- },
- ],
- },
- [POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I]: {
- id: POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I,
- name: "Night Vision, Thermal Imaging & Digital C3I",
- description:
- "Choose your night combat doctrine with thermal imaging and digital command systems.",
- unlockedByTech: RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I,
- options: [
- {
- id: "high_tempo_maneuver",
- name: "High-Tempo Maneuver Warfare",
- description:
- "+10% enemy losses when you attack, -10% your losses when attacking",
- effects: {
- enemyLossMulOnAttack: 1.1,
- attackerLossMul: 0.9,
- },
- },
- {
- id: "precision_defensive",
- name: "Precision Defensive Fire Doctrine",
- description:
- "+10% enemy losses when they attack you, -10% your losses when defending",
- effects: {
- attackerLossMulOnDefense: 1.1,
- defenderLossMul: 0.9,
- },
- },
- ],
- },
-});
-
-/**
- * Get all policy directives.
- */
-export function getAllPolicyDirectives(): PolicyDirective[] {
- return Object.values(POLICY_DIRECTIVES);
-}
-
-/**
- * Get a policy directive by ID.
- */
-export function getPolicyDirective(
- id: PolicyDirectiveId,
-): PolicyDirective | undefined {
- return POLICY_DIRECTIVES[id];
-}
-
-/**
- * Get policy directives unlocked by a specific tech.
- */
-export function getDirectivesUnlockedByTech(techId: string): PolicyDirective[] {
- return Object.values(POLICY_DIRECTIVES).filter(
- (d) => d.unlockedByTech === techId,
- );
-}
-
-/**
- * Get a specific option from a directive.
- */
-export function getPolicyOption(
- directiveId: PolicyDirectiveId,
- optionId: PolicyOptionId,
-): PolicyOption | undefined {
- const directive = POLICY_DIRECTIVES[directiveId];
- return directive?.options.find((o) => o.id === optionId);
-}
-
-/**
- * Check if a player has unlocked a policy directive based on researched techs.
- */
-export function isDirectiveUnlocked(
- directiveId: PolicyDirectiveId,
- hasResearchedTech: (techId: string) => boolean,
-): boolean {
- const directive = POLICY_DIRECTIVES[directiveId];
- if (!directive) return false;
- return hasResearchedTech(directive.unlockedByTech);
-}
-
-/**
- * Get all directives that are unlocked based on researched techs.
- */
-export function getUnlockedDirectives(
- hasResearchedTech: (techId: string) => boolean,
-): PolicyDirective[] {
- return Object.values(POLICY_DIRECTIVES).filter((d) =>
- hasResearchedTech(d.unlockedByTech),
- );
-}
diff --git a/src/core/tech/ResearchTree.ts b/src/core/tech/ResearchTree.ts
index f709d4002..b8e85c54b 100644
--- a/src/core/tech/ResearchTree.ts
+++ b/src/core/tech/ResearchTree.ts
@@ -1,4 +1,4 @@
-export type Category = "Land" | "Sea" | "Air" | "Nuclear" | "Economy";
+export type Category = "Land" | "Sea" | "Air" | "Nuclear";
/**
* Core tech node for tree structure - metadata (name, description) is in TechEffects.ts
@@ -28,7 +28,7 @@ const baseLevels: TechNode[] = (() => {
return nodes;
})();
-// Nuclear branch techs (explicit definitions)
+// Nuclear branch techs (explicit definitions) - 4 levels
const nuclearTechs: TechNode[] = [
{ id: "Nuclear-1", category: "Nuclear", level: 1, cost: costForLevel(1) },
{
@@ -45,6 +45,7 @@ const nuclearTechs: TechNode[] = [
requiresAllOf: ["Nuclear-2"],
cost: costForLevel(3),
},
+ // Level 4 - TBD (placeholder for future tech)
{
id: "Nuclear-4",
category: "Nuclear",
@@ -54,11 +55,11 @@ const nuclearTechs: TechNode[] = [
},
];
-// Sea branch techs (explicit definitions) - Simplified linear tree
+// Sea branch techs (explicit definitions) - 4 levels
const seaTechs: TechNode[] = [
- // Level 1 - Early Missile Navy (unlocks Warship L2, Sub L2)
+ // Level 1 - Missile Navy (unlocks Warship L2, Sub L2)
{ id: "Sea-1", category: "Sea", level: 1, cost: costForLevel(1) },
- // Level 2 - Submarine Silent Service Modernization (unlocks Sub L3)
+ // Level 2 - Advanced Fleet (unlocks Warship L3, Ship Anti-Air)
{
id: "Sea-2",
category: "Sea",
@@ -66,7 +67,7 @@ const seaTechs: TechNode[] = [
requiresAllOf: ["Sea-1"],
cost: costForLevel(2),
},
- // Level 3 - SSBN Programs (unlocks SSBNs)
+ // Level 3 - Nuclear Submarines (unlocks Subs can launch nukes)
{
id: "Sea-3",
category: "Sea",
@@ -74,7 +75,7 @@ const seaTechs: TechNode[] = [
requiresAllOf: ["Sea-2"],
cost: costForLevel(3),
},
- // Level 4 - Modern Fleet Sensor & SAM Integration (unlocks Warship L3, Ship SAM)
+ // Level 4 - TBD (placeholder for future tech)
{
id: "Sea-4",
category: "Sea",
@@ -84,11 +85,13 @@ const seaTechs: TechNode[] = [
},
];
-// Land branch techs (explicit definitions) - Simplified linear tree
+// Land branch techs (explicit definitions) - 4 levels
+// Reordered: Land-1 now Roads & Hospitals (moved from former Economy),
+// and existing Land techs shift up one level.
const landTechs: TechNode[] = [
- // Level 1 - Post-WW2 Ground Forces Modernization (unlocks Military Academy, AA Guns)
+ // Level 1 - Roads & Hospitals (previously Economy-1)
{ id: "Land-1", category: "Land", level: 1, cost: costForLevel(1) },
- // Level 2 - Mechanized Warfare Doctrine (unlocks Scorched Earth, policy directive)
+ // Level 2 - Military Academy
{
id: "Land-2",
category: "Land",
@@ -96,7 +99,7 @@ const landTechs: TechNode[] = [
requiresAllOf: ["Land-1"],
cost: costForLevel(2),
},
- // Level 3 - Air-Defense Grid Expansion (unlocks SAM Level 2)
+ // Level 3 - SAM Systems
{
id: "Land-3",
category: "Land",
@@ -104,7 +107,7 @@ const landTechs: TechNode[] = [
requiresAllOf: ["Land-2"],
cost: costForLevel(3),
},
- // Level 4 - Integrated SAM & Battlefield Command Systems (unlocks SAM Level 3)
+ // Level 4 - Doomsday Device
{
id: "Land-4",
category: "Land",
@@ -112,21 +115,13 @@ const landTechs: TechNode[] = [
requiresAllOf: ["Land-3"],
cost: costForLevel(4),
},
- // Level 5 - Night Vision, Thermal Imaging & Digital C3I (policy directive)
- {
- id: "Land-5",
- category: "Land",
- level: 5,
- requiresAllOf: ["Land-4"],
- cost: costForLevel(5),
- },
];
-// Air branch techs (explicit definitions) - Simplified linear tree
+// Air branch techs (explicit definitions) - 4 levels
const airTechs: TechNode[] = [
- // Level 1 - Early Jet Aviation Framework (unlocks Paratroopers)
+ // Level 1 - Paratroopers (unlocks Paratroopers, Fighter L2)
{ id: "Air-1", category: "Air", level: 1, cost: costForLevel(1) },
- // Level 2 - Supersonic Airframe Development (unlocks Fighter L2, Bomber L2)
+ // Level 2 - Advanced Jets (unlocks Fighter L3, Bomber L2)
{
id: "Air-2",
category: "Air",
@@ -134,7 +129,7 @@ const airTechs: TechNode[] = [
requiresAllOf: ["Air-1"],
cost: costForLevel(2),
},
- // Level 3 - Pulse-Doppler Radar & BVR Combat (unlocks Fighter L3, Naval Strike)
+ // Level 3 - Naval Strike (unlocks Fighter L4, Bomber L3, Naval Strike)
{
id: "Air-3",
category: "Air",
@@ -142,7 +137,7 @@ const airTechs: TechNode[] = [
requiresAllOf: ["Air-2"],
cost: costForLevel(3),
},
- // Level 4 - Fly-By-Wire Platforms & Advanced Maneuverability (unlocks Fighter L4, Bomber L3)
+ // Level 4 - TBD (placeholder for future tech)
{
id: "Air-4",
category: "Air",
@@ -152,51 +147,12 @@ const airTechs: TechNode[] = [
},
];
-// Economy branch techs (explicit definitions) - Linear 5-level tree
-const economyTechs: TechNode[] = [
- // Level 1 - National Reconstruction Program (1950s): Roads, Hospitals, +20% infrastructure effectiveness, +20% road effects
- { id: "Economy-1", category: "Economy", level: 1, cost: costForLevel(1) },
- // Level 2 - National Research & Industrial Foundations (1960s): Research Labs, policy directive
- {
- id: "Economy-2",
- category: "Economy",
- level: 2,
- requiresAllOf: ["Economy-1"],
- cost: costForLevel(2),
- },
- // Level 3 - Trade Policy Framework (1970s): policy directive (Open Trade vs Autarky)
- {
- id: "Economy-3",
- category: "Economy",
- level: 3,
- requiresAllOf: ["Economy-2"],
- cost: costForLevel(3),
- },
- // Level 4 - National Infrastructure Modernization (1980s): +20% infrastructure effectiveness, -20% maintenance, +10% construction speed
- {
- id: "Economy-4",
- category: "Economy",
- level: 4,
- requiresAllOf: ["Economy-3"],
- cost: costForLevel(4),
- },
- // Level 5 - Digital Administration & Economic Coordination Systems (Early 1990s): policy directive
- {
- id: "Economy-5",
- category: "Economy",
- level: 5,
- requiresAllOf: ["Economy-4"],
- cost: costForLevel(5),
- },
-];
-
// Compose full tree
const tree: TechNode[] = [
...baseLevels,
...nuclearTechs,
...seaTechs,
...landTechs,
- ...economyTechs,
...airTechs,
];
diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts
index 22b98ac69..7f7ece141 100644
--- a/src/core/tech/TechEffects.ts
+++ b/src/core/tech/TechEffects.ts
@@ -1,16 +1,12 @@
import { CityAAExecution } from "../execution/CityAAExecution";
import { Game, Player, UpgradeType } from "../game/Game";
-import {
- getAllPolicyDirectives,
- getPolicyOption,
- type PolicyDirectiveId,
-} from "./PolicyDirectives";
import { RESEARCH_TECH_IDS } from "./TechIds";
// Re-export for backward compatibility with existing imports
export { RESEARCH_TECH_IDS } from "./TechIds";
export interface TechMeta {
name: string;
+ shortDescription?: string;
description?: string;
}
@@ -91,65 +87,59 @@ export type TechDefinition = {
// Unified registry containing both metadata and effects per tech
export const TECHS: Readonly> = Object.freeze({
- // Sea techs - Level 1: Early Missile Navy
- [RESEARCH_TECH_IDS.EARLY_MISSILE_NAVY]: {
+ // Sea techs - Level 1: Maritime Warfare
+ [RESEARCH_TECH_IDS.SEA_MISSILE_NAVY]: {
meta: {
- name: "Early Missile Navy",
+ name: "Maritime Warfare",
+ shortDescription: "Cruisers, Diesel-Electric Subs",
description:
- "Develop guided missile technology for naval warfare. Unlocks Warship Level 2, Submarine Level 2.",
+ "Develop naval warfare capabilities. Unlocks Cruisers (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395) and Diesel-Electric Submarines (1,000 health, 200-325 damage, stealth capabilities).",
},
effects: {
onComplete: (player) => {
if (!player.hasUpgrade?.(UpgradeType.WarshipLevel2)) {
player.addUpgrade?.(UpgradeType.WarshipLevel2);
}
- if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) {
- player.addUpgrade?.(UpgradeType.SubmarineLevel2);
+ if (!player.hasUpgrade?.(UpgradeType.SubmarineResearch)) {
+ player.addUpgrade?.(UpgradeType.SubmarineResearch);
}
- },
- },
- },
- // Sea techs - Level 2: Submarine Silent Service Modernization
- [RESEARCH_TECH_IDS.SUBMARINE_SILENT_SERVICE]: {
- meta: {
- name: "Submarine Silent Service Modernization",
- description:
- "Advanced quieting and acoustic stealth for submarines. Unlocks Submarine Level 3.",
- },
- effects: {
- onComplete: (player) => {
- if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) {
- player.addUpgrade?.(UpgradeType.SubmarineLevel3);
+ if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel1)) {
+ player.addUpgrade?.(UpgradeType.SubmarineLevel1);
}
},
},
},
- // Sea techs - Level 3: SSBN Programs
- [RESEARCH_TECH_IDS.SSBN_PROGRAMS]: {
+ // Sea techs - Level 2: Fleet Modernization
+ [RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET]: {
meta: {
- name: "SSBN Programs",
+ name: "Fleet Modernization",
+ shortDescription: "Aegis, Tactical Subs",
description:
- "Ballistic missile submarine programs for strategic deterrence. Unlocks SSBNs (Submarines can launch nuclear weapons).",
+ "Advanced naval systems and fleet integration. Unlocks Aegis Warships (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Tactical Submarines (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395).",
},
effects: {
onComplete: (player) => {
- if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) {
- player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch);
+ if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) {
+ player.addUpgrade?.(UpgradeType.WarshipLevel3);
+ }
+ if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) {
+ player.addUpgrade?.(UpgradeType.SubmarineLevel2);
}
},
},
},
- // Sea techs - Level 4: Modern Fleet Sensor & SAM Integration
- [RESEARCH_TECH_IDS.MODERN_FLEET_SENSOR_SAM]: {
+ // Sea techs - Level 3: Submarine Dominance
+ [RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES]: {
meta: {
- name: "Modern Fleet Sensor & SAM Integration",
+ name: "Submarine Dominance",
+ shortDescription: "Attack Subs, Ship Anti-Air",
description:
- "Advanced sensor suites and integrated air defense systems for the fleet. Unlocks Warship Level 3, Ship SAM Systems.",
+ "Advanced submarine technology and fleet air defense. Unlocks Attack Submarines (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Ship Anti-Air Systems (allows warships to engage and destroy enemy aircraft within range).",
},
effects: {
onComplete: (player) => {
- if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) {
- player.addUpgrade?.(UpgradeType.WarshipLevel3);
+ if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) {
+ player.addUpgrade?.(UpgradeType.SubmarineLevel3);
}
if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) {
player.addUpgrade?.(UpgradeType.WarshipAntiAir);
@@ -157,213 +147,130 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
- [RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION]: {
+ // Sea techs - Level 4: Strategic Deterrent
+ [RESEARCH_TECH_IDS.SEA_TBD_LEVEL4]: {
meta: {
- name: "Post-WW2 Ground Forces Modernization",
+ name: "Strategic Deterrent",
+ shortDescription: "Nuclear Sub",
description:
- "Doctrine refined by hard-won experience improves offensive capabilities and tactical efficiency. Effects: Enables Military Academy, AA Guns. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when you attack, -10% your losses when defending.",
+ "Ballistic missile submarine programs for strategic deterrence. Unlocks Nuclear Submarines (enables submarines to launch nuclear weapons while remaining submerged and undetected, providing second-strike capability).",
},
effects: {
- onComplete: (player, game) => {
- if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) {
- player.addUpgrade?.(UpgradeType.MilitaryAcademy);
- }
- if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) {
- player.addUpgrade?.(UpgradeType.CityAntiAir);
- // Start the city AA execution to fire bullets at planes
- game.addExecution(new CityAAExecution(player));
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) {
+ player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch);
}
},
- attack: (mods) => {
- mods.defenderLossMul *= 1.1; // enemy (defender) takes 10% more losses when we attack
- },
- defense: (mods) => {
- mods.defenderLossMul *= 0.9; // we take 10% less losses when defending
- },
- attackSpeed: (mods) => {
- mods.speedMul *= 1.05; // 5% faster offensive speed
- },
},
},
- [RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM]: {
+ // Land techs - Level 1: Road Network
+ [RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS]: {
meta: {
- name: "National Reconstruction Program",
+ name: "Road Network",
+ shortDescription: "Roads, Trade Routes",
description:
- "Revitalize infrastructure and industry by mobilizing civilian labor and resources to rebuild the national economy. Effects: Enables Roads, Hospitals. +20% infrastructure spending effectiveness, +20% stronger road effects.",
+ "Develop critical infrastructure to boost your economy and military mobility. Unlocks Roads (increases unit movement speed and generates passive trade income per connected tile) and Trade Routes (enables trade ships to establish international commerce routes, generating continuous gold income).",
},
effects: {
onComplete: (player, game) => {
- // Unlock Roads upgrade and trigger reconnection
if (!player.hasUpgrade?.(UpgradeType.Roads)) {
player.addUpgrade?.(UpgradeType.Roads);
game.markPlayerNodesForReconnection?.(player);
}
- if (player.hasUpgrade?.(UpgradeType.ScorchedEarth)) {
- player.removeUpgrade?.(UpgradeType.ScorchedEarth);
+ if (!player.hasUpgrade?.(UpgradeType.InternationalTrade)) {
+ player.addUpgrade?.(UpgradeType.InternationalTrade);
}
- // Unlock Hospitals
- if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) {
- player.addUpgrade?.(UpgradeType.HospitalResearch);
- }
- },
- infrastructureEffectiveness: (mods) => {
- mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness
- },
- roadEffect: (mods) => {
- mods.effectMul *= 1.2; // +20% stronger road effects
},
},
},
- // Economy Level 2 tech - National Research & Industrial Foundations (1960s)
- [RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: {
+ // Land techs - Level 2: Ground Air Defense
+ [RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY]: {
meta: {
- name: "National Research & Industrial Foundations",
+ name: "Ground Air Defense",
+ shortDescription: "City AA, SAM+, Artillery",
description:
- "Establish national research institutions and industrial base. Effects: Enables Research Labs. Policy Directive: Industrial Expansion Priority (+5% domestic income, +20% construction speed) or Scientific Institution Priority (+30% research spending effectiveness).",
+ "Establish comprehensive air defense capabilities. Unlocks City Anti-Air (cities automatically engage enemy aircraft), Improved SAM (+35% range to 94.5 pixels, improved interception vs bombers/fighters/missiles), and Artillery (land-based heavy artillery that patrols and bombards enemy structures, spawns from Factories, 60 tile range).",
},
effects: {
- onComplete: (player) => {
- if (!player.hasUpgrade?.(UpgradeType.ResearchLabResearch)) {
- player.addUpgrade?.(UpgradeType.ResearchLabResearch);
+ onComplete: (player, game) => {
+ if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) {
+ player.addUpgrade?.(UpgradeType.CityAntiAir);
+ // Start the city AA execution to fire bullets at planes
+ game.addExecution(new CityAAExecution(player));
}
- },
- // Policy directive effects are applied via getPolicyChoice
- },
- },
- // Economy Level 3 tech - Trade Policy Framework (1970s)
- [RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK]: {
- meta: {
- name: "Trade Policy Framework",
- description:
- "Establish trade agreements and commercial policies. Policy Directive: Open Trade Policy (+5% trade income, +5% trade ship income) or Autarky Doctrine (disables international trade, +20% domestic income).",
- },
- effects: {
- // Policy directive effects are applied via getPolicyChoice
- },
- },
- // Economy Level 4 tech - National Infrastructure Modernization (1980s)
- [RESEARCH_TECH_IDS.NATIONAL_INFRASTRUCTURE_MODERNIZATION]: {
- meta: {
- name: "National Infrastructure Modernization",
- description:
- "Modernize national infrastructure with advanced technology. Effects: +20% infrastructure spending effectiveness, -20% maintenance costs, +10% construction speed.",
- },
- effects: {
- infrastructureEffectiveness: (mods) => {
- mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness
- },
- constructionSpeed: (mods) => {
- mods.speedMul *= 1.1; // +10% construction speed
- },
- // TODO: -20% maintenance costs when maintenance is implemented
- },
- },
- // Economy Level 5 tech - Digital Administration & Economic Coordination Systems (Early 1990s)
- [RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: {
- meta: {
- name: "Digital Administration & Economic Coordination Systems",
- description:
- "Digital systems for administration and economic coordination. Policy Directive: Market Optimization Systems (+10% domestic income, -10% maintenance costs) or Central Planning Automation (+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed).",
- },
- effects: {
- // Policy directive effects are applied via getPolicyChoice
- },
- },
- // Land Level 2 tech - Mechanized Warfare Doctrine (1960s)
- [RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE]: {
- meta: {
- name: "Mechanized Warfare Doctrine",
- description:
- "Develop doctrine for mechanized infantry and armored operations. Effects: Unlocks Scorched Earth. +5% offensive speed. Policy Directive (20%): Mobile Infantry Tactics (-10% your losses attacking, +10% enemy losses when they attack you) or Armored Breakthrough Doctrine (+10% enemy losses when you attack, -10% your losses when defending).",
- },
- effects: {
- attackSpeed: (mods) => {
- mods.speedMul *= 1.05; // 5% faster offensive speed
- },
- // Policy directive effects are applied via getPolicyChoice
- },
- },
- // Land Level 3 tech - Air-Defense Grid Expansion (1970s)
- [RESEARCH_TECH_IDS.AIR_DEFENSE_GRID_EXPANSION]: {
- meta: {
- name: "Air-Defense Grid Expansion",
- description:
- "Expand air defense networks with improved SAM coverage. Effects: Enables SAM Level 2. +5% offensive speed. Casualty Effects (20%): +15% enemy losses when they attack you, -5% your losses when defending.",
- },
- effects: {
- onComplete: (player) => {
if (!player.hasUpgrade?.(UpgradeType.SAMLevel2)) {
player.addUpgrade?.(UpgradeType.SAMLevel2);
}
- },
- defense: (mods) => {
- mods.attackerLossMul *= 1.15; // enemy takes 15% more losses when they attack us
- mods.defenderLossMul *= 0.95; // we take 5% less losses when defending
- },
- attackSpeed: (mods) => {
- mods.speedMul *= 1.05; // 5% faster offensive speed
+ if (!player.hasUpgrade?.(UpgradeType.ArtilleryResearch)) {
+ player.addUpgrade?.(UpgradeType.ArtilleryResearch);
+ }
},
},
},
- // Land Level 4 tech - Integrated SAM & Battlefield Command Systems (1980s)
- [RESEARCH_TECH_IDS.INTEGRATED_SAM_BATTLEFIELD_COMMAND]: {
+ // Land techs - Level 3: Modern Air Defense
+ [RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS]: {
meta: {
- name: "Integrated SAM & Battlefield Command Systems",
+ name: "Modern Air Defense",
+ shortDescription: "SAM++, Hospitals, Artillery+",
description:
- "Integrate SA-10, Patriot-era SAM platforms with C3I systems. Effects: Enables SAM Level 3. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when they attack you, -10% your losses when attacking.",
+ "Achieve peak defensive and medical capabilities. Unlocks Advanced SAM (+82.25% range to 127.6 pixels, maximum interception range exceeding H-bomb blast radius, highest success vs aircraft/missiles), Hospitals (increases population growth rate, accelerating troops/economy), and Artillery Level 2 (75 tile range, increased damage and health for all artillery).",
},
effects: {
onComplete: (player) => {
if (!player.hasUpgrade?.(UpgradeType.SAMLevel3)) {
player.addUpgrade?.(UpgradeType.SAMLevel3);
}
- },
- defense: (mods) => {
- mods.attackerLossMul *= 1.1; // enemy takes 10% more losses when they attack us
- },
- attack: (mods) => {
- mods.attackerLossMul *= 0.9; // we take 10% less losses when attacking
- },
- attackSpeed: (mods) => {
- mods.speedMul *= 1.05; // 5% faster offensive speed
+ if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) {
+ player.addUpgrade?.(UpgradeType.HospitalResearch);
+ }
+ if (!player.hasUpgrade?.(UpgradeType.ArtilleryLevel2)) {
+ player.addUpgrade?.(UpgradeType.ArtilleryLevel2);
+ }
},
},
},
- // Land Level 5 tech - Night Vision, Thermal Imaging & Digital C3I (Early 1990s)
- [RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I]: {
+ // Land techs - Level 4: Military Academy
+ [RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE]: {
meta: {
- name: "Night Vision, Thermal Imaging & Digital C3I",
+ name: "Military Academy",
+ shortDescription: "Academy, Artillery++",
description:
- "Equip forces with night vision, thermal imaging, and digital command systems for 24-hour combat capability. Effects: +5% offensive speed. Policy Directive (20%): High-Tempo Maneuver Warfare (+10% enemy losses when you attack, -10% your losses when attacking) or Precision Defensive Fire Doctrine (+10% enemy losses when they attack you, -10% your losses when defending).",
+ "Establish elite military training infrastructure. Unlocks Military Academy building (increases enemy casualties in land battles: +10% with one, asymptotically capped at +20% with multiple, scaled by level/health/roads) and Artillery Level 3 (90 tile range, maximum damage and durability for all artillery).",
},
effects: {
- attackSpeed: (mods) => {
- mods.speedMul *= 1.05; // 5% faster offensive speed
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) {
+ player.addUpgrade?.(UpgradeType.MilitaryAcademy);
+ }
+ if (!player.hasUpgrade?.(UpgradeType.ArtilleryLevel3)) {
+ player.addUpgrade?.(UpgradeType.ArtilleryLevel3);
+ }
},
- // Policy directive effects are applied via getPolicyChoice
},
},
- // Air techs - Level 1: Early Jet Aviation Framework
- [RESEARCH_TECH_IDS.EARLY_JET_AVIATION_FRAMEWORK]: {
+ // Air techs - Level 1: Early Air Power
+ [RESEARCH_TECH_IDS.AIR_PARATROOPERS]: {
meta: {
- name: "Early Jet Aviation Framework",
+ name: "Early Air Power",
+ shortDescription: "Gen 1 Fighters, Paratroopers",
description:
- "Establish jet aviation infrastructure and doctrine. Unlocks Paratroopers.",
+ "Develop airborne warfare capabilities. Unlocks Jet Engines enabling 1st Generation Fighters (750 health, 200-325 damage, engages enemy aircraft) and Paratroopers (airborne infantry units that can be deployed behind enemy lines for rapid territorial expansion).",
},
effects: {
onComplete: (player) => {
- if (!player.hasUpgrade?.(UpgradeType.AirUpgrade1)) {
- player.addUpgrade?.(UpgradeType.AirUpgrade1);
+ if (!player.hasUpgrade?.(UpgradeType.JetEngines)) {
+ player.addUpgrade?.(UpgradeType.JetEngines);
}
},
},
},
- // Air techs - Level 2: Supersonic Airframe Development
- [RESEARCH_TECH_IDS.SUPERSONIC_AIRFRAME_DEVELOPMENT]: {
+ // Air techs - Level 2: Jet Technology
+ [RESEARCH_TECH_IDS.AIR_ADVANCED_JETS]: {
meta: {
- name: "Supersonic Airframe Development",
+ name: "Jet Technology",
+ shortDescription: "Gen 2 Fighters, Heavy Bombers",
description:
- "Develop supersonic aircraft designs. Unlocks Fighter Level 2, Bomber Level 2.",
+ "Advance to next-generation aircraft systems. Unlocks 2nd Generation Fighters (+33.3% health to 1,000, +50% minimum damage to 300, +30.8% maximum damage to 425) and Heavy Bombers (+20% health to 600, +20% damage to 300, +40% range to 350, +50% speed to 3).",
},
effects: {
onComplete: (player) => {
@@ -376,12 +283,13 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
- // Air techs - Level 3: Pulse-Doppler Radar & BVR Combat
- [RESEARCH_TECH_IDS.PULSE_DOPPLER_RADAR_BVR]: {
+ // Air techs - Level 3: Anti-Ship Warfare
+ [RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE]: {
meta: {
- name: "Pulse-Doppler Radar & BVR Combat",
+ name: "Anti-Ship Warfare",
+ shortDescription: "Gen 3 Fighters, Anti-ship",
description:
- "Advanced radar and beyond-visual-range combat systems. Unlocks Fighter Level 3, Naval Strike Capability.",
+ "Develop advanced anti-ship capabilities for air superiority. Unlocks 3rd Generation Fighters (+25% health to 1,250, +33.3% minimum damage to 400, +23.5% maximum damage to 525) and Naval Strike Weapons (enables fighters to target and attack warships, transport ships, and trade ships).",
},
effects: {
onComplete: (player) => {
@@ -394,12 +302,13 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
- // Air techs - Level 4: Fly-By-Wire Platforms & Advanced Maneuverability
- [RESEARCH_TECH_IDS.FLY_BY_WIRE_PLATFORMS]: {
+ // Air techs - Level 4: TBD
+ [RESEARCH_TECH_IDS.AIR_TBD_LEVEL4]: {
meta: {
- name: "Fly-By-Wire Platforms & Advanced Maneuverability",
+ name: "Advanced Fighters",
+ shortDescription: "Gen 4 Fighters, Supersonic Bombers",
description:
- "Digital flight control systems for maximum aircraft performance. Unlocks Fighter Level 4, Bomber Level 3.",
+ "Master cutting-edge aerospace technology. Unlocks 4th Generation Fighters (+20% health to 1,500, +25% minimum damage to 500, +19% maximum damage to 625) and Supersonic Bombers (+16.7% health to 700, +16.7% damage to 350, +28.6% range to 450, +33.3% speed to 4).",
},
effects: {
onComplete: (player) => {
@@ -412,23 +321,30 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
+ // Nuclear techs - Level 1: Atomic Weapons
[RESEARCH_TECH_IDS.NUCLEAR_FISSION]: {
meta: {
- name: "Nuclear Fission",
- description: "Enables: Atom Bomb",
+ name: "Atomic Weapons",
+ shortDescription: "Atom Bomb, Silo",
+ description:
+ "Harness nuclear fission technology. Unlocks Atom Bomb (basic nuclear weapon with large blast radius causing massive area damage) and Missile Silo (required launch facility for deploying nuclear weapons against enemy targets).",
},
effects: {
onComplete: (player) => {
if (!player.hasUpgrade?.(UpgradeType.NuclearFission)) {
player.addUpgrade?.(UpgradeType.NuclearFission);
}
+ // Note: MissileSilo building is unlocked via gameplay progression
},
},
},
+ // Nuclear techs - Level 2: Thermonuclear Weapons
[RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING]: {
meta: {
- name: "Thermonuclear Staging",
- description: "Enables: Hydrogen Bomb",
+ name: "Thermonuclear Weapons",
+ shortDescription: "Hydrogen Bomb",
+ description:
+ "Advance to fusion-based thermonuclear weapons. Unlocks Hydrogen Bomb (high-yield nuclear weapon with significantly larger blast radius than atom bombs, capable of devastating multi-tile areas and causing catastrophic damage to enemy infrastructure).",
},
effects: {
onComplete: (player) => {
@@ -438,10 +354,13 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
+ // Nuclear techs - Level 3: MIRV Warheads
[RESEARCH_TECH_IDS.MIRV_TECHNOLOGY]: {
meta: {
- name: "MIRV Technology",
- description: "Enables: MIRV",
+ name: "MIRV Warheads",
+ shortDescription: "MIRV",
+ description:
+ "Develop Multiple Independent Reentry Vehicle technology. Unlocks MIRV (advanced nuclear missiles deploying multiple independently targetable warheads from a single missile, significantly harder for enemy SAM systems to intercept, ensuring delivery of nuclear payload).",
},
effects: {
onComplete: (player) => {
@@ -451,10 +370,13 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
- [RESEARCH_TECH_IDS.DOOMSDAY_DEVICE]: {
+ // Nuclear techs - Level 4: TBD
+ [RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4]: {
meta: {
name: "Doomsday Device",
- description: "Enables: Doomsday Device",
+ shortDescription: "Global deterrence",
+ description:
+ "Construct the ultimate deterrent. Unlocks Doomsday Device. When any of your tiles are hit by a nuclear detonation, the device auto-triggers: it consumes itself, plays a global alert, and unleashes an expanding fallout wave across every land tile. The wave instantly destroys all bombers, fighters, warships, and trade ships; damages remaining structures by 80% of current health; relinquishes claimed land; and seeds widespread fallout (noise-pattern coverage) world-wide.",
},
effects: {
onComplete: (player) => {
@@ -531,7 +453,6 @@ export function applyTechCompletionEffects(
*/
export function defenseCasualtyModifiers(defender: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): DefenseCasualtyModifiers {
const mods: DefenseCasualtyModifiers = {
attackerLossMul: 1.0,
@@ -542,22 +463,6 @@ export function defenseCasualtyModifiers(defender: {
def.effects?.defense?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = defender.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.defenderLossMul) {
- mods.defenderLossMul *= option.effects.defenderLossMul;
- }
- if (option?.effects.attackerLossMulOnDefense) {
- mods.attackerLossMul *= option.effects.attackerLossMulOnDefense;
- }
- }
- }
return mods;
}
@@ -569,7 +474,6 @@ export function defenseCasualtyModifiers(defender: {
*/
export function attackCasualtyModifiers(attacker: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): DefenseCasualtyModifiers {
const mods: DefenseCasualtyModifiers = {
attackerLossMul: 1.0,
@@ -580,22 +484,6 @@ export function attackCasualtyModifiers(attacker: {
def.effects?.attack?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = attacker.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.attackerLossMul) {
- mods.attackerLossMul *= option.effects.attackerLossMul;
- }
- if (option?.effects.enemyLossMulOnAttack) {
- mods.defenderLossMul *= option.effects.enemyLossMulOnAttack;
- }
- }
- }
return mods;
}
@@ -605,7 +493,6 @@ export function attackCasualtyModifiers(attacker: {
*/
export function attackSpeedModifiers(attacker: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): AttackSpeedModifiers {
const mods: AttackSpeedModifiers = {
speedMul: 1.0,
@@ -615,19 +502,6 @@ export function attackSpeedModifiers(attacker: {
def.effects?.attackSpeed?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = attacker.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.attackSpeedMul) {
- mods.speedMul *= option.effects.attackSpeedMul;
- }
- }
- }
return mods;
}
@@ -637,7 +511,6 @@ export function attackSpeedModifiers(attacker: {
*/
export function constructionSpeedModifiers(player: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): ConstructionSpeedModifiers {
const mods: ConstructionSpeedModifiers = {
speedMul: 1.0,
@@ -648,19 +521,6 @@ export function constructionSpeedModifiers(player: {
def.effects?.constructionSpeed?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = player.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.constructionSpeedMul) {
- mods.speedMul *= option.effects.constructionSpeedMul;
- }
- }
- }
return mods;
}
@@ -689,7 +549,6 @@ export function researchEffectivenessModifiers(
*/
export function incomeModifiers(player: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): IncomeModifiers {
const mods: IncomeModifiers = {
domesticIncomeMul: 1.0,
@@ -700,19 +559,6 @@ export function incomeModifiers(player: {
def.effects?.income?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = player.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.domesticIncomeMul) {
- mods.domesticIncomeMul *= option.effects.domesticIncomeMul;
- }
- }
- }
return mods;
}
@@ -722,7 +568,6 @@ export function incomeModifiers(player: {
*/
export function infrastructureEffectivenessModifiers(player: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): InfrastructureEffectivenessModifiers {
const mods: InfrastructureEffectivenessModifiers = {
effectivenessMul: 1.0,
@@ -732,20 +577,6 @@ export function infrastructureEffectivenessModifiers(player: {
def.effects?.infrastructureEffectiveness?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = player.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.infrastructureSpendingEffectivenessMul) {
- mods.effectivenessMul *=
- option.effects.infrastructureSpendingEffectivenessMul;
- }
- }
- }
return mods;
}
@@ -756,7 +587,6 @@ export function infrastructureEffectivenessModifiers(player: {
*/
export function tradeIncomeModifiers(player: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): TradeIncomeModifiers {
const mods: TradeIncomeModifiers = {
incomeMul: 1.0,
@@ -767,22 +597,6 @@ export function tradeIncomeModifiers(player: {
def.effects?.tradeIncome?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = player.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.tradeIncomeMul) {
- mods.incomeMul *= option.effects.tradeIncomeMul;
- }
- if (option?.effects.tradeShipIncomeMul) {
- mods.tradeShipIncomeMul *= option.effects.tradeShipIncomeMul;
- }
- }
- }
return mods;
}
@@ -792,7 +606,6 @@ export function tradeIncomeModifiers(player: {
*/
export function roadEffectModifiers(player: {
hasResearchedTech?(techId: string): boolean;
- getPolicyChoice?(directiveId: string): string | null;
}): RoadEffectModifiers {
const mods: RoadEffectModifiers = {
effectMul: 1.0,
@@ -803,18 +616,5 @@ export function roadEffectModifiers(player: {
def.effects?.roadEffect?.(mods);
}
}
- // Apply policy directive effects
- for (const directive of getAllPolicyDirectives()) {
- const chosenOptionId = player.getPolicyChoice?.(directive.id);
- if (chosenOptionId) {
- const option = getPolicyOption(
- directive.id as PolicyDirectiveId,
- chosenOptionId,
- );
- if (option?.effects.roadEffectMul) {
- mods.effectMul *= option.effects.roadEffectMul;
- }
- }
- }
return mods;
}
diff --git a/src/core/tech/TechIds.ts b/src/core/tech/TechIds.ts
index 2835309cf..f9f1be8aa 100644
--- a/src/core/tech/TechIds.ts
+++ b/src/core/tech/TechIds.ts
@@ -1,49 +1,47 @@
/**
* Central tech IDs for research tree items.
* This file has NO dependencies to prevent circular imports.
- * Keep IDs aligned with ResearchTreeModal generation (e.g., "Land-1").
+ * Keep IDs aligned with ResearchTree definitions (e.g., "Land-1").
*/
export const RESEARCH_TECH_IDS = {
- // Air techs - Level 1
+ // Air techs
+ AIR_PARATROOPERS: "Air-1",
+ AIR_ADVANCED_JETS: "Air-2",
+ AIR_NAVAL_STRIKE: "Air-3",
+ AIR_TBD_LEVEL4: "Air-4",
+ // Sea techs
+ SEA_MISSILE_NAVY: "Sea-1",
+ SEA_ADVANCED_FLEET: "Sea-2",
+ SEA_NUCLEAR_SUBMARINES: "Sea-3",
+ SEA_TBD_LEVEL4: "Sea-4",
+ // Land techs
+ LAND_ROADS_HOSPITALS: "Land-1",
+ LAND_MILITARY_ACADEMY: "Land-2",
+ LAND_SAM_SYSTEMS: "Land-3",
+ LAND_DOOMSDAY_DEVICE: "Land-4",
+ // Economy techs (legacy; category removed, kept for backwards compatibility)
+ ECONOMY_ROADS_HOSPITALS: "Land-1",
+ // Nuclear techs
+ NUCLEAR_FISSION: "Nuclear-1",
+ THERMONUCLEAR_STAGING: "Nuclear-2",
+ MIRV_TECHNOLOGY: "Nuclear-3",
+ NUCLEAR_TBD_LEVEL4: "Nuclear-4",
+
+ // Legacy mappings for backwards compatibility during migration
EARLY_JET_AVIATION_FRAMEWORK: "Air-1",
- // Air techs - Level 2
SUPERSONIC_AIRFRAME_DEVELOPMENT: "Air-2",
- // Air techs - Level 3
PULSE_DOPPLER_RADAR_BVR: "Air-3",
- // Air techs - Level 4
FLY_BY_WIRE_PLATFORMS: "Air-4",
- // Sea techs - Level 1
EARLY_MISSILE_NAVY: "Sea-1",
- // Sea techs - Level 2
SUBMARINE_SILENT_SERVICE: "Sea-2",
- // Sea techs - Level 3
SSBN_PROGRAMS: "Sea-3",
- // Sea techs - Level 4
MODERN_FLEET_SENSOR_SAM: "Sea-4",
- // Land techs - Level 1
POST_WW2_GROUND_FORCES_MODERNIZATION: "Land-1",
- // Land techs - Level 2
MECHANIZED_WARFARE_DOCTRINE: "Land-2",
- // Land techs - Level 3
AIR_DEFENSE_GRID_EXPANSION: "Land-3",
- // Land techs - Level 4
INTEGRATED_SAM_BATTLEFIELD_COMMAND: "Land-4",
- // Land techs - Level 5
NIGHT_VISION_THERMAL_C3I: "Land-5",
- // Economy techs - Level 1 (1950s)
NATIONAL_RECONSTRUCTION_PROGRAM: "Economy-1",
- // Economy techs - Level 2 (1960s)
- NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "Economy-2",
- // Economy techs - Level 3 (1970s)
- TRADE_POLICY_FRAMEWORK: "Economy-3",
- // Economy techs - Level 4 (1980s)
- NATIONAL_INFRASTRUCTURE_MODERNIZATION: "Economy-4",
- // Economy techs - Level 5 (Early 1990s)
- DIGITAL_ADMINISTRATION_SYSTEMS: "Economy-5",
- // Nuclear techs
- NUCLEAR_FISSION: "Nuclear-1",
- THERMONUCLEAR_STAGING: "Nuclear-2",
- MIRV_TECHNOLOGY: "Nuclear-3",
DOOMSDAY_DEVICE: "Nuclear-4",
} as const;
diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts
index 5a2db707a..91a2b1f57 100644
--- a/tests/Warship.test.ts
+++ b/tests/Warship.test.ts
@@ -265,4 +265,46 @@ describe("Warship", () => {
expect(exec.isActive()).toBe(false);
});
+
+ test("Warship targets coastal artillery when in range", async () => {
+ // Build a port for player1 (required for warship deployment)
+ player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
+
+ // Build warship for player1 near the coast
+ const warship = player1.buildUnit(
+ UnitType.Warship,
+ game.ref(coastX + 2, 10),
+ {
+ patrolTile: game.ref(coastX + 2, 10),
+ },
+ );
+ game.addExecution(new WarshipExecution(warship));
+
+ // Give player2 coastal land territory
+ for (let y = 9; y < 12; y++) {
+ const tile = game.ref(coastX - 1, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player2, tile);
+ }
+ }
+
+ // Build artillery for player2 on coastal land (within warship range)
+ const artillery = player2.buildUnit(
+ UnitType.Artillery,
+ game.ref(coastX - 1, 10),
+ {
+ patrolTile: game.ref(coastX - 1, 10),
+ },
+ );
+
+ const initialHealth = artillery.health();
+
+ // Run ticks to allow warship to detect and target artillery
+ executeTicks(game, 25);
+
+ // Warship should target the coastal artillery
+ expect(warship.targetUnit()).toBe(artillery);
+ // Artillery should take damage from warship shells
+ expect(artillery.health()).toBeLessThan(initialHealth);
+ });
});
diff --git a/tests/core/execution/ArtilleryExecution.test.ts b/tests/core/execution/ArtilleryExecution.test.ts
new file mode 100644
index 000000000..008897bf2
--- /dev/null
+++ b/tests/core/execution/ArtilleryExecution.test.ts
@@ -0,0 +1,288 @@
+import { ArtilleryExecution } from "../../../src/core/execution/ArtilleryExecution";
+import { MoveArtilleryExecution } from "../../../src/core/execution/MoveArtilleryExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ Unit,
+ UnitType,
+} from "../../../src/core/game/Game";
+import { PlayerImpl } from "../../../src/core/game/PlayerImpl";
+import { UnitImpl } from "../../../src/core/game/UnitImpl";
+import { setup } from "../../util/Setup";
+import { executeTicks } from "../../util/utils";
+
+// half_land_half_ocean has land on the left side (x < 7) and ocean on the right
+const landX = 5; // Safe land coordinate
+
+/**
+ * Test helper to set unit level directly.
+ * Uses internal property access since there's no public API for test scenarios.
+ */
+function setUnitLevel(unit: Unit, level: number): void {
+ (unit as UnitImpl)["_level"] = level;
+}
+
+describe("ArtilleryExecution", () => {
+ let game: Game;
+ let player1: Player;
+ let player2: Player;
+ let artillery: Unit;
+ let factory: Unit;
+
+ beforeEach(async () => {
+ game = await setup(
+ "half_land_half_ocean",
+ { infiniteGold: true, instantBuild: true },
+ [
+ new PlayerInfo("p1", "Player 1", PlayerType.Human, null, "p1_id"),
+ new PlayerInfo("p2", "Player 2", PlayerType.Human, null, "p2_id"),
+ ],
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ player1 = game.player("p1_id");
+ player2 = game.player("p2_id");
+
+ // Ensure player1 controls some territory (on land side)
+ for (let x = 0; x < 6; x++) {
+ for (let y = 0; y < 6; y++) {
+ const tile = game.ref(x, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player1 as PlayerImpl, tile);
+ }
+ }
+ }
+
+ // Build factory and artillery for player1 (on land)
+ factory = player1.buildUnit(UnitType.Factory, game.ref(2, 2), {});
+ artillery = player1.buildUnit(UnitType.Artillery, game.ref(3, 3), {
+ patrolTile: game.ref(3, 3),
+ });
+
+ game.addExecution(new ArtilleryExecution(artillery));
+ });
+
+ describe("Target Priority", () => {
+ test("should prioritize enemy artillery over defense posts", () => {
+ // Give player2 some nearby territory (on land)
+ for (let x = 0; x < 6; x++) {
+ for (let y = 10; y < 16; y++) {
+ const tile = game.ref(x, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player2 as PlayerImpl, tile);
+ }
+ }
+ }
+
+ // Declare war so targeting is allowed
+ player1.setWarWith(player2);
+ player2.setWarWith(player1);
+
+ const defensePost = player2.buildUnit(
+ UnitType.DefensePost,
+ game.ref(2, 12),
+ {},
+ );
+ const enemyArtillery = player2.buildUnit(
+ UnitType.Artillery,
+ game.ref(3, 13),
+ { patrolTile: game.ref(3, 13) },
+ );
+
+ executeTicks(game, 15);
+
+ // Artillery should target enemy artillery first
+ expect(artillery.targetUnit()).toBe(enemyArtillery);
+ });
+
+ test("should prioritize defense posts over other structures", () => {
+ // Give player2 some nearby territory (on land)
+ for (let x = 0; x < 5; x++) {
+ for (let y = 8; y < 13; y++) {
+ const tile = game.ref(x, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player2 as PlayerImpl, tile);
+ }
+ }
+ }
+
+ player1.setWarWith(player2);
+ player2.setWarWith(player1);
+
+ const city = player2.buildUnit(UnitType.City, game.ref(2, 10), {});
+ const defensePost = player2.buildUnit(
+ UnitType.DefensePost,
+ game.ref(3, 11),
+ {},
+ );
+
+ executeTicks(game, 15);
+
+ expect(artillery.targetUnit()).toBe(defensePost);
+ });
+
+ test("should not target neutral players without war", () => {
+ // Give player2 some nearby territory (no war declared, on land)
+ for (let x = 0; x < 6; x++) {
+ for (let y = 10; y < 16; y++) {
+ const tile = game.ref(x, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player2 as PlayerImpl, tile);
+ }
+ }
+ }
+
+ const city = player2.buildUnit(UnitType.City, game.ref(2, 12), {});
+
+ executeTicks(game, 15);
+
+ // Should not target without war declared
+ expect(artillery.targetUnit()).toBeUndefined();
+ });
+ });
+
+ describe("Deletion on Conquest", () => {
+ test("should be destroyed when tile is conquered by enemy", () => {
+ const artilleryTile = artillery.tile();
+ const initialHealth = artillery.health();
+
+ expect(initialHealth).toBeGreaterThan(0);
+ expect(artillery.isActive()).toBe(true);
+
+ // Enemy conquers the artillery's tile
+ game.conquer(player2 as PlayerImpl, artilleryTile);
+
+ // First tick: artillery sees conquered tile, sets health to 0
+ // Second tick: artillery is deleted because health <= 0
+ executeTicks(game, 2);
+
+ // Artillery should be deleted (health = 0, inactive)
+ expect(artillery.health()).toBe(0);
+ expect(artillery.isActive()).toBe(false);
+ });
+
+ test("should survive when tile owner matches unit owner", () => {
+ const artilleryTile = artillery.tile();
+ const initialHealth = artillery.health();
+
+ // Re-conquer with same player (should not destroy)
+ game.conquer(player1 as PlayerImpl, artilleryTile);
+
+ executeTicks(game, 1);
+
+ expect(artillery.health()).toBe(initialHealth);
+ expect(artillery.isActive()).toBe(true);
+ });
+ });
+
+ describe("Distance Validation (MoveArtilleryExecution)", () => {
+ test("should allow movement within level 1 range (60 tiles)", () => {
+ const startTile = artillery.tile();
+ // Move to a nearby location within map bounds (map is ~16x16)
+ const targetTile = game.ref(3, 8);
+
+ const moveExec = new MoveArtilleryExecution(
+ player1,
+ artillery.id(),
+ targetTile,
+ );
+ moveExec.init(game, game.ticks());
+
+ executeTicks(game, 1);
+
+ expect(artillery.targetTile()).toBe(targetTile);
+ expect(artillery.patrolTile()).toBe(targetTile);
+ });
+
+ test("should reject movement beyond level 1 range (60 tiles)", () => {
+ // Since map is small, we can't actually test 60 tile distance
+ // This test verifies the validation logic exists
+ const startTile = artillery.tile();
+ const targetTile = game.ref(3, 13);
+
+ const moveExec = new MoveArtilleryExecution(
+ player1,
+ artillery.id(),
+ targetTile,
+ );
+ moveExec.init(game, game.ticks());
+
+ executeTicks(game, 1);
+
+ // Should still work on small map (under 60 tile limit)
+ expect(artillery.targetTile()).toBe(targetTile);
+ });
+
+ test("should allow longer movement for level 2 (75 tiles)", () => {
+ // Set artillery to level 2
+ setUnitLevel(artillery, 2);
+
+ const startTile = artillery.tile();
+ const targetTile = game.ref(3, 13);
+
+ const moveExec = new MoveArtilleryExecution(
+ player1,
+ artillery.id(),
+ targetTile,
+ );
+ moveExec.init(game, game.ticks());
+
+ executeTicks(game, 1);
+
+ expect(artillery.targetTile()).toBe(targetTile);
+ });
+
+ test("should allow longest movement for level 3 (90 tiles)", () => {
+ // Set artillery to level 3
+ setUnitLevel(artillery, 3);
+
+ const startTile = artillery.tile();
+ const targetTile = game.ref(3, 13);
+
+ const moveExec = new MoveArtilleryExecution(
+ player1,
+ artillery.id(),
+ targetTile,
+ );
+ moveExec.init(game, game.ticks());
+
+ executeTicks(game, 1);
+
+ expect(artillery.targetTile()).toBe(targetTile);
+ });
+
+ test("should clear target unit when redirected", () => {
+ // Give artillery a target first (on land)
+ for (let x = 0; x < 6; x++) {
+ for (let y = 10; y < 16; y++) {
+ const tile = game.ref(x, y);
+ if (game.isValidRef(tile) && game.isLand(tile)) {
+ game.conquer(player2 as PlayerImpl, tile);
+ }
+ }
+ }
+ player1.setWarWith(player2);
+ player2.setWarWith(player1);
+ const enemyCity = player2.buildUnit(UnitType.City, game.ref(2, 12), {});
+
+ executeTicks(game, 15);
+ expect(artillery.targetUnit()).toBeDefined();
+
+ // Now redirect the artillery
+ const newTarget = game.ref(4, 4);
+ const moveExec = new MoveArtilleryExecution(
+ player1,
+ artillery.id(),
+ newTarget,
+ );
+ moveExec.init(game, game.ticks());
+
+ expect(artillery.targetUnit()).toBeUndefined();
+ });
+ });
+});
diff --git a/tests/core/execution/ResearchPriorityAllocation.test.ts b/tests/core/execution/ResearchPriorityAllocation.test.ts
index 7d8152868..8da5ba4d8 100644
--- a/tests/core/execution/ResearchPriorityAllocation.test.ts
+++ b/tests/core/execution/ResearchPriorityAllocation.test.ts
@@ -44,7 +44,7 @@ describe("Research Priority Allocation", () => {
});
describe("buildMissingPrereqPath logic validation", () => {
- it("should identify all prerequisites for Economy-4 when nothing is researched", () => {
+ it("should identify all prerequisites for Land-4 when nothing is researched", () => {
// Simulate the buildMissingPrereqPath logic
const nodes = getTechNodes();
const researched = new Set();
@@ -90,19 +90,19 @@ describe("Research Priority Allocation", () => {
return path;
};
- // Test: When setting priority to Economy-4, it should identify Economy-1, Economy-2, Economy-3 as prerequisites
- const pathSet = buildMissingPrereqPath("Economy-4");
+ // Test: When setting priority to Land-4, it should identify Land-1, Land-2, Land-3 as prerequisites
+ const pathSet = buildMissingPrereqPath("Land-4");
- expect(pathSet.has("Economy-1")).toBe(true);
- expect(pathSet.has("Economy-2")).toBe(true);
- expect(pathSet.has("Economy-3")).toBe(true);
- expect(pathSet.has("Economy-4")).toBe(false); // Target itself should not be in path
+ expect(pathSet.has("Land-1")).toBe(true);
+ expect(pathSet.has("Land-2")).toBe(true);
+ expect(pathSet.has("Land-3")).toBe(true);
+ expect(pathSet.has("Land-4")).toBe(false); // Target itself should not be in path
expect(pathSet.size).toBe(3);
});
- it("should identify correct frontier when Economy-1 is already researched", () => {
+ it("should identify correct frontier when Land-1 is already researched", () => {
const nodes = getTechNodes();
- const researched = new Set(["Economy-1"]); // Already researched Economy-1
+ const researched = new Set(["Land-1"]); // Already researched Land-1
const byId = new Map(nodes.map((n) => [n.id, n] as const));
const sameCat = (a: string, b: string) =>
(byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? "");
@@ -145,12 +145,12 @@ describe("Research Priority Allocation", () => {
return path;
};
- // Test: When Economy-1 is researched and priority is Economy-4, path should only include Economy-2, Economy-3
- const pathSet = buildMissingPrereqPath("Economy-4");
+ // Test: When Land-1 is researched and priority is Land-4, path should only include Land-2, Land-3
+ const pathSet = buildMissingPrereqPath("Land-4");
- expect(pathSet.has("Economy-1")).toBe(false); // Already researched
- expect(pathSet.has("Economy-2")).toBe(true);
- expect(pathSet.has("Economy-3")).toBe(true);
+ expect(pathSet.has("Land-1")).toBe(false); // Already researched
+ expect(pathSet.has("Land-2")).toBe(true);
+ expect(pathSet.has("Land-3")).toBe(true);
expect(pathSet.size).toBe(2);
});
@@ -206,21 +206,20 @@ describe("Research Priority Allocation", () => {
// Only level-1 techs should be available when nothing is researched
const availableIds = available.map((n) => n.id);
- expect(availableIds).toContain("Economy-1");
expect(availableIds).toContain("Land-1");
expect(availableIds).toContain("Sea-1");
expect(availableIds).toContain("Air-1");
expect(availableIds).toContain("Nuclear-1");
- // Set priority to Economy-4
- const pathSet = buildMissingPrereqPath("Economy-4");
+ // Set priority to Land-4
+ const pathSet = buildMissingPrereqPath("Land-4");
// Frontier = intersection of pathSet and available
const frontier = available.filter((n) => pathSet.has(n.id));
- // Only Economy-1 should be in the frontier (the only available prereq)
+ // Only Land-1 should be in the frontier (the only available prereq)
expect(frontier.length).toBe(1);
- expect(frontier[0].id).toBe("Economy-1");
+ expect(frontier[0].id).toBe("Land-1");
});
it("should prioritize frontier techs when priority target is not directly available", () => {
@@ -268,13 +267,13 @@ describe("Research Priority Allocation", () => {
return path;
};
- const priorityId = "Economy-4";
+ const priorityId = "Land-4";
const available = nodes.filter(
(n) => !researched.has(n.id) && isTechAvailable(n.id, researched),
);
const priorityInSet = available.some((n) => n.id === priorityId);
- expect(priorityInSet).toBe(false); // Economy-4 should NOT be directly available
+ expect(priorityInSet).toBe(false); // Land-4 should NOT be directly available
// Simulate allocation logic
const xTotal = 1000; // arbitrary total research points
@@ -300,12 +299,12 @@ describe("Research Priority Allocation", () => {
}
}
- // Economy-1 should receive 50% of the total (500 points)
- expect(alloc["Economy-1"]).toBe(500);
+ // Land-1 should receive 50% of the total (500 points)
+ expect(alloc["Land-1"]).toBe(500);
// Other level-1 techs should share the remaining 50%
- const otherTechs = ["Land-1", "Sea-1", "Air-1", "Nuclear-1"];
- const expectedShareOthers = 500 / otherTechs.length; // 125 each
+ const otherTechs = ["Sea-1", "Air-1", "Nuclear-1"];
+ const expectedShareOthers = 500 / otherTechs.length; // ~166.67 each
for (const techId of otherTechs) {
expect(alloc[techId]).toBeCloseTo(expectedShareOthers, 5);
}
@@ -316,8 +315,8 @@ describe("Research Priority Allocation", () => {
it("should eventually progress frontier as prerequisite techs complete", () => {
const nodes = getTechNodes();
- // Simulate: Economy-1 is now researched
- const researched = new Set(["Economy-1"]);
+ // Simulate: Land-1 is now researched
+ const researched = new Set(["Land-1"]);
const byId = new Map(nodes.map((n) => [n.id, n] as const));
const sameCat = (a: string, b: string) =>
(byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? "");
@@ -360,24 +359,24 @@ describe("Research Priority Allocation", () => {
return path;
};
- const priorityId = "Economy-4";
+ const priorityId = "Land-4";
const available = nodes.filter(
(n) => !researched.has(n.id) && isTechAvailable(n.id, researched),
);
- // Now Economy-2 should be available (since Economy-1 is researched)
- expect(available.some((n) => n.id === "Economy-2")).toBe(true);
+ // Now Land-2 should be available (since Land-1 is researched)
+ expect(available.some((n) => n.id === "Land-2")).toBe(true);
- // Path should now only include Economy-2 and Economy-3
+ // Path should now only include Land-2 and Land-3
const pathSet = buildMissingPrereqPath(priorityId);
- expect(pathSet.has("Economy-1")).toBe(false); // Already researched
- expect(pathSet.has("Economy-2")).toBe(true);
- expect(pathSet.has("Economy-3")).toBe(true);
+ expect(pathSet.has("Land-1")).toBe(false); // Already researched
+ expect(pathSet.has("Land-2")).toBe(true);
+ expect(pathSet.has("Land-3")).toBe(true);
- // Frontier should be Economy-2 (the only currently available prereq)
+ // Frontier should be Land-2 (the only currently available prereq)
const frontier = available.filter((n) => pathSet.has(n.id));
expect(frontier.length).toBe(1);
- expect(frontier[0].id).toBe("Economy-2");
+ expect(frontier[0].id).toBe("Land-2");
});
});
});
diff --git a/tests/core/execution/ResearchTreeSelectExecution.test.ts b/tests/core/execution/ResearchTreeSelectExecution.test.ts
index bd2fd698c..c518ed6ef 100644
--- a/tests/core/execution/ResearchTreeSelectExecution.test.ts
+++ b/tests/core/execution/ResearchTreeSelectExecution.test.ts
@@ -34,7 +34,7 @@ describe("ResearchTreeSelectExecution", () => {
// Simulate PlayerImpl side-effects when research completes
(mockPlayer.addResearchedTech as jest.Mock).mockImplementation(
(id: string) => {
- if (id === RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM) {
+ if (id === RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS) {
const alreadyHas = (mockPlayer.hasUpgrade as jest.Mock)(
UpgradeType.Roads,
);
@@ -60,10 +60,10 @@ describe("ResearchTreeSelectExecution", () => {
);
});
- it("grants Roads and reconnects when Economy-1 is selected", () => {
+ it("grants Roads and reconnects when Land-1 is selected", () => {
const exec = new ResearchTreeSelectExecution(
mockPlayer as any,
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
+ RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS,
);
exec.init(mockGame as any, 0);
exec.tick(0);
@@ -78,7 +78,7 @@ describe("ResearchTreeSelectExecution", () => {
(mockPlayer.hasUpgrade as jest.Mock).mockReturnValue(true);
const exec = new ResearchTreeSelectExecution(
mockPlayer as any,
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
+ RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS,
);
exec.init(mockGame as any, 0);
exec.tick(0);
diff --git a/tests/core/execution/ScorchedEarthExecution.test.ts b/tests/core/execution/ScorchedEarthExecution.test.ts
deleted file mode 100644
index e0c6f1944..000000000
--- a/tests/core/execution/ScorchedEarthExecution.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { ScorchedEarthExecution } from "../../../src/core/execution/ScorchedEarthExecution";
-import { Gold, Player, UpgradeType } from "../../../src/core/game/Game";
-import { GameImpl } from "../../../src/core/game/GameImpl";
-
-describe("ScorchedEarthExecution", () => {
- let mockPlayer: jest.Mocked;
- let mockGame: jest.Mocked;
-
- beforeEach(() => {
- mockPlayer = {
- gold: jest.fn(),
- hasUpgrade: jest.fn(),
- addUpgrade: jest.fn(),
- removeUpgrade: jest.fn(),
- removeGold: jest.fn(),
- hasResearchedTech: jest.fn(),
- removeResearchedTechsByCategory: jest.fn(),
- setRoadInvestmentRate: jest.fn(),
- } as unknown as jest.Mocked;
- (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(true);
-
- mockGame = {
- config: jest.fn().mockReturnValue({
- scorchedEarthActivationCost: jest.fn().mockReturnValue(3_000_000n),
- }),
- destroyPlayerRoads: jest.fn(),
- markPlayerNodesForReconnection: jest.fn(),
- } as unknown as jest.Mocked;
- });
-
- it("should do nothing if player already has ScorchedEarth", () => {
- mockPlayer.hasUpgrade.mockReturnValue(true);
-
- const exec = new ScorchedEarthExecution(mockPlayer);
- exec.init(mockGame, 0);
-
- expect(mockPlayer.removeGold).not.toHaveBeenCalled();
- expect(mockPlayer.addUpgrade).not.toHaveBeenCalled();
- expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled();
- });
-
- it("requires the Scorched Earth tech to be researched before activation", () => {
- (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(false);
- mockPlayer.hasUpgrade.mockReturnValue(false);
-
- const exec = new ScorchedEarthExecution(mockPlayer);
- exec.init(mockGame, 0);
-
- expect(mockPlayer.removeGold).not.toHaveBeenCalled();
- expect(mockPlayer.addUpgrade).not.toHaveBeenCalled();
- expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled();
- });
-
- it("should fail if player does not have enough gold", () => {
- mockPlayer.gold.mockReturnValue(2_999_999n as Gold);
- mockPlayer.hasUpgrade.mockReturnValue(false);
-
- const exec = new ScorchedEarthExecution(mockPlayer);
- exec.init(mockGame, 0);
-
- expect(mockPlayer.removeGold).not.toHaveBeenCalled();
- expect(mockPlayer.addUpgrade).not.toHaveBeenCalled();
- expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled();
- });
-
- it("should activate Scorched Earth with sufficient gold and tech", () => {
- mockPlayer.gold.mockReturnValue(3_000_000n as Gold);
- mockPlayer.hasUpgrade.mockReturnValue(false);
-
- const exec = new ScorchedEarthExecution(mockPlayer);
- exec.init(mockGame, 0);
-
- expect(mockPlayer.removeGold).toHaveBeenCalledWith(3_000_000n);
- expect(mockPlayer.addUpgrade).toHaveBeenCalledWith(
- UpgradeType.ScorchedEarth,
- );
- expect(mockGame.destroyPlayerRoads).toHaveBeenCalledWith(mockPlayer);
- expect(mockPlayer.setRoadInvestmentRate).toHaveBeenCalledWith(0);
- expect(mockGame.markPlayerNodesForReconnection).toHaveBeenCalledWith(
- mockPlayer,
- );
- });
-});
diff --git a/tests/core/execution/UpgradeStructureExecution.test.ts b/tests/core/execution/UpgradeStructureExecution.test.ts
index f06f3ac15..9fa25ec47 100644
--- a/tests/core/execution/UpgradeStructureExecution.test.ts
+++ b/tests/core/execution/UpgradeStructureExecution.test.ts
@@ -22,8 +22,16 @@ describe("UpgradeStructureExecution", () => {
isUnit: jest.fn().mockReturnValue(true),
type: jest.fn().mockReturnValue(unitType),
owner: jest.fn().mockReturnValue(mockPlayer),
+ level: jest.fn().mockReturnValue(1),
+ stackCount: jest.fn().mockReturnValue(1),
+ setStackCount: jest.fn(),
upgradeStructure: jest.fn(),
- } as unknown as jest.Mocked void }>;
+ } as unknown as jest.Mocked<
+ Unit & {
+ upgradeStructure: () => void;
+ setStackCount: (count: number) => void;
+ }
+ >;
return { mockPlayer, mockGame, mockUnit };
};
@@ -76,16 +84,23 @@ describe("UpgradeStructureExecution", () => {
expect(mockUnit.upgradeStructure).toHaveBeenCalled();
});
- it("does not charge or upgrade a Missile Silo at max level (3)", () => {
+ it("does not charge or upgrade a Missile Silo at max stack level", () => {
const { mockPlayer, mockGame } = makeMocks(UnitType.MissileSilo);
- // Create a unit mock that reports level 3
+ // Create a unit mock that reports level 25 (max stack count)
const mockUnit = {
isUnit: jest.fn().mockReturnValue(true),
type: jest.fn().mockReturnValue(UnitType.MissileSilo),
owner: jest.fn().mockReturnValue(mockPlayer),
- level: jest.fn().mockReturnValue(3),
+ level: jest.fn().mockReturnValue(25), // MAX_STACK_COUNT
+ stackCount: jest.fn().mockReturnValue(25),
+ setStackCount: jest.fn(),
upgradeStructure: jest.fn(),
- } as unknown as jest.Mocked void }>;
+ } as unknown as jest.Mocked<
+ Unit & {
+ upgradeStructure: () => void;
+ setStackCount: (count: number) => void;
+ }
+ >;
const exec = new UpgradeStructureExecution(mockPlayer, mockUnit);
exec.init(mockGame, 0);
diff --git a/tests/core/game/CargoManager.test.ts b/tests/core/game/CargoManager.test.ts
index 30341b870..0609b1d8f 100644
--- a/tests/core/game/CargoManager.test.ts
+++ b/tests/core/game/CargoManager.test.ts
@@ -46,7 +46,7 @@ describe("CargoManager", () => {
}
// Grant Roads via research tech so RoadManager reconnects nodes immediately
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS);
// Let roads form
for (let i = 0; i < 200; i++) {
diff --git a/tests/core/game/PlayerImpl.tech.test.ts b/tests/core/game/PlayerImpl.tech.test.ts
index 6d17a27de..3d649a5f5 100644
--- a/tests/core/game/PlayerImpl.tech.test.ts
+++ b/tests/core/game/PlayerImpl.tech.test.ts
@@ -5,48 +5,38 @@ import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects";
import { playerInfo, setup } from "../../util/Setup";
describe("PlayerImpl.removeResearchedTechsByCategory", () => {
- it("revokes economy techs and clears associated progress", async () => {
+ it("revokes land techs and clears associated progress", async () => {
const game = (await setup("ocean_and_land")) as GameImpl;
const info = playerInfo("Tester", PlayerType.Human);
game.addPlayer(info);
const player = game.player(info.id) as PlayerImpl;
- player.addResearchedTech(
- RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
- player.addResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK);
- player.addResearchBeakers("Economy-4", 500, 1_000);
- player.setResearchPriority("Economy-4");
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE);
+ player.addResearchBeakers("Land-4", 500, 1_000);
+ player.setResearchPriority("Land-4");
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true);
- expect(player.researchBeakers("Economy-4")).toBe(500);
+ expect(player.researchBeakers("Land-4")).toBe(500);
- player.removeResearchedTechsByCategory("Economy");
+ player.removeResearchedTechsByCategory("Land");
expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION,
- ),
- ).toBe(true);
- expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
- ),
+ player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS),
).toBe(false);
expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- ),
+ player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY),
).toBe(false);
+ expect(player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS)).toBe(
+ false,
+ );
// Upgrades are NOT removed by removeResearchedTechsByCategory - only techs and progress
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true);
- expect(player.researchBeakers("Economy-4")).toBe(0);
+ expect(player.researchBeakers("Land-4")).toBe(0);
expect(player.researchPriority()).toBeNull();
});
});
diff --git a/tests/core/tech/EconomyTechEffects.test.ts b/tests/core/tech/EconomyTechEffects.test.ts
index d3cb3980f..3fbd2138c 100644
--- a/tests/core/tech/EconomyTechEffects.test.ts
+++ b/tests/core/tech/EconomyTechEffects.test.ts
@@ -1,75 +1,27 @@
import { PlayerType, UpgradeType } from "../../../src/core/game/Game";
import { GameImpl } from "../../../src/core/game/GameImpl";
import { PlayerImpl } from "../../../src/core/game/PlayerImpl";
-import { POLICY_DIRECTIVE_IDS } from "../../../src/core/tech/PolicyDirectives";
import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects";
import { playerInfo, setup } from "../../util/Setup";
-describe("Economy tech integrations", () => {
- it("enables Roads after researching National Reconstruction Program", async () => {
+describe("Land infrastructure tech integrations", () => {
+ it("enables Roads after researching Roads & Hospitals", async () => {
const info = playerInfo("builder", PlayerType.Human);
const game = (await setup("ocean_and_land", {}, [info])) as GameImpl;
const player = game.player(info.id) as PlayerImpl;
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(false);
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS);
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
});
- it("enables InternationalTrade after choosing Open Trade policy", async () => {
- const info = playerInfo("trader", PlayerType.Human);
- const game = (await setup("ocean_and_land", {}, [info])) as GameImpl;
- const player = game.player(info.id) as PlayerImpl;
-
- expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false);
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
- player.addResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK);
- // Tech alone doesn't grant the upgrade anymore
- expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false);
-
- // Choosing Open Trade policy grants the upgrade
- player.setPolicyChoice(
- POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK,
- "open_trade",
- );
- player.addUpgrade(UpgradeType.InternationalTrade); // Simulating what execution does
- expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(true);
- });
-
- it("Autarky policy does not grant InternationalTrade", async () => {
- const info = playerInfo("autarky", PlayerType.Human);
- const game = (await setup("ocean_and_land", {}, [info])) as GameImpl;
- const player = game.player(info.id) as PlayerImpl;
-
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
- player.addResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK);
-
- // Choosing Autarky policy does NOT grant the upgrade
- player.setPolicyChoice(
- POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK,
- "autarky",
- );
- expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false);
- });
-
- // TEMPORARILY DISABLED: Structure insurance tests
- // it("refunds 33% of a structure's cost on destruction with Infrastructure Recovery Fund", ...)
- // it("refunds insured structures when conquered", ...)
-
- it("enables HospitalResearch after researching National Reconstruction Program", async () => {
+ it("enables HospitalResearch after researching Modern Air Defense (Land-3)", async () => {
const info = playerInfo("health", PlayerType.Human);
const game = (await setup("ocean_and_land", {}, [info])) as GameImpl;
const player = game.player(info.id) as PlayerImpl;
- // Hospitals are unlocked at Level 1 now (National Reconstruction Program)
expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(false);
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS);
expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true);
});
@@ -78,15 +30,13 @@ describe("Economy tech integrations", () => {
const game = (await setup("ocean_and_land", {}, [info])) as GameImpl;
const player = game.player(info.id) as PlayerImpl;
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
+ player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS);
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
- player.removeResearchedTechsByCategory("Economy");
+ player.removeResearchedTechsByCategory("Land");
// Techs are removed but upgrades remain
expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
- ),
+ player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS),
).toBe(false);
expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
});
diff --git a/tests/core/tech/TechEffects.test.ts b/tests/core/tech/TechEffects.test.ts
index 325b05b4c..920ca8876 100644
--- a/tests/core/tech/TechEffects.test.ts
+++ b/tests/core/tech/TechEffects.test.ts
@@ -5,8 +5,8 @@ import {
} from "../../../src/core/tech/TechEffects";
describe("TechEffects", () => {
- it("removes Scorched Earth upgrade when National Reconstruction Program completes", () => {
- const owned = new Set([UpgradeType.ScorchedEarth]);
+ it("grants Roads when Land-1 (Road Network) completes", () => {
+ const owned = new Set();
const player = {
hasUpgrade: jest.fn((upgrade: UpgradeType) => owned.has(upgrade)),
addUpgrade: jest.fn((upgrade: UpgradeType) => owned.add(upgrade)),
@@ -19,14 +19,10 @@ describe("TechEffects", () => {
applyTechCompletionEffects(
player,
game,
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
+ RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS,
);
expect(player.addUpgrade).toHaveBeenCalledWith(UpgradeType.Roads);
expect(game.markPlayerNodesForReconnection).toHaveBeenCalledWith(player);
- expect(player.removeUpgrade).toHaveBeenCalledWith(
- UpgradeType.ScorchedEarth,
- );
- expect(owned.has(UpgradeType.ScorchedEarth)).toBe(false);
});
});
diff --git a/tests/integrations/ScorchedEarth.test.ts b/tests/integrations/ScorchedEarth.test.ts
deleted file mode 100644
index 2665f5f35..000000000
--- a/tests/integrations/ScorchedEarth.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { ScorchedEarthExecution } from "../../src/core/execution/ScorchedEarthExecution";
-import { PlayerType, UnitType, UpgradeType } from "../../src/core/game/Game";
-import { GameImpl } from "../../src/core/game/GameImpl";
-import { PlayerImpl } from "../../src/core/game/PlayerImpl";
-import { RESEARCH_TECH_IDS } from "../../src/core/tech/TechEffects";
-import { playerInfo, setup } from "../util/Setup";
-
-describe("Scorched Earth Full Cycle Integration Test", () => {
- it("should allow a player to build, destroy, and rebuild their road network", async () => {
- // Given: A game with a player having several cities and enough gold
- const game = (await setup("ocean_and_land", {
- instantBuild: true,
- })) as GameImpl;
- const pInfo = playerInfo("Player A", PlayerType.Human);
- game.addPlayer(pInfo);
- const player = game.player(pInfo.id);
- player.addGold(10_000_000n);
- // Allocate income to road building so construction progresses in tests
- player.setRoadInvestmentRate(1);
- (player as any).addWorkers(10000000);
- const city1 = player.buildUnit(UnitType.City, game.ref(0, 10), {});
- const city2 = player.buildUnit(UnitType.City, game.ref(0, 12), {});
-
- // Conquer a path between the cities
- for (let i = 10; i <= 12; i++) {
- const tile = game.ref(0, i);
- if (game.owner(tile) !== player) {
- game.conquer(player as PlayerImpl, tile);
- }
- }
-
- // Research core economy techs to unlock and test revocation behavior
- player.addResearchedTech(
- RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
- player.addResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- );
- player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK);
-
- // Allow the automatic road upgrade to build out the network
- for (let i = 0; i < 200; i++) {
- game.executeNextTick();
- }
- expect(game.roads().length).toBeGreaterThan(0);
- expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
- expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true);
-
- // Step 2: Research and activate Scorched Earth, verify network destruction
- player.addResearchedTech(RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE);
- game.addExecution(new ScorchedEarthExecution(player));
- game.executeNextTick();
- expect(game.roads().length).toBe(0);
- // Scorched Earth only destroys roads, keeps upgrades and techs
- expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true);
- expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true);
- expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(true);
- expect(player.roadInvestmentRate()).toBe(0);
- expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM,
- ),
- ).toBe(true);
- expect(
- player.hasResearchedTech(
- RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS,
- ),
- ).toBe(true);
-
- // Step 3: Re-unlock roads and verify Scorched Earth deactivates
- player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM);
- expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false);
- player.setRoadInvestmentRate(1);
- for (let i = 0; i < 200; i++) {
- game.executeNextTick();
- }
- expect(game.roads().length).toBeGreaterThan(0);
- expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false);
- });
-});