- ${this.categories.map(
- (cat) =>
- html`
`,
- )}
+
+
+
+ ${tabs.map((cat) => {
+ const isAllTab = cat === "Overview";
+ const isActive = isAllTab ? isAllView : cat === activeCategory;
+ return html``;
+ })}
+
+
+ ${this.renderResearchSlider()}
+ ${this.renderRoadSlider(me ?? null)}
+
- ${levels.map(
- (lvl) => html`
-
-
-
- ${this.categories.map((cat) => {
- const techs = this.techs.filter(
- (t) => t.level === lvl && t.category === cat,
- );
- return html`
-
-
${cat}
-
- ${techs.map((tech) => {
- const available = this.isAvailable(
- tech.id,
- researched,
- );
- const isResearched = researched.has(tech.id);
- const clickable = !isResearched; // allow prioritizing locked techs
- // Compute highlight membership for this node
- const byId = new Map(
- this.techs.map((n) => [n.id, n] as const),
- );
- const sameCat = (a: string, b: string) =>
- (byId.get(a)?.category ?? "") ===
- (byId.get(b)?.category ?? "");
- const buildMissingPrereqPath = (
- targetId: string,
- ): Set
=> {
- const path = new Set();
- const seen = new Set();
- const dfs = (tid: string) => {
- if (seen.has(tid)) return;
- seen.add(tid);
- const node = byId.get(tid);
- if (!node) return;
- const reqAll = (
- node.requiresAllOf ?? []
- ).filter((p) => sameCat(p, tid));
- const reqOne = (
- node.requiresOneOf ?? []
- ).filter((p) => sameCat(p, tid));
- for (const r of reqAll) {
- if (!researched.has(r)) {
- path.add(r);
- dfs(r);
- }
- }
- if (
- reqOne.length > 0 &&
- !reqOne.some((p) => researched.has(p))
- ) {
- const sorted = [...reqOne].sort(
- (a, b) =>
- (byId.get(a)?.level ?? 0) -
- (byId.get(b)?.level ?? 0),
+
+
+ ${isAllView
+ ? this.renderAllView(
+ levels,
+ researched,
+ categoryColors,
+ percentByTechId,
+ )
+ : activeCategory
+ ? html`
+ ${levels.map((lvl) => {
+ const techsForLevel = this.techs.filter(
+ (t) =>
+ t.level === lvl && t.category === activeCategory,
+ );
+ return html`
+
Tech Level ${lvl}
+
+ ${techsForLevel.length
+ ? techsForLevel.map((tech) => {
+ const available = this.isAvailable(
+ tech.id,
+ researched,
+ );
+ const isResearched = researched.has(tech.id);
+ const clickable = !isResearched;
+ const inHighlight = highlightTrail.has(
+ tech.id,
);
- const choice = sorted[0];
- if (choice && !researched.has(choice)) {
- path.add(choice);
- dfs(choice);
- }
- }
- };
- if (priority) dfs(priority);
- return path;
- };
- const highlightSet = (() => {
- const s = new Set
();
- if (priority) {
- s.add(priority);
- const missing =
- buildMissingPrereqPath(priority);
- for (const id of missing) s.add(id);
- }
- return s;
- })();
- const inHighlight = highlightSet.has(tech.id);
-
- const classes = [
- "tech",
- available ? "" : "locked",
- isResearched ? "researched" : "",
- inHighlight ? "priority" : "",
- ]
- .filter(Boolean)
- .join(" ");
- const action = this.renderScorchedEarthAction(
- tech,
- me ?? null,
- isResearched,
- );
- return html`
-
-
- `;
- })}
-
-
- `,
- )}
-
+ : ""}
+ ${(() => {
+ const meLocal =
+ this.game?.myPlayer?.();
+ const b =
+ meLocal?.researchBeakers?.(
+ tech.id,
+ ) ?? 0;
+ const pct = Math.min(
+ 100,
+ Math.floor(
+ (b / (tech.cost || 1)) * 100,
+ ),
+ );
+ return html`
+
+
Cost:
+ ${tech.cost.toLocaleString()}
+

+
+ ${isResearched
+ ? html`
+ Status: Completed
+
`
+ : html`
+ Progress:
+ ${b.toLocaleString()} /
+ ${tech.cost.toLocaleString()}
+ (${pct}%)
+
`}
+
`;
+ })()}
+
+
+ ${tech.name}
+
+
+
${tech.cost.toLocaleString()}
+

+
+
+ ${tech.description &&
+ tech.description.trim().length
+ ? tech.description
+ : html` `}
+
+ ${!isResearched && me
+ ? (() => {
+ const b =
+ me.researchBeakers?.(tech.id) ??
+ 0;
+ const pct = Math.min(
+ 100,
+ Math.floor(
+ (b / (tech.cost || 1)) * 100,
+ ),
+ );
+ return b > 0
+ ? html`
`
+ : "";
+ })()
+ : ""}
+
+ ${tech.requiresAllOf?.length
+ ? html`Requires:
+ ${tech.requiresAllOf.length}`
+ : ""}
+ ${tech.requiresOneOf?.length
+ ? html`One of:
+ ${tech.requiresOneOf.length}`
+ : ""}
+ ${priority === tech.id && !isResearched
+ ? html`Priority`
+ : ""}
+
+
+ ${action}
+
`;
+ })
+ : html`
+ No techs at this level
+
`}
+
+ `;
+ })}
+
`
+ : html`
+ No research categories found.
+
`}
+ ${!isAllView
+ ? html`
`
+ : ""}
+
+
`;
diff --git a/src/client/Transport.ts b/src/client/Transport.ts
index b1a3f5506..6b10e212d 100644
--- a/src/client/Transport.ts
+++ b/src/client/Transport.ts
@@ -93,6 +93,14 @@ export class SendBoatAttackIntentEvent implements GameEvent {
) {}
}
+export class SendParatrooperAttackIntentEvent implements GameEvent {
+ constructor(
+ public readonly targetID: PlayerID | null,
+ public readonly dst: TileRef,
+ public readonly troops: number,
+ ) {}
+}
+
export class BuildUnitIntentEvent implements GameEvent {
constructor(
public readonly unit: UnitType,
@@ -156,6 +164,10 @@ export class CancelBoatIntentEvent implements GameEvent {
constructor(public readonly unitID: number) {}
}
+export class CancelParatrooperIntentEvent implements GameEvent {
+ constructor(public readonly unitID: number) {}
+}
+
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -191,6 +203,13 @@ export class MoveWarshipIntentEvent implements GameEvent {
) {}
}
+export class MoveSubmarineIntentEvent implements GameEvent {
+ constructor(
+ public readonly unitId: number,
+ public readonly tile: TileRef,
+ ) {}
+}
+
export class MoveFighterJetIntentEvent implements GameEvent {
constructor(
public readonly unitId: number,
@@ -293,6 +312,9 @@ export class Transport {
this.eventBus.on(SendPurchaseUpgradeIntentEvent, (e) =>
this.onSendPurchaseUpgradeIntent(e),
);
+ this.eventBus.on(SendParatrooperAttackIntentEvent, (e) =>
+ this.onSendParatrooperAttackIntent(e),
+ );
this.eventBus.on(SendResearchTreeSelectIntentEvent, (e) =>
this.onSendResearchTreeSelectIntent(e),
@@ -309,10 +331,16 @@ export class Transport {
this.eventBus.on(CancelBoatIntentEvent, (e) =>
this.onCancelBoatIntentEvent(e),
);
+ this.eventBus.on(CancelParatrooperIntentEvent, (e) =>
+ this.onCancelParatrooperIntentEvent(e),
+ );
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
this.onMoveWarshipEvent(e);
});
+ this.eventBus.on(MoveSubmarineIntentEvent, (e) => {
+ this.onMoveSubmarineEvent(e);
+ });
this.eventBus.on(MoveFighterJetIntentEvent, (e) => {
this.onMoveFighterJetEvent(e);
});
@@ -730,6 +758,14 @@ export class Transport {
});
}
+ private onCancelParatrooperIntentEvent(event: CancelParatrooperIntentEvent) {
+ this.sendIntent({
+ type: "cancel_paratrooper",
+ clientID: this.lobbyConfig.clientID,
+ unitID: event.unitID,
+ });
+ }
+
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
this.sendIntent({
type: "move_warship",
@@ -739,6 +775,15 @@ export class Transport {
});
}
+ private onMoveSubmarineEvent(event: MoveSubmarineIntentEvent) {
+ this.sendIntent({
+ type: "move_submarine",
+ clientID: this.lobbyConfig.clientID,
+ unitId: event.unitId,
+ tile: event.tile,
+ });
+ }
+
private onMoveFighterJetEvent(event: MoveFighterJetIntentEvent) {
this.sendIntent({
type: "move_fighter_jet",
@@ -771,6 +816,18 @@ export class Transport {
});
}
+ private onSendParatrooperAttackIntent(
+ event: SendParatrooperAttackIntentEvent,
+ ) {
+ this.sendIntent({
+ type: "paratrooper_attack",
+ clientID: this.lobbyConfig.clientID,
+ targetID: event.targetID ?? null,
+ troops: event.troops,
+ dst: event.dst,
+ });
+ }
+
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
diff --git a/src/client/Utils.ts b/src/client/Utils.ts
index 116a598b8..0a62ce828 100644
--- a/src/client/Utils.ts
+++ b/src/client/Utils.ts
@@ -181,6 +181,7 @@ export function getMessageTypeClasses(type: MessageType): string {
case MessageType.SAM_MISS:
case MessageType.ALLIANCE_EXPIRED:
case MessageType.NAVAL_INVASION_INBOUND:
+ case MessageType.PARATROOPER_INBOUND:
case MessageType.WARN:
case MessageType.PEACE_TIMER_BLOCKED:
return severityColors["warn"];
diff --git a/src/client/events/InvestmentEvents.ts b/src/client/events/InvestmentEvents.ts
new file mode 100644
index 000000000..f243b4c9c
--- /dev/null
+++ b/src/client/events/InvestmentEvents.ts
@@ -0,0 +1,26 @@
+export const INVESTMENT_SYNC_EVENT = "investment-sync";
+export const INVESTMENT_SYNC_REQUEST_EVENT = "investment-sync-request";
+export const INVESTMENT_REQUEST_EVENT = "investment-request-change";
+
+export type InvestmentSlider = "prod" | "road" | "research";
+
+export type InvestmentSyncDetail = {
+ prod: number;
+ road: number;
+ research: number;
+ lockProd: boolean;
+ lockRoad: boolean;
+ lockResearch: boolean;
+ roadEnabled: boolean;
+};
+
+export type InvestmentRequestDetail =
+ | {
+ type: "set";
+ slider: InvestmentSlider;
+ value: number;
+ }
+ | {
+ type: "toggle-lock";
+ slider: InvestmentSlider;
+ };
diff --git a/src/client/events/UnitCooldownEndedEvent.ts b/src/client/events/UnitCooldownEndedEvent.ts
new file mode 100644
index 000000000..75b100298
--- /dev/null
+++ b/src/client/events/UnitCooldownEndedEvent.ts
@@ -0,0 +1,5 @@
+import { UnitView } from "../../core/game/GameView";
+
+export class UnitCooldownEndedEvent {
+ constructor(public readonly unit: UnitView) {}
+}
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 0a9fedbe5..f9d941281 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -24,10 +24,12 @@ import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RadialMenu } from "./layers/RadialMenu";
import { ReplayPanel } from "./layers/ReplayPanel";
+import { ResearchToggleButton } from "./layers/ResearchToggleButton";
import { RoadLayer } from "./layers/RoadLayer";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
+import { TechUnlockNotification } from "./layers/TechUnlockNotification";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
@@ -116,6 +118,24 @@ export function createRenderer(
controlPanel2.uiState = uiState;
controlPanel2.game = game;
+ const researchToggleButton = document.querySelector(
+ "research-toggle-button",
+ ) as ResearchToggleButton;
+ if (!(researchToggleButton instanceof ResearchToggleButton)) {
+ console.error("ResearchToggleButton element not found in the DOM");
+ }
+ researchToggleButton.eventBus = eventBus;
+ researchToggleButton.game = game;
+
+ const techUnlockNotification = document.querySelector(
+ "tech-unlock-notification",
+ ) as TechUnlockNotification;
+ if (!(techUnlockNotification instanceof TechUnlockNotification)) {
+ console.error("TechUnlockNotification element not found in the DOM");
+ }
+ techUnlockNotification.eventBus = eventBus;
+ techUnlockNotification.game = game;
+
const eventsDisplay = document.querySelector(
"events-display",
) as EventsDisplay;
@@ -237,6 +257,8 @@ export function createRenderer(
gameLeftSidebar,
controlPanel,
controlPanel2,
+ researchToggleButton,
+ techUnlockNotification,
playerInfo,
winModel,
optionsMenu,
diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts
index 67f21916c..8dc036b41 100644
--- a/src/client/graphics/NameBoxCalculator.ts
+++ b/src/client/graphics/NameBoxCalculator.ts
@@ -14,9 +14,7 @@ export interface Rectangle {
}
export function placeName(game: Game, player: Player): NameViewData {
- const boundingBox =
- player.largestClusterBoundingBox ??
- calculateBoundingBox(game, player.borderTiles());
+ const boundingBox = calculateBoundingBox(game, player.borderTiles());
let scalingFactor = 1;
const width = boundingBox.max.x - boundingBox.min.x;
diff --git a/src/client/graphics/SpriteLoader.ts b/src/client/graphics/SpriteLoader.ts
index 2062a0bb5..5c26ff80c 100644
--- a/src/client/graphics/SpriteLoader.ts
+++ b/src/client/graphics/SpriteLoader.ts
@@ -5,7 +5,9 @@ import cargoPlaneSprite from "../../../resources/sprites/cargoplane.png";
import fighterJetSprite from "../../../resources/sprites/fighterJet.png";
import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
import mirvSprite from "../../../resources/sprites/mirv2.png";
+import ParatrooperSprite from "../../../resources/sprites/paratrooper.png";
import samMissileSprite from "../../../resources/sprites/samMissile.png";
+import submarineSprite from "../../../resources/sprites/submarine.png";
import tradeShipSprite from "../../../resources/sprites/tradeship.png";
import transportShipSprite from "../../../resources/sprites/transportship.png";
import warshipSprite from "../../../resources/sprites/warship.png";
@@ -16,12 +18,14 @@ import { UnitView } from "../../core/game/GameView";
const SPRITE_CONFIG: Partial
> = {
[UnitType.TransportShip]: transportShipSprite,
[UnitType.Warship]: warshipSprite,
+ [UnitType.Submarine]: submarineSprite,
[UnitType.SAMMissile]: samMissileSprite,
[UnitType.AtomBomb]: atomBombSprite,
[UnitType.HydrogenBomb]: hydrogenBombSprite,
[UnitType.TradeShip]: tradeShipSprite,
[UnitType.MIRV]: mirvSprite,
[UnitType.CargoPlane]: cargoPlaneSprite,
+ [UnitType.Paratrooper]: ParatrooperSprite,
[UnitType.Bomber]: bomberSprite,
[UnitType.FighterJet]: fighterJetSprite,
};
diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts
index fa179c51a..77c6af9ea 100644
--- a/src/client/graphics/layers/BuildMenu.ts
+++ b/src/client/graphics/layers/BuildMenu.ts
@@ -14,9 +14,10 @@ import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samlauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg";
+import submarineIcon from "../../../../resources/images/submarine.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
-import { Gold, UnitType } from "../../../core/game/Game";
+import { Gold, UnitType, UpgradeType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { CloseViewEvent } from "../../InputHandler";
import { displayKey, renderNumber } from "../../Utils";
@@ -64,7 +65,13 @@ const buildTable: BuildItemDisplay[][] = [
unitType: UnitType.Warship,
icon: warshipIcon,
description: "build_menu.desc.warship",
- key: "unit_type.warship",
+ countable: true,
+ },
+ {
+ unitType: UnitType.Submarine,
+ icon: submarineIcon,
+ description: "build_menu.desc.submarine",
+ key: "unit_type.submarine",
countable: true,
},
{
@@ -213,11 +220,23 @@ export class BuildMenu extends LitElement {
}
if (this.game?.config()) {
- this.filteredBuildTable = current.map((row) =>
+ current = current.map((row) =>
row.filter(
(item) => !this.game!.config().isUnitDisabled(item.unitType),
),
);
+ }
+
+ if (this.game?.myPlayer()) {
+ const player = this.game.myPlayer()!;
+ this.filteredBuildTable = current.map((row) =>
+ row.filter((item) => {
+ if (item.unitType === UnitType.Submarine) {
+ return player.hasUpgrade(UpgradeType.SubmarineResearch);
+ }
+ return true;
+ }),
+ );
} else {
this.filteredBuildTable = current;
}
@@ -393,11 +412,17 @@ export class BuildMenu extends LitElement {
}
switch (item.unitType) {
+ case UnitType.Submarine:
case UnitType.Warship:
return player.unitsOwned(UnitType.Port) > 0;
case UnitType.FighterJet:
return player.unitsOwned(UnitType.Airfield) > 0;
case UnitType.AtomBomb:
+ return (
+ player.unitsOwned(UnitType.MissileSilo) > 0 ||
+ (player.hasUpgrade(UpgradeType.NuclearSubmarineResearch) &&
+ player.unitsOwned(UnitType.Submarine) > 0)
+ );
case UnitType.HydrogenBomb:
case UnitType.MIRV:
return player.unitsOwned(UnitType.MissileSilo) > 0;
diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts
index 2411da08b..21c04ebfa 100644
--- a/src/client/graphics/layers/ControlPanel2.ts
+++ b/src/client/graphics/layers/ControlPanel2.ts
@@ -9,11 +9,16 @@ import {
UpgradeType,
} from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
-import { getTechNodes } from "../../../core/tech/ResearchTree";
import { getTechMeta, RESEARCH_TECH_IDS } from "../../../core/tech/TechEffects";
+import {
+ INVESTMENT_REQUEST_EVENT,
+ INVESTMENT_SYNC_EVENT,
+ INVESTMENT_SYNC_REQUEST_EVENT,
+ type InvestmentRequestDetail,
+ type InvestmentSyncDetail,
+} from "../../events/InvestmentEvents";
import { PlayerListChangedEvent } from "../../events/PlayerListChangedEvent";
import { AttackRatioEvent } from "../../InputHandler";
-import "../../ResearchTreeModal";
import {
SendBomberIntentEvent,
SendSetAutoBombingEvent,
@@ -38,9 +43,6 @@ export class ControlPanel2 extends LitElement implements Layer {
@state()
private targetTroopRatio = 0.6;
- @state()
- private currentTroopRatio = 0.6;
-
@state()
private investmentRate: number = 0; // default to 0%
@@ -61,30 +63,12 @@ export class ControlPanel2 extends LitElement implements Layer {
@state()
private _population: number;
- @state()
- private _maxPopulation: number;
-
- @state()
- private popRate: number;
-
- @state()
- private _hospitalReturns: number = 0;
-
- @state()
- private _troops: number;
-
- @state()
- private _workers: number;
-
@state()
private _isVisible = false;
@state()
private isOpen = false;
- @state()
- private _manpower: number = 0;
-
@state()
private _gold: Gold;
@@ -94,21 +78,10 @@ export class ControlPanel2 extends LitElement implements Layer {
@state()
private _productivityGrowth: number;
- @state()
- private _goldPerSecond: Gold;
-
- private _lastPopulationIncreaseRate: number;
-
- private _popRateIsIncreasing: boolean = true;
-
private init_: boolean = false;
@state()
- private activeTab: "Build" | "Attack" | "Economy" | "Research" | "Bombers" =
- "Build";
-
- @state()
- private activeResearchTab: "Land" | "Water" | "Air" | "Economy" = "Land";
+ private activeTab: "Build" | "Attack" | "Economy" | "Bombers" = "Build";
@state()
private _lastAirfieldCount: number = 0;
@@ -171,6 +144,7 @@ export class ControlPanel2 extends LitElement implements Layer {
UnitType.HydrogenBomb,
UnitType.FighterJet,
UnitType.Warship,
+ UnitType.Submarine,
];
private readonly StructureTypes: UnitType[] = [
@@ -184,6 +158,55 @@ export class ControlPanel2 extends LitElement implements Layer {
UnitType.City,
];
+ private readonly investmentRequestHandler = (event: Event) => {
+ const { detail } = event as CustomEvent;
+ if (!detail) return;
+ if (detail.type === "set") {
+ const value = Math.max(0, Math.min(1, detail.value ?? 0));
+ if (detail.slider === "road" && !this.playerHasRoadsUpgrade()) return;
+ this.applyInvestmentChange(detail.slider, value);
+ } else if (detail.type === "toggle-lock") {
+ if (detail.slider === "prod") {
+ this._lockProd = !this._lockProd;
+ } else if (detail.slider === "road") {
+ if (!this.playerHasRoadsUpgrade()) return;
+ this._lockRoad = !this._lockRoad;
+ } else if (detail.slider === "research") {
+ this._lockResearch = !this._lockResearch;
+ }
+ this.emitInvestmentSync();
+ this.requestUpdate();
+ }
+ };
+
+ private readonly investmentSyncRequestHandler = () => {
+ this.emitInvestmentSync();
+ };
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener(
+ INVESTMENT_REQUEST_EVENT,
+ this.investmentRequestHandler as EventListener,
+ );
+ window.addEventListener(
+ INVESTMENT_SYNC_REQUEST_EVENT,
+ this.investmentSyncRequestHandler,
+ );
+ }
+
+ disconnectedCallback(): void {
+ window.removeEventListener(
+ INVESTMENT_REQUEST_EVENT,
+ this.investmentRequestHandler as EventListener,
+ );
+ window.removeEventListener(
+ INVESTMENT_SYNC_REQUEST_EVENT,
+ this.investmentSyncRequestHandler,
+ );
+ super.disconnectedCallback();
+ }
+
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.3",
@@ -211,7 +234,6 @@ export class ControlPanel2 extends LitElement implements Layer {
this.uiState.investmentRate = this.investmentRate;
this.init_ = true;
this.uiState.attackRatio = this.attackRatio;
- this.currentTroopRatio = this.targetTroopRatio;
this.eventBus.on(AttackRatioEvent, (event: AttackRatioEvent) => {
let newAttackRatio =
@@ -289,39 +311,10 @@ export class ControlPanel2 extends LitElement implements Layer {
return;
}
- const popIncreaseRate = player.population() - this._population;
- if (this.game.ticks() % 5 === 0) {
- this._popRateIsIncreasing =
- popIncreaseRate >= this._lastPopulationIncreaseRate;
- this._lastPopulationIncreaseRate = popIncreaseRate;
- }
-
this._population = player.population();
- this._maxPopulation = this.game.config().maxPopulation(player);
- this._hospitalReturns = player.hospitalReturns() * 10;
this._gold = player.gold();
this._productivity = player.productivity();
this._productivityGrowth = player.productivityGrowthPerMinute();
- this._troops = player.troops();
- this._workers = player.workers();
- this.popRate = this.game.config().populationIncreaseRate(player) * 10;
- // Compute net gold/sec consistent with server logic
- {
- const grossPerTick = this.game.config().grossGoldAdditionRate(player);
- const prod = player.investmentRate?.() ?? 0;
- const hasRoads = player.hasUpgrade(UpgradeType.Roads);
- const effectiveRoad = hasRoads ? this._roadInvestmentRate : 0;
- let totalInvest = prod + effectiveRoad + this._researchInvestmentRate;
- const hasTreasury = (this._gold ?? 0n) > 0n;
- const maxTotal = hasTreasury ? 1.1 : 1.0;
- if (!Number.isFinite(totalInvest)) totalInvest = 0;
- if (totalInvest > maxTotal) totalInvest = maxTotal;
- let netPerTickDouble = grossPerTick * (1 - totalInvest);
- if (!Number.isFinite(netPerTickDouble)) netPerTickDouble = 0;
- const netPerTick = BigInt(Math.floor(netPerTickDouble));
- this._goldPerSecond = netPerTick * 10n;
- }
-
this.investmentRate = player.investmentRate();
// If Roads are not researched, force road investment to 0 and persist
const hasRoadsUpgrade = player.hasUpgrade(UpgradeType.Roads);
@@ -421,7 +414,6 @@ export class ControlPanel2 extends LitElement implements Layer {
}
}
}
- this.currentTroopRatio = player.troops() / player.population();
// Track relevant state for dynamic updates
const currentAirfieldCount = player.units(UnitType.Airfield).length;
@@ -568,6 +560,79 @@ export class ControlPanel2 extends LitElement implements Layer {
return { prod: currentProd, road: currentRoad, research: currentResearch };
}
+ private applyInvestmentChange(
+ changed: "prod" | "road" | "research",
+ proposed: number,
+ ) {
+ const { prod, road, research } = this._applyTripleInvestmentConstraint(
+ changed,
+ proposed,
+ );
+ this.commitInvestmentRates(prod, road, research);
+ }
+
+ private commitInvestmentRates(
+ prod: number,
+ road: number,
+ research: number,
+ ): boolean {
+ const prodChanged = Math.abs(prod - this.investmentRate) > 1e-6;
+ const roadChanged = Math.abs(road - this._roadInvestmentRate) > 1e-6;
+ const researchChanged =
+ Math.abs(research - this._researchInvestmentRate) > 1e-6;
+
+ if (!prodChanged && !roadChanged && !researchChanged) {
+ return false;
+ }
+
+ this.investmentRate = prod;
+ this._roadInvestmentRate = road;
+ this._researchInvestmentRate = research;
+
+ if (prodChanged) {
+ this.onInvestmentRateChange(this.investmentRate);
+ this.uiState.investmentRate = this.investmentRate;
+ localStorage.setItem(
+ "settings.investmentRate",
+ this.investmentRate.toString(),
+ );
+ }
+ if (roadChanged) {
+ this.onRoadInvestmentChange(this._roadInvestmentRate);
+ localStorage.setItem(
+ "settings.roadInvestmentRate",
+ this._roadInvestmentRate.toString(),
+ );
+ }
+ if (researchChanged) {
+ this.onResearchInvestmentChange(this._researchInvestmentRate);
+ localStorage.setItem(
+ "settings.researchInvestmentRate",
+ this._researchInvestmentRate.toString(),
+ );
+ }
+
+ this.emitInvestmentSync();
+ return true;
+ }
+
+ private emitInvestmentSync() {
+ const detail: InvestmentSyncDetail = {
+ prod: this.investmentRate,
+ road: this._roadInvestmentRate,
+ research: this._researchInvestmentRate,
+ lockProd: this._lockProd,
+ lockRoad: this._lockRoad,
+ lockResearch: this._lockResearch,
+ roadEnabled: this.playerHasRoadsUpgrade(),
+ };
+ window.dispatchEvent(
+ new CustomEvent(INVESTMENT_SYNC_EVENT, {
+ detail,
+ }),
+ );
+ }
+
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
@@ -581,17 +646,13 @@ export class ControlPanel2 extends LitElement implements Layer {
this.requestUpdate();
}
- targetTroops(): number {
- return this._manpower * this.targetTroopRatio;
- }
-
onTroopChange(newRatio: number) {
this.eventBus.emit(new SendSetTargetTroopRatioEvent(newRatio));
}
- delta(): number {
- const d = this._population - this.targetTroops();
- return d;
+ private playerHasRoadsUpgrade(): boolean {
+ const player = this.game?.myPlayer?.();
+ return player?.hasUpgrade?.(UpgradeType.Roads) ?? false;
}
private _getPlayersInAirfieldRange(): PlayerView[] {
@@ -785,9 +846,7 @@ export class ControlPanel2 extends LitElement implements Layer {
this.uiState.multibuildEnabled = checkbox.checked;
}
- private _changeTab(
- tab: "Build" | "Attack" | "Economy" | "Research" | "Bombers",
- ) {
+ private _changeTab(tab: "Build" | "Attack" | "Economy" | "Bombers") {
this.activeTab = tab;
if (this.uiState.pendingBuildUnitType) {
this.uiState.pendingBuildUnitType = null;
@@ -801,7 +860,6 @@ export class ControlPanel2 extends LitElement implements Layer {
const player = this.game.myPlayer();
const hasRoads = player?.hasUpgrade(UpgradeType.Roads) ?? false;
- // Research tab has been simplified; upgrade buttons and sub-tabs removed.
return html`
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index f5dabcbf2..62cc3fd16 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -13,11 +13,13 @@ import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerID, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
+import { UnitCooldownEndedEvent } from "../../events/UnitCooldownEndedEvent";
import { MouseUpEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
class StructureRenderInfo {
public isOnScreen: boolean = false;
+ public isOnCooldown: boolean = false;
constructor(
public unit: UnitView,
public owner: PlayerID,
@@ -42,6 +44,7 @@ const ICON_SIZES: Record = {
const ICON_GROW_ZOOM_THRESHOLD = 2;
const UNDER_CONSTRUCTION_FILL = "rgb(198, 198, 198)";
const UNDER_CONSTRUCTION_BORDER = "rgb(128, 127, 127)";
+const reloadingColor = "red";
// Background shape per structure type
type BgShape = "circle" | "square" | "triangle" | "pentagon" | "octagon";
@@ -123,6 +126,14 @@ export class StructureLayer implements Layer {
await this.setupRenderer();
this.redraw();
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
+ this.eventBus.on(UnitCooldownEndedEvent, (e) => {
+ if (e.unit.type() === UnitType.City) {
+ const render = this.renders.find((r) => r.unit.id() === e.unit.id());
+ if (render) {
+ this.updateRenderState(render, e.unit);
+ }
+ }
+ });
}
async setupRenderer() {
@@ -221,7 +232,17 @@ export class StructureLayer implements Layer {
const ownerChanged = render.owner !== unit.owner().id();
const constructionStateChanged =
render.underConstruction !== isConstruction;
- if (ownerChanged || constructionStateChanged) {
+
+ let cooldownChanged = false;
+ if (unit.type() === UnitType.City) {
+ const isOnCooldown = this.game.isCitySamOnCooldown(unit.id());
+ if (isOnCooldown !== render.isOnCooldown) {
+ cooldownChanged = true;
+ render.isOnCooldown = isOnCooldown;
+ }
+ }
+
+ if (ownerChanged || constructionStateChanged || cooldownChanged) {
render.owner = unit.owner().id();
render.underConstruction = isConstruction;
render.pixiSprite?.destroy();
@@ -235,9 +256,12 @@ export class StructureLayer implements Layer {
const structureType = isConstruction
? (unit.constructionType() ?? unit.type())
: unit.type();
- const cacheKey = isConstruction
+ let cacheKey = isConstruction
? `construction-${structureType}`
: `${unit.owner().id()}-${structureType}`;
+ if (unit.type() === UnitType.City) {
+ cacheKey += `-${this.game.isCitySamOnCooldown(unit.id())}`;
+ }
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
@@ -265,6 +289,13 @@ export class StructureLayer implements Layer {
borderColor = border.darken(0.17).toRgbString();
}
+ if (
+ unit.type() === UnitType.City &&
+ this.game.isCitySamOnCooldown(unit.id())
+ ) {
+ borderColor = reloadingColor;
+ }
+
// Draw background shape
ctx.beginPath();
if (shape === "circle") {
diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts
new file mode 100644
index 000000000..08987e97c
--- /dev/null
+++ b/src/client/graphics/layers/TechUnlockNotification.ts
@@ -0,0 +1,271 @@
+import { html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { EventBus } from "../../../core/EventBus";
+import { PlayerID } from "../../../core/game/Game";
+import {
+ GameUpdateType,
+ type PlayerUpdate,
+} from "../../../core/game/GameUpdates";
+import { GameView } from "../../../core/game/GameView";
+import { getTechNodes } from "../../../core/tech/ResearchTree";
+import { getTechMeta } from "../../../core/tech/TechEffects";
+import { Layer } from "./Layer";
+
+type TechNotificationPayload = {
+ id: string;
+ name: string;
+ description: string;
+};
+
+const AUTO_DISMISS_DELAY_MS = 5000;
+const EXIT_ANIMATION_MS = 200;
+
+@customElement("tech-unlock-notification")
+export class TechUnlockNotification extends LitElement implements Layer {
+ @property({ attribute: false })
+ public game!: GameView;
+
+ @property({ attribute: false })
+ public eventBus!: EventBus;
+
+ @state()
+ private current: TechNotificationPayload | null = null;
+
+ @state()
+ private isVisible = false;
+
+ private queue: TechNotificationPayload[] = [];
+ private seenTechs = new Set();
+ private activePlayerId: PlayerID | null = null;
+ private dismissTimer: number | null = null;
+ private exitTimer: number | null = null;
+ private allTechIds = new Set(getTechNodes().map((t) => t.id));
+
+ createRenderRoot() {
+ return this;
+ }
+
+ init() {
+ this.seedFromPlayer();
+ }
+
+ shouldTransform(): boolean {
+ return false;
+ }
+
+ tick() {
+ const player = this.game.myPlayer();
+ if (!player || !player.isAlive()) {
+ if (this.activePlayerId !== null) {
+ this.resetState();
+ }
+ return;
+ }
+
+ if (player.id() !== this.activePlayerId) {
+ this.activePlayerId = player.id();
+ this.seedFromPlayer();
+ }
+
+ const updates = this.game.updatesSinceLastTick();
+ const playerUpdates =
+ (updates?.[GameUpdateType.Player] as PlayerUpdate[]) ?? [];
+ if (!playerUpdates.length) return;
+
+ for (const update of playerUpdates) {
+ if (update.id !== player.id()) continue;
+ if (!update.researchTreeTechs) continue;
+ this.handleResearchUpdate(update.researchTreeTechs);
+ }
+ }
+
+ private handleResearchUpdate(updatedTechs: string[]) {
+ const filtered = updatedTechs.filter((id) => this.allTechIds.has(id));
+ for (const techId of filtered) {
+ if (this.seenTechs.has(techId)) continue;
+ const meta = getTechMeta(techId, { strict: false });
+ if (!meta) continue;
+ this.seenTechs.add(techId);
+ this.enqueue({
+ id: techId,
+ name: meta.name ?? techId,
+ description: meta.description ?? "",
+ });
+ }
+ for (const techId of filtered) this.seenTechs.add(techId);
+ }
+
+ private seedFromPlayer() {
+ this.queue = [];
+ this.clearTimers();
+ this.current = null;
+ this.isVisible = false;
+ this.seenTechs.clear();
+ const player = this.game.myPlayer();
+ if (!player) return;
+ for (const techId of this.allTechIds) {
+ if (player.hasResearchedTech(techId)) {
+ this.seenTechs.add(techId);
+ }
+ }
+ }
+
+ private resetState() {
+ this.activePlayerId = null;
+ this.seenTechs.clear();
+ this.queue = [];
+ this.clearTimers();
+ this.current = null;
+ this.isVisible = false;
+ }
+
+ private enqueue(payload: TechNotificationPayload) {
+ this.queue.push(payload);
+ if (!this.current) {
+ this.showNext();
+ }
+ }
+
+ private showNext() {
+ const next = this.queue.shift() ?? null;
+ this.current = next;
+ if (!next) {
+ this.isVisible = false;
+ return;
+ }
+ this.isVisible = true;
+ this.clearDismissTimer();
+ this.dismissTimer = window.setTimeout(
+ () => this.handleAutoDismiss(),
+ AUTO_DISMISS_DELAY_MS,
+ );
+ }
+
+ private handleAutoDismiss() {
+ this.dismiss();
+ }
+
+ private dismiss = () => {
+ if (!this.current) return;
+ this.isVisible = false;
+ this.clearDismissTimer();
+ this.clearExitTimer();
+ this.exitTimer = window.setTimeout(() => {
+ this.current = null;
+ this.showNext();
+ }, EXIT_ANIMATION_MS);
+ };
+
+ private clearTimers() {
+ this.clearDismissTimer();
+ this.clearExitTimer();
+ }
+
+ private clearDismissTimer() {
+ if (this.dismissTimer !== null) {
+ window.clearTimeout(this.dismissTimer);
+ this.dismissTimer = null;
+ }
+ }
+
+ private clearExitTimer() {
+ if (this.exitTimer !== null) {
+ window.clearTimeout(this.exitTimer);
+ this.exitTimer = null;
+ }
+ }
+
+ render() {
+ const visible = this.isVisible && this.current !== null;
+ return html`
+
+
+ ${this.current
+ ? html`
+
+
${this.current.description}
+ `
+ : null}
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index 5ad06a4b8..3bbcc828d 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -157,6 +157,12 @@ export class TerritoryLayer implements Layer {
});
}
+ const tileOwnerChangedUpdates =
+ updates !== null ? updates[GameUpdateType.TileOwnerChanged] : [];
+ tileOwnerChangedUpdates.forEach((update) => {
+ this.enqueueTile(update.tile);
+ });
+
const focusedPlayer = this.game.focusedPlayer();
if (focusedPlayer !== this.lastFocusedPlayer) {
if (this.lastFocusedPlayer) {
diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts
index 1f359aa71..a8b5316ea 100644
--- a/src/client/graphics/layers/UILayer.ts
+++ b/src/client/graphics/layers/UILayer.ts
@@ -68,7 +68,8 @@ export class UILayer implements Layer {
if (
this.selectedUnit &&
(this.selectedUnit.type() === UnitType.Warship ||
- this.selectedUnit.type() === UnitType.FighterJet)
+ this.selectedUnit.type() === UnitType.FighterJet ||
+ this.selectedUnit.type() === UnitType.Submarine)
) {
this.drawSelectionBox(this.selectedUnit);
}
@@ -166,7 +167,8 @@ export class UILayer implements Layer {
if (
event.unit &&
(event.unit.type() === UnitType.Warship ||
- event.unit.type() === UnitType.FighterJet)
+ event.unit.type() === UnitType.FighterJet ||
+ event.unit.type() === UnitType.Submarine)
) {
this.drawSelectionBox(event.unit);
}
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts
index 01c4a4c37..4a7a93204 100644
--- a/src/client/graphics/layers/UnitLayer.ts
+++ b/src/client/graphics/layers/UnitLayer.ts
@@ -12,6 +12,7 @@ import {
} from "../../InputHandler";
import {
MoveFighterJetIntentEvent,
+ MoveSubmarineIntentEvent, // <-- Add this
MoveWarshipIntentEvent,
} from "../../Transport";
import { TransformHandler } from "../TransformHandler";
@@ -53,6 +54,7 @@ export class UnitLayer implements Layer {
// Configuration for unit selection
private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone
+ private readonly SUBMARINE_SELECTION_RADIUS = 10;
private readonly FIGHTER_JET_SELECTION_RADIUS = 10;
// Cache sprite sizes per UnitType to avoid repeated lookups when clearing
@@ -118,6 +120,28 @@ export class UnitLayer implements Layer {
});
}
+ private findSubmarinesNearCell(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.Submarine) // <-- Change this line
+ .filter(
+ (unit) =>
+ unit.isActive() &&
+ unit.owner() === this.game.myPlayer() &&
+ this.game.manhattanDist(unit.tile(), clickRef) <=
+ this.SUBMARINE_SELECTION_RADIUS, // <-- Change this line
+ )
+ .sort((a, b) => {
+ const distA = this.game.manhattanDist(a.tile(), clickRef);
+ const distB = this.game.manhattanDist(b.tile(), clickRef);
+ return distA - distB;
+ });
+ }
+
private findFighterJetsNearCell(cell: { x: number; y: number }): UnitView[] {
if (!this.game.isValidCoord(cell.x, cell.y)) {
return [];
@@ -149,6 +173,7 @@ export class UnitLayer implements Layer {
// Find warships near this cell, sorted by distance
const nearbyWarships = this.findWarshipsNearCell(cell);
+ const nearbySubmarines = this.findSubmarinesNearCell(cell);
const nearbyFighterJets = this.findFighterJetsNearCell(cell);
if (this.selectedUnit) {
@@ -164,6 +189,13 @@ export class UnitLayer implements Layer {
this.eventBus.emit(
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
);
+ } else if (
+ this.selectedUnit.type() === UnitType.Submarine &&
+ this.game.isOcean(clickRef)
+ ) {
+ this.eventBus.emit(
+ new MoveSubmarineIntentEvent(this.selectedUnit.id(), clickRef),
+ );
}
// Deselect
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
@@ -172,6 +204,10 @@ export class UnitLayer implements Layer {
// Toggle selection of the closest warship
const clickedUnit = nearbyWarships[0];
this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
+ } else if (nearbySubmarines.length > 0) {
+ // Toggle selection of the closest submarine
+ const clickedUnit = nearbySubmarines[0];
+ this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
} else if (nearbyFighterJets.length > 0) {
const clickedUnit = nearbyFighterJets[0];
this.eventBus.emit(new UnitSelectionEvent(clickedUnit, true));
@@ -259,6 +295,11 @@ export class UnitLayer implements Layer {
.filter((unit) => unit !== undefined) as UnitView[] | undefined;
if (unitsToUpdate && unitsToUpdate.length > 0) {
+ const oldAngleByUnit = new Map();
+ for (const u of unitsToUpdate) {
+ oldAngleByUnit.set(u, this.unitToLastAngle.get(u) ?? null);
+ }
+
// Precompute angles once per unit to avoid duplicate work across passes
const angleByUnit = new Map();
for (const u of unitsToUpdate) {
@@ -268,7 +309,7 @@ export class UnitLayer implements Layer {
// the clearing and drawing of unit sprites need to be done in 2 passes
// otherwise the sprite of a unit can be drawn on top of another unit
- this.clearUnitsCells(unitsToUpdate, angleByUnit);
+ this.clearUnitsCells(unitsToUpdate, oldAngleByUnit);
this.drawUnitsCells(unitsToUpdate, angleByUnit);
}
}
@@ -333,10 +374,55 @@ export class UnitLayer implements Layer {
this.handleUnitDeactivation(unit);
}
+ if (
+ unit.type() === UnitType.Submarine &&
+ unit.owner() !== this.game.myPlayer()
+ ) {
+ const isPeriodicallyVisible = this.game.isUnitPeriodicallyVisible(
+ unit.id(),
+ );
+ const isAttacking = unit.isAttacking();
+ const isDetected = unit.isDetectedByNavalUnit();
+
+ if (
+ !isPeriodicallyVisible &&
+ !isAttacking &&
+ !isDetected &&
+ !unit.isCooldown()
+ ) {
+ return; // Don't render the submarine
+ }
+ }
+
+ // START: Custom rendering for owner's submarine visibility
+ if (
+ unit.type() === UnitType.Submarine &&
+ unit.owner() === this.game.myPlayer()
+ ) {
+ const isPeriodicallyVisible = this.game.isUnitPeriodicallyVisible(
+ unit.id(),
+ );
+ const isAttacking = unit.isAttacking();
+ const isDetected = unit.isDetectedByNavalUnit();
+ const isOnCooldown = unit.isCooldown();
+
+ const isVisibleToEnemies =
+ isPeriodicallyVisible || isAttacking || isDetected || isOnCooldown;
+
+ if (!isVisibleToEnemies) {
+ // If hidden, draw it smaller and return early
+ this.drawSprite(unit, undefined, 0.75);
+ return;
+ }
+ }
+ // END: Custom rendering
+
switch (unit.type()) {
case UnitType.TransportShip:
+ case UnitType.Paratrooper:
this.handleBoatEvent(unit);
break;
+ case UnitType.Submarine:
case UnitType.Warship:
this.handleWarShipEvent(unit, angleByUnit);
break;
@@ -613,11 +699,32 @@ export class UnitLayer implements Layer {
context.clearRect(x, y, 1, 1);
}
+ drawSprite(
+ unit: UnitView,
+ customTerritoryColor?: Colord,
+ sizeMultiplier?: number,
+ );
drawSprite(
unit: UnitView,
customTerritoryColor?: Colord,
angleByUnit?: Map,
+ sizeMultiplier?: number,
+ );
+ drawSprite(
+ unit: UnitView,
+ customTerritoryColor?: Colord,
+ angleByUnitOrSizeMultiplier?: Map | number,
+ sizeMultiplier: number = 1.0,
) {
+ let angleByUnit: Map | undefined;
+ let sizeMult = sizeMultiplier;
+
+ if (typeof angleByUnitOrSizeMultiplier === "number") {
+ sizeMult = angleByUnitOrSizeMultiplier;
+ } else {
+ angleByUnit = angleByUnitOrSizeMultiplier;
+ }
+
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
@@ -676,12 +783,15 @@ export class UnitLayer implements Layer {
this.context.translate(-x, -y);
}
+ const newWidth = sprite.width * sizeMult;
+ const newHeight = sprite.width * sizeMult; // Keep aspect ratio square
+
this.context.drawImage(
sprite,
- Math.round(x - sprite.width / 2),
- Math.round(y - sprite.height / 2),
- sprite.width,
- sprite.width,
+ Math.round(x - newWidth / 2),
+ Math.round(y - newHeight / 2),
+ newWidth,
+ newHeight,
);
if (angle !== null) {
diff --git a/src/client/index.html b/src/client/index.html
index c59569fbb..1c04e1300 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -360,7 +360,9 @@
+
+
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 442c9a96e..e406b53e5 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -19,6 +19,7 @@ import {
PlayerInfo,
PlayerProfile,
PlayerType,
+ UnitType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
@@ -189,6 +190,22 @@ export class GameRunner {
});
}
+ // Submarine periodic visibility ping
+ this.game.players().forEach((p) => {
+ p.units(UnitType.Submarine).forEach((submarine) => {
+ if (
+ this.game.ticks() - (submarine.lastVisibleTick ?? -Infinity) >
+ 15 * (1000 / this.game.config().serverConfig().turnIntervalMs())
+ ) {
+ submarine.lastVisibleTick = this.game.ticks();
+ updates[GameUpdateType.SubmarinePing].push({
+ type: GameUpdateType.SubmarinePing,
+ unitId: submarine.id(),
+ });
+ }
+ });
+ });
+
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
updates[GameUpdateType.Tile] = [];
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index ff47fd4be..6f864281d 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -45,8 +45,11 @@ export type Intent =
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
+ | MoveSubmarineIntent
| MoveFighterJetIntent
| BomberIntent
+ | ParatrooperAttackIntent
+ | CancelParatrooperIntent
| MarkDisconnectedIntent
| SetAutoBombingIntent
| KickPlayerIntent;
@@ -81,9 +84,17 @@ export type ResearchTreeSelectIntent = z.infer<
typeof ResearchTreeSelectIntentSchema
>;
export type MoveWarshipIntent = z.infer;
+export type MoveSubmarineIntent = z.infer;
export type MoveFighterJetIntent = z.infer;
export type BomberIntent = z.infer;
export type SetAutoBombingIntent = z.infer;
+export type ParatrooperAttackIntent = z.infer<
+ typeof ParatrooperAttackIntentSchema
+>;
+
+export type CancelParatrooperIntent = z.infer<
+ typeof CancelParatrooperIntentSchema
+>;
export type QuickChatIntent = z.infer;
export type MarkDisconnectedIntent = z.infer<
@@ -363,7 +374,7 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({
export const PurchaseUpgradeIntentSchema = BaseIntentSchema.extend({
type: z.literal("purchase_upgrade"),
- upgrade: z.enum(UpgradeType),
+ upgrade: z.nativeEnum(UpgradeType),
});
export const ResearchTreeSelectIntentSchema = BaseIntentSchema.extend({
@@ -387,6 +398,12 @@ export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
tile: z.number(),
});
+export const MoveSubmarineIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("move_submarine"),
+ unitId: z.number(),
+ tile: z.number(),
+});
+
export const MoveFighterJetIntentSchema = BaseIntentSchema.extend({
type: z.literal("move_fighter_jet"),
unitId: z.number(),
@@ -398,6 +415,18 @@ export const BomberIntentSchema = BaseIntentSchema.extend({
structure: z.enum(UnitType).nullable(), // what to bomb
});
+export const ParatrooperAttackIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("paratrooper_attack"),
+ targetID: ID.nullable(),
+ troops: z.number(),
+ dst: z.number(),
+});
+
+export const CancelParatrooperIntentSchema = BaseIntentSchema.extend({
+ type: z.literal("cancel_paratrooper"),
+ unitID: z.number(),
+});
+
export const QuickChatIntentSchema = BaseIntentSchema.extend({
type: z.literal("quick_chat"),
recipient: ID,
@@ -444,8 +473,11 @@ const IntentSchema = z.discriminatedUnion("type", [
ResearchTreeSelectIntentSchema,
EmbargoIntentSchema,
MoveWarshipIntentSchema,
+ MoveSubmarineIntentSchema,
MoveFighterJetIntentSchema,
BomberIntentSchema,
+ ParatrooperAttackIntentSchema,
+ CancelParatrooperIntentSchema,
QuickChatIntentSchema,
SetAutoBombingIntentSchema,
KickPlayerIntentSchema,
diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts
index 7dcdfdfce..797af6aaa 100644
--- a/src/core/StatsSchemas.ts
+++ b/src/core/StatsSchemas.ts
@@ -21,7 +21,11 @@ export const unitTypeToBombUnit = {
[UnitType.MIRVWarhead]: "mirvw",
} as const satisfies Record;
-export const BoatUnitSchema = z.union([z.literal("trade"), z.literal("trans")]);
+export const BoatUnitSchema = z.union([
+ z.literal("trade"),
+ z.literal("trans"),
+ z.literal("para"),
+]);
export type BoatUnit = z.infer;
export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip;
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index bd0f213a3..2c7ccd4a2 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -122,6 +122,14 @@ export interface Config {
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
shellLifetime(): number;
boatMaxNumber(): number;
+ paratrooperAttackAmount(
+ attacker: Player,
+ defender: Player | TerraNullius,
+ ): number;
+ paratrooperMaxNumber(): number;
+ paratrooperSpeed(): number;
+ paratrooperMaxRange(): number;
+ paratrooperTroopCostPercentage(): number;
allianceDuration(): Tick;
allianceRequestCooldown(): Tick;
temporaryEmbargoDuration(): Tick;
@@ -175,6 +183,8 @@ export interface Config {
bomberSpeed(): number;
safeFromPiratesCooldownMax(): number;
defensePostRange(): number;
+ citySamLaunchRange(): number;
+ citySamCooldown(): number;
SAMNukeCooldown(): number;
SAMPlaneCooldown(): number;
SiloCooldown(): number;
@@ -195,6 +205,10 @@ export interface Config {
fighterJetTargetReachedDistance(): number;
fighterJetDogfightDistance(): number;
fighterJetMinDogfightDistance(): number;
+ warshipAARange(): number;
+ warshipAACooldown(): number;
+ warshipAAScanInterval(): number;
+ warshipAAHittingChance(): number;
// 0-1
traitorDefenseDebuff(): number;
traitorDuration(): number;
@@ -219,6 +233,7 @@ export interface Config {
researchBeakerMax(): number; // inclusive
// Server-side cadence for research innovation calculation (ticks)
researchIntervalTicks(): number;
+ forceCanBuildBomberInTests?(): boolean; // Change to optional method
}
export interface Theme {
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 103cf902c..70adeea9b 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -86,6 +86,9 @@ const TERRAIN_EFFECTS = {
[TerrainType.Mountain]: { mag: 1.2, speed: 1.2 },
} as const;
+const WARSHIP_AA_RANGE_MULTIPLIER = 0.75;
+const WARSHIP_AA_COOLDOWN_MULTIPLIER = 1.25;
+
export abstract class DefaultServerConfig implements ServerConfig {
private publicKey: JWK;
abstract jwtAudience(): string;
@@ -217,6 +220,10 @@ export class DefaultConfig implements Config {
return this._isReplay;
}
+ forceCanBuildBomberInTests(): boolean {
+ return false;
+ }
+
traitorDefenseDebuff(): number {
return 0.5;
}
@@ -272,6 +279,12 @@ export class DefaultConfig implements Config {
}
//SAMs
+ citySamLaunchRange(): number {
+ return 50;
+ }
+ citySamCooldown(): number {
+ return 300;
+ }
samNukeHittingChance(): number {
return 1;
}
@@ -442,6 +455,47 @@ export class DefaultConfig implements Config {
return 10;
}
+ // Paratroopers/Air attack
+ paratrooperMaxNumber(): number {
+ return 3;
+ }
+
+ paratrooperSpeed(): number {
+ return 1;
+ }
+
+ paratrooperMaxRange(): number {
+ return 1000;
+ }
+
+ paratrooperTroopCostPercentage(): number {
+ return 0.3;
+ }
+
+ paratrooperAttackAmount(
+ attacker: Player,
+ defender: Player | TerraNullius,
+ ): number {
+ return Math.floor(attacker.troops() / 10);
+ }
+
+ warshipAARange(): number {
+ return this.defaultSamRange() * WARSHIP_AA_RANGE_MULTIPLIER; // 80 * 0.75 = 60
+ }
+
+ warshipAACooldown(): number {
+ return this.SAMPlaneCooldown() * WARSHIP_AA_COOLDOWN_MULTIPLIER; // 40 * 1.25 = 50
+ }
+
+ warshipAAScanInterval(): number {
+ return 5; // 5 ticks = 0.5 seconds
+ }
+
+ warshipAAHittingChance(): number {
+ // For now, mirrors the standard SAM hit chance. Can be modified later for balancing.
+ return this.samPlaneHittingChance();
+ }
+
unitInfo(type: UnitType): UnitInfo {
switch (type) {
case UnitType.TransportShip:
@@ -463,6 +517,15 @@ export class DefaultConfig implements Config {
territoryBound: false,
maxHealth: 1000,
};
+ case UnitType.Submarine:
+ return {
+ cost: (p: Player) =>
+ p.type() === PlayerType.Human && this.infiniteGold()
+ ? 0n
+ : 1_000_000n,
+ territoryBound: false,
+ maxHealth: 1000,
+ };
case UnitType.Shell:
return {
cost: () => 0n,
@@ -653,11 +716,19 @@ export class DefaultConfig implements Config {
territoryBound: false,
maxHealth: 750,
};
+ case UnitType.Paratrooper:
+ return {
+ cost: () => 0n,
+ territoryBound: false,
+ };
default:
assertNever(type);
}
}
- upgradeInfo(type: UpgradeType): { cost: (player: Player) => Gold } {
+ upgradeInfo(type: UpgradeType): {
+ cost: (player: Player) => Gold;
+ prerequisite?: (player: Player) => boolean;
+ } {
const costForPlayer = (cost: bigint) => (p: Player) => {
if (p.type() === PlayerType.Human && this.infiniteGold()) {
return 0n;
@@ -678,8 +749,18 @@ export class DefaultConfig implements Config {
return { cost: costForPlayer(3_000_000n) };
// Water
+ case UpgradeType.SubmarineResearch:
+ return { cost: costForPlayer(1_000_000n) };
+ case UpgradeType.NuclearSubmarineResearch:
+ return {
+ cost: costForPlayer(3_000_000n),
+ prerequisite: (p: Player) =>
+ p.hasUpgrade(UpgradeType.SubmarineResearch),
+ };
case UpgradeType.WaterUpgrade1:
return { cost: costForPlayer(1_000_000n) };
+ case UpgradeType.WarshipAntiAir:
+ return { cost: costForPlayer(2_000_000n) };
case UpgradeType.WaterUpgrade2:
return { cost: costForPlayer(2_000_000n) };
case UpgradeType.WaterUpgrade3:
@@ -688,6 +769,10 @@ export class DefaultConfig implements Config {
// Air
case UpgradeType.AirUpgrade1:
return { cost: costForPlayer(1_000_000n) };
+ case UpgradeType.CityAntiAir:
+ return { cost: costForPlayer(2_000_000n) };
+ case UpgradeType.FighterJetNavalTargeting:
+ return { cost: costForPlayer(3_000_000n) };
case UpgradeType.AirUpgrade2:
return { cost: costForPlayer(2_000_000n) };
case UpgradeType.AirUpgrade3:
@@ -746,6 +831,7 @@ export class DefaultConfig implements Config {
boatMaxNumber(): number {
return 3;
}
+
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index dcb54eab9..79013a0ce 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -29,6 +29,7 @@ export class AttackExecution implements Execution {
private mg: Game;
private attack: Attack | null = null;
+ private isDeepStrike: boolean = false;
constructor(
private startTroops: number | null = null,
@@ -36,7 +37,9 @@ export class AttackExecution implements Execution {
private _targetID: PlayerID | null,
private sourceTile: TileRef | null = null,
private removeTroops: boolean = true,
- ) {}
+ ) {
+ this.isDeepStrike = sourceTile !== null;
+ }
public targetID(): PlayerID | null {
return this._targetID;
@@ -127,7 +130,7 @@ export class AttackExecution implements Execution {
this._owner.removeTroops(penalty);
if (this.sourceTile !== null) {
- this.addNeighbors(this.sourceTile);
+ this.initializeConquestFromLandingTile(this.sourceTile);
} else {
this.refreshToConquer();
}
@@ -171,6 +174,21 @@ export class AttackExecution implements Execution {
}
}
+ private initializeConquestFromLandingTile(tile: TileRef) {
+ if (this.attack === null) {
+ throw new Error("Attack not initialized");
+ }
+ this.toConquer.clear();
+ this.attack.clearBorder();
+
+ // Add the source tile itself to be conquered first
+ this.toConquer.enqueue(tile, 0); // High priority for the landing tile
+ this.attack.addBorderTile(tile);
+
+ // Then add its neighbors that are owned by the target
+ this.addNeighbors(tile);
+ }
+
private refreshToConquer() {
if (this.attack === null) {
throw new Error("Attack not initialized");
@@ -262,7 +280,9 @@ export class AttackExecution implements Execution {
}
if (this.toConquer.size() === 0) {
- this.refreshToConquer();
+ if (!this.isDeepStrike) {
+ this.refreshToConquer();
+ }
this.retreat();
return;
}
@@ -271,10 +291,14 @@ export class AttackExecution implements Execution {
this.attack.removeBorderTile(tileToConquer);
let onBorder = false;
- for (const n of this.mg.neighbors(tileToConquer)) {
- if (this.mg.owner(n) === this._owner) {
- onBorder = true;
- break;
+ if (this.isDeepStrike && tileToConquer === this.sourceTile) {
+ onBorder = true; // The landing tile is always considered "on border" for a deep strike
+ } else {
+ for (const n of this.mg.neighbors(tileToConquer)) {
+ if (this.mg.owner(n) === this._owner) {
+ onBorder = true;
+ break;
+ }
}
}
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
@@ -311,7 +335,7 @@ export class AttackExecution implements Execution {
if (targetPlayer) {
targetPlayer.addHospitalReturns(defenderReturns);
}
- this._owner.conquer(tileToConquer);
+ this.mg.conquer(this._owner, tileToConquer);
this.handleDeadDefender();
}
}
diff --git a/src/core/execution/BomberExecution.ts b/src/core/execution/BomberExecution.ts
index db735b94a..60ed493cf 100644
--- a/src/core/execution/BomberExecution.ts
+++ b/src/core/execution/BomberExecution.ts
@@ -1,6 +1,11 @@
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { StraightPathFinder } from "../pathfinding/PathFinding";
+import { PseudoRandom } from "../PseudoRandom";
+import {
+ attemptInterception,
+ findEligibleCitiesForBomber,
+} from "./utils/CityAntiAirUtils";
export class BomberExecution implements Execution {
private active = true;
@@ -10,6 +15,8 @@ export class BomberExecution implements Execution {
private returning = false;
private pathFinder: StraightPathFinder;
private dropTicker = 0;
+ private eligibleCities: Unit[] = [];
+ private random: PseudoRandom;
constructor(
private origOwner: Player,
@@ -22,6 +29,7 @@ export class BomberExecution implements Execution {
this.mg = mg;
this.pathFinder = new StraightPathFinder(mg);
this.bombsLeft = mg.config().bomberPayload();
+ this.random = new PseudoRandom(ticks);
}
tick(_ticks: number): void {
@@ -41,6 +49,7 @@ export class BomberExecution implements Execution {
this.bomber = this.origOwner.buildUnit(UnitType.Bomber, spawn, {
targetTile: this.targetTile,
});
+ this.eligibleCities = findEligibleCitiesForBomber(this.bomber, this.mg);
}
if (!this.bomber.isActive()) {
this.active = false;
@@ -50,17 +59,6 @@ export class BomberExecution implements Execution {
);
return;
}
- if (!this.returning && this.bombsLeft > 0) {
- this.dropTicker++;
- if (
- this.dropTicker >= this.mg.config().bomberDropCadence() &&
- this.mg.euclideanDistSquared(this.bomber.tile(), this.targetTile) <= 1
- ) {
- this.dropBomb();
- this.dropTicker = 0;
- return;
- }
- }
const destination = this.returning
? this.sourceAirfield.tile()
@@ -86,6 +84,28 @@ export class BomberExecution implements Execution {
this.bomber.move(step);
+ if (this.bomber === null || this.bomber.targetedBySAM()) return;
+
+ const currentBomber = this.bomber;
+ const readyInterceptors = this.eligibleCities.filter(
+ (city) =>
+ !this.mg.isCitySamOnCooldown(city.id()) &&
+ this.mg.euclideanDistSquared(currentBomber.tile(), city.tile()) <=
+ this.mg.config().citySamLaunchRange() *
+ this.mg.config().citySamLaunchRange(),
+ );
+
+ if (readyInterceptors.length > 0) {
+ readyInterceptors.sort(
+ (a, b) =>
+ this.mg.euclideanDistSquared(currentBomber.tile(), a.tile()) -
+ this.mg.euclideanDistSquared(currentBomber.tile(), b.tile()),
+ );
+
+ const closestInterceptor = readyInterceptors[0];
+ attemptInterception(currentBomber, this.mg, closestInterceptor);
+ }
+
if (
!this.returning &&
this.bombsLeft > 0 &&
diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts
index a461a6d8a..66a6bcbd6 100644
--- a/src/core/execution/ConstructionExecution.ts
+++ b/src/core/execution/ConstructionExecution.ts
@@ -3,9 +3,11 @@ import {
Game,
Gold,
Player,
+ PlayerType,
Tick,
Unit,
UnitType,
+ UpgradeType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { AcademyExecution } from "./AcademyExecution";
@@ -19,6 +21,7 @@ import { MissileSiloExecution } from "./MissileSiloExecution";
import { NukeExecution } from "./NukeExecution";
import { PortExecution } from "./PortExecution";
import { SAMLauncherExecution } from "./SAMLauncherExecution";
+import { SubmarineExecution } from "./SubmarineExecution";
import { WarshipExecution } from "./WarshipExecution";
export class ConstructionExecution implements Execution {
@@ -118,6 +121,11 @@ export class ConstructionExecution implements Execution {
new WarshipExecution({ owner: player, patrolTile: this.tile }),
);
break;
+ case UnitType.Submarine:
+ this.mg.addExecution(
+ new SubmarineExecution({ owner: player, patrolTile: this.tile }),
+ );
+ break;
case UnitType.FighterJet:
this.mg.addExecution(
new FighterJetExecution({ owner: player, patrolTile: this.tile }),
@@ -133,6 +141,12 @@ export class ConstructionExecution implements Execution {
this.mg.addExecution(new DefensePostExecution(player, this.tile));
break;
case UnitType.SAMLauncher:
+ if (
+ player.type() === PlayerType.FakeHuman &&
+ player.unitsOwned(UnitType.SAMLauncher) === 0
+ ) {
+ player.addUpgrade(UpgradeType.CityAntiAir);
+ }
this.mg.addExecution(new SAMLauncherExecution(player, this.tile));
break;
case UnitType.City:
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index 5708782e4..04220f50b 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -18,8 +18,11 @@ import { EmojiExecution } from "./EmojiExecution";
import { FakeHumanExecution } from "./FakeHumanExecution";
import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
import { MoveFighterJetExecution } from "./MoveFighterJetExecution";
+import { MoveSubmarineExecution } from "./MoveSubmarineExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NoOpExecution } from "./NoOpExecution";
+import { ParatrooperAttackExecution } from "./ParatrooperAttackExecution";
+import { ParatrooperRetreatExecution } from "./ParatrooperRetreatExecution";
import { PeaceRequestExecution } from "./PeaceRequestExecution";
import { PurchaseUpgradeExecution } from "./PurchaseUpgradeExecution";
import { QuickChatExecution } from "./QuickChatExecution";
@@ -72,8 +75,12 @@ export class Executor {
return new RetreatExecution(player, intent.attackID);
case "cancel_boat":
return new BoatRetreatExecution(player, intent.unitID);
+ case "cancel_paratrooper":
+ return new ParatrooperRetreatExecution(player, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
+ case "move_submarine":
+ return new MoveSubmarineExecution(player, intent.unitId, intent.tile);
case "move_fighter_jet":
return new MoveFighterJetExecution(player, intent.unitId, intent.tile);
case "bomber_intent":
@@ -94,6 +101,13 @@ export class Executor {
intent.troops,
src,
);
+ case "paratrooper_attack":
+ return new ParatrooperAttackExecution(
+ player,
+ intent.targetID,
+ intent.troops,
+ intent.dst,
+ );
case "allianceRequest":
return new AllianceRequestExecution(player, intent.recipient);
case "allianceRequestReply":
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index c1013861d..2a9a3368a 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -135,6 +135,7 @@ export class FakeHumanExecution implements Execution {
return;
}
this.player.addUpgrade(UpgradeType.InternationalTrade);
+ this.player.addUpgrade(UpgradeType.SubmarineResearch);
// Set research slider to 20% and set road investment to 20% at game start.
// Do NOT set any research priority here so the AI leaves research priority null.
diff --git a/src/core/execution/FighterJetExecution.ts b/src/core/execution/FighterJetExecution.ts
index a3c24701f..e4bde9c10 100644
--- a/src/core/execution/FighterJetExecution.ts
+++ b/src/core/execution/FighterJetExecution.ts
@@ -1,4 +1,11 @@
-import { Execution, OwnerComp, Unit, UnitParams, UnitType } from "../game/Game";
+import {
+ Execution,
+ OwnerComp,
+ Unit,
+ UnitParams,
+ UnitType,
+ UpgradeType,
+} from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { TileRef } from "../game/GameMap";
@@ -10,8 +17,9 @@ export class FighterJetExecution implements Execution {
private fighterJet: Unit;
private mg: GameImpl;
private random: PseudoRandom;
- private alreadySentShell: Set = new Set();
+ private lastAttackTick = 0;
private pathFinder: StraightPathFinder;
+ private nextScanTick = 0;
constructor(
private input: (UnitParams & OwnerComp) | Unit,
@@ -49,7 +57,14 @@ export class FighterJetExecution implements Execution {
this.fighterJet.modifyHealth(this.mg.config().fighterJetHealingAmount());
}
- this.fighterJet.setTargetUnit(this.findTargetUnit());
+ if (
+ this.mg.ticks() >= this.nextScanTick ||
+ !this.fighterJet.targetUnit()?.isActive()
+ ) {
+ this.fighterJet.setTargetUnit(this.findTargetUnit());
+ this.fighterJet.touch();
+ this.nextScanTick = this.mg.ticks() + 10;
+ }
if (this.fighterJet.targetUnit() !== undefined) {
if (this.fighterJet.targetUnit()?.type() === UnitType.CargoPlane) {
@@ -63,69 +78,109 @@ export class FighterJetExecution implements Execution {
}
private findTargetUnit(): Unit | undefined {
- const hasAirfield =
- this.fighterJet.owner().units(UnitType.Airfield).length > 0;
- const patrolRangeSquared = this.mg.config().fighterJetPatrolRange() ** 2;
- const closest = this._findClosest(
+ const owner = this.fighterJet.owner();
+ const ownerHasUpgrade = owner.hasUpgrade(
+ UpgradeType.FighterJetNavalTargeting,
+ );
+
+ const targetableUnitTypes: UnitType[] = [
+ UnitType.Bomber,
+ UnitType.FighterJet,
+ UnitType.CargoPlane,
+ UnitType.Paratrooper,
+ ];
+
+ if (ownerHasUpgrade) {
+ targetableUnitTypes.push(
+ UnitType.TransportShip,
+ UnitType.Warship,
+ UnitType.TradeShip,
+ );
+ }
+
+ const nearbyUnits = this.mg.nearbyUnits(
this.fighterJet.tile()!,
this.mg.config().fighterJetTargettingRange(),
- [UnitType.Bomber, UnitType.FighterJet, UnitType.CargoPlane],
- (unit) => {
- if (
- unit.owner() === this.fighterJet.owner() ||
- unit === this.fighterJet ||
- unit.owner().isFriendly(this.fighterJet.owner()) ||
- !unit.isTargetable()
- ) {
- return false;
+ targetableUnitTypes,
+ );
+
+ let bestTarget: Unit | undefined = undefined;
+ let bestPriority = 999;
+ let bestDistSquared = Infinity;
+
+ const getPriority = (type: UnitType): number => {
+ switch (type) {
+ case UnitType.FighterJet:
+ return 1;
+ case UnitType.Bomber:
+ return 2;
+ case UnitType.Paratrooper:
+ return 3;
+ case UnitType.CargoPlane:
+ return 4;
+ case UnitType.TransportShip:
+ return 5;
+ case UnitType.Warship:
+ return 6;
+ case UnitType.TradeShip:
+ return 7;
+ default:
+ return 99;
+ }
+ };
+
+ for (const { unit, distSquared } of nearbyUnits) {
+ if (
+ unit.owner() === owner ||
+ unit === this.fighterJet ||
+ unit.owner().isFriendly(owner) ||
+ !unit.isTargetable()
+ ) {
+ continue;
+ }
+
+ if (unit.type() === UnitType.CargoPlane) {
+ if (owner.units(UnitType.Airfield).length === 0) {
+ continue;
}
- // Only engage if at war with the target's owner
- if (unit.type() === UnitType.CargoPlane) {
- if (!hasAirfield) {
- return false;
- }
- const cargoPlaneDestinationAirfield = unit.targetUnit();
- if (cargoPlaneDestinationAirfield) {
- const destinationOwner = cargoPlaneDestinationAirfield.owner();
- if (
- destinationOwner === this.fighterJet.owner() ||
- destinationOwner.isFriendly(this.fighterJet.owner())
- ) {
- return false;
- }
+ const cargoPlaneDestinationAirfield = unit.targetUnit();
+ if (cargoPlaneDestinationAirfield) {
+ const destinationOwner = cargoPlaneDestinationAirfield.owner();
+ if (
+ destinationOwner === owner ||
+ destinationOwner.isFriendly(owner)
+ ) {
+ continue;
}
}
- return true;
- },
- );
-
- if (closest.length === 0) {
- return undefined;
- }
+ }
- closest.sort((a, b) => {
- const distA = this.mg.euclideanDistSquared(
- this.fighterJet.tile()!,
- a.tile()!,
- );
- const distB = this.mg.euclideanDistSquared(
- this.fighterJet.tile()!,
- b.tile()!,
- );
+ if (ownerHasUpgrade && unit.type() === UnitType.TradeShip) {
+ if (
+ owner.units(UnitType.Port).length === 0 ||
+ unit.isSafeFromPirates() ||
+ unit.targetUnit()?.owner() === owner ||
+ unit.targetUnit()?.owner().isFriendly(owner)
+ ) {
+ continue;
+ }
+ }
- if (a.type() === UnitType.FighterJet && b.type() !== UnitType.FighterJet)
- return -1;
- if (a.type() !== UnitType.FighterJet && b.type() === UnitType.FighterJet)
- return 1;
- if (a.type() === UnitType.Bomber && b.type() === UnitType.CargoPlane)
- return -1;
- if (a.type() === UnitType.CargoPlane && b.type() === UnitType.Bomber)
- return 1;
+ const priority = getPriority(unit.type());
- return distA - distB;
- });
+ if (priority < bestPriority) {
+ bestTarget = unit;
+ bestPriority = priority;
+ bestDistSquared = distSquared;
+ } else if (priority === bestPriority) {
+ if (distSquared < bestDistSquared) {
+ bestTarget = unit;
+ bestDistSquared = distSquared;
+ }
+ }
+ }
- return closest[0];
+ return bestTarget;
}
private attackTarget() {
@@ -206,25 +261,34 @@ export class FighterJetExecution implements Execution {
}
this.fighterJet.touch();
- if (
- distToTargetSquared <=
- this.mg.config().fighterJetTargetReachedDistance() ** 2
- ) {
- this.alreadySentShell.add(targetUnit);
- this.fighterJet.setTargetUnit(undefined);
+ if (this.mg.ticks() - this.lastAttackTick < 20) {
return;
}
-
- const shellAttackRate = this.mg.config().fighterJetAttackRate();
- if (this.mg.ticks() % shellAttackRate === 0) {
- this.mg.addExecution(
- new ShellExecution(
- this.fighterJet.tile()!,
- this.fighterJet.owner(),
- this.fighterJet,
- targetUnit,
- ),
- );
+ this.lastAttackTick = this.mg.ticks();
+
+ switch (targetUnit.type()) {
+ case UnitType.TransportShip:
+ case UnitType.TradeShip:
+ case UnitType.Warship:
+ this.mg.addExecution(
+ new ShellExecution(
+ this.fighterJet.tile()!,
+ this.fighterJet.owner(),
+ this.fighterJet,
+ targetUnit,
+ ),
+ );
+ break;
+ default: //FighterJet and Bomber
+ this.mg.addExecution(
+ new ShellExecution(
+ this.fighterJet.tile()!,
+ this.fighterJet.owner(),
+ this.fighterJet,
+ targetUnit,
+ ),
+ );
+ break;
}
}
@@ -314,44 +378,6 @@ export class FighterJetExecution implements Execution {
return this.mg.map().ref(x, y);
}
- private _findClosest(
- startTile: TileRef,
- range: number,
- unitTypes: UnitType[],
- predicate: (unit: Unit) => boolean,
- ): Unit[] {
- const nearbyUnits = this.mg.nearbyUnits(startTile, range, unitTypes);
- const validUnits: Unit[] = [];
-
- for (const { unit } of nearbyUnits) {
- if (predicate(unit)) {
- validUnits.push(unit);
- }
- }
-
- validUnits.sort((a, b) => {
- const distA = this.mg.euclideanDistSquared(startTile, a.tile());
- const distB = this.mg.euclideanDistSquared(startTile, b.tile());
-
- if (
- a.type() === UnitType.FighterJet &&
- b.type() !== UnitType.FighterJet
- ) {
- return -1;
- }
- if (
- a.type() !== UnitType.FighterJet &&
- b.type() === UnitType.FighterJet
- ) {
- return 1;
- }
-
- return distA - distB;
- });
-
- return validUnits;
- }
-
isActive(): boolean {
return this.fighterJet?.isActive();
}
diff --git a/src/core/execution/MoveSubmarineExecution.ts b/src/core/execution/MoveSubmarineExecution.ts
new file mode 100644
index 000000000..4c14c15b6
--- /dev/null
+++ b/src/core/execution/MoveSubmarineExecution.ts
@@ -0,0 +1,36 @@
+import { Execution, Game, Player, UnitType } from "../game/Game";
+import { TileRef } from "../game/GameMap";
+
+export class MoveSubmarineExecution implements Execution {
+ constructor(
+ private readonly owner: Player,
+ private readonly unitId: number,
+ private readonly position: TileRef,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ const submarine = this.owner
+ .units(UnitType.Submarine)
+ .find((u) => u.id() === this.unitId);
+ if (!submarine) {
+ console.warn("MoveSubmarineExecution: submarine not found");
+ return;
+ }
+ if (!submarine.isActive()) {
+ console.warn("MoveSubmarineExecution: submarine is not active");
+ return;
+ }
+ submarine.setPatrolTile(this.position);
+ submarine.setTargetTile(undefined);
+ }
+
+ tick(ticks: number): void {}
+
+ isActive(): boolean {
+ return false;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index 3b7a0e803..8ff33dd9a 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -12,6 +12,10 @@ import { TileRef } from "../game/GameMap";
import { ParabolaPathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { NukeType } from "../StatsSchemas";
+import {
+ attemptInterception,
+ findEligibleCitiesForNuke,
+} from "./utils/CityAntiAirUtils";
const SPRITE_RADIUS = 16;
@@ -20,6 +24,7 @@ export class NukeExecution implements Execution {
private mg: Game;
private nuke: Unit | null = null;
private tilesToDestroyCache: Set | undefined;
+ private eligibleCities: Unit[] = [];
private random: PseudoRandom;
private pathFinder: ParabolaPathFinder;
@@ -148,13 +153,21 @@ export class NukeExecution implements Execution {
}
}
- // after sending a nuke set the missilesilo on cooldown
- const silo = this.player
- .units(UnitType.MissileSilo)
- .find((silo) => silo.tile() === spawn);
- if (silo) {
- silo.launch();
+ // after sending a nuke set the launcher on cooldown
+ const launcher = this.player
+ .units()
+ .find((unit) => unit.tile() === spawn);
+ if (launcher) {
+ launcher.launch();
}
+
+ if (
+ this.nuke.type() === UnitType.AtomBomb ||
+ this.nuke.type() === UnitType.HydrogenBomb
+ ) {
+ this.eligibleCities = findEligibleCitiesForNuke(this.nuke, this.mg);
+ }
+
return;
}
@@ -178,6 +191,28 @@ export class NukeExecution implements Execution {
} else {
this.updateNukeTargetable();
this.nuke.move(nextTile);
+
+ if (this.nuke === null || this.nuke.targetedBySAM()) return;
+
+ const currentNuke = this.nuke;
+ const readyInterceptors = this.eligibleCities.filter(
+ (city) =>
+ !this.mg.isCitySamOnCooldown(city.id()) &&
+ this.mg.euclideanDistSquared(currentNuke.tile(), city.tile()) <=
+ this.mg.config().citySamLaunchRange() *
+ this.mg.config().citySamLaunchRange(),
+ );
+
+ if (readyInterceptors.length > 0) {
+ readyInterceptors.sort(
+ (a, b) =>
+ this.mg.euclideanDistSquared(currentNuke.tile(), a.tile()) -
+ this.mg.euclideanDistSquared(currentNuke.tile(), b.tile()),
+ );
+
+ const closestInterceptor = readyInterceptors[0];
+ attemptInterception(currentNuke, this.mg, closestInterceptor);
+ }
}
}
diff --git a/src/core/execution/ParatrooperAttackExecution.ts b/src/core/execution/ParatrooperAttackExecution.ts
new file mode 100644
index 000000000..4b76f89c3
--- /dev/null
+++ b/src/core/execution/ParatrooperAttackExecution.ts
@@ -0,0 +1,233 @@
+import {
+ Execution,
+ Game,
+ MessageType,
+ Player,
+ PlayerType,
+ Unit,
+ UnitType,
+ UpgradeType,
+} from "../game/Game";
+
+import { TileRef } from "../game/GameMap";
+import { StraightPathFinder } from "../pathfinding/PathFinding";
+import { AttackExecution } from "./AttackExecution";
+import {
+ attemptInterception,
+ findEligibleCitiesForBomber,
+} from "./utils/CityAntiAirUtils";
+
+export class ParatrooperAttackExecution implements Execution {
+ private paratrooperUnitID: number | null = null;
+ private pathFinder: StraightPathFinder | null = null;
+ private currentPathIndex: number = 0;
+ private troops: number;
+ private dst: TileRef;
+ private targetPlayerID: string | null;
+ private attacker: Player;
+ private mg: Game; // Add this line
+ private eligibleCities: Unit[] = [];
+
+ constructor(
+ attacker: Player,
+ targetPlayerID: string | null,
+ troops: number,
+ dst: TileRef,
+ ) {
+ this.attacker = attacker;
+ this.targetPlayerID = targetPlayerID;
+ this.troops = troops;
+ this.dst = dst;
+ }
+
+ isActive(): boolean {
+ return this.paratrooperUnitID !== null;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+
+ init(game: Game, ticks: number): void {
+ this.mg = game;
+
+ if (!this.attacker.hasUpgrade(UpgradeType.AirUpgrade1)) {
+ return;
+ }
+
+ const target = this.targetPlayerID
+ ? game.player(this.targetPlayerID)
+ : game.terraNullius();
+ const isPeaceTimerActive =
+ game.peaceTimerEndsAtTick !== null &&
+ game.ticks() < game.peaceTimerEndsAtTick;
+
+ if (isPeaceTimerActive && target.isPlayer()) {
+ const attackerType = this.attacker.type();
+ const defenderType = target.type();
+
+ if (
+ (attackerType === PlayerType.Human ||
+ attackerType === PlayerType.FakeHuman) &&
+ (defenderType === PlayerType.Human ||
+ defenderType === PlayerType.FakeHuman)
+ ) {
+ return;
+ }
+ }
+
+ const airfields = this.attacker.units(UnitType.Airfield);
+ if (airfields.length === 0) {
+ console.warn("No airfields available to launch paratrooper attack.");
+ return;
+ }
+
+ // Find the closest airfield to the destination
+ let closestAirfield: TileRef | null = null;
+ let minDistance = Infinity;
+
+ for (const airfield of airfields) {
+ const airfieldTile = airfield.tile();
+ const distance = game.manhattanDist(airfieldTile, this.dst);
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestAirfield = airfieldTile;
+ }
+ }
+
+ if (closestAirfield === null) {
+ console.warn(
+ "Could not find a suitable airfield for paratrooper attack.",
+ );
+ return;
+ }
+
+ if (minDistance > game.config().paratrooperMaxRange()) {
+ console.warn("Destination is out of range for paratrooper attack.");
+ return;
+ }
+
+ if (this.troops <= 0 || this.troops > this.attacker.troops()) {
+ console.warn("Invalid number of troops for paratrooper attack.");
+ return;
+ }
+
+ const troopCost = Math.floor(
+ this.troops * game.config().paratrooperTroopCostPercentage(),
+ );
+
+ this.troops -= troopCost;
+
+ if (this.troops <= 0) {
+ console.warn(
+ "Not enough troops to send after deducting paratrooper cost.",
+ );
+ return;
+ }
+
+ if (
+ this.attacker.units(UnitType.Paratrooper).length >=
+ game.config().paratrooperMaxNumber()
+ ) {
+ game.displayMessage(
+ "Maximum number of active paratrooper units reached.",
+ MessageType.WARN,
+ this.attacker.id(),
+ );
+ return;
+ }
+
+ // Spawn the paratrooper unit
+ const paratrooper = this.attacker.buildUnit(
+ UnitType.Paratrooper,
+ closestAirfield,
+ { troops: this.troops, targetTile: this.dst },
+ );
+ this.paratrooperUnitID = paratrooper.id();
+ this.eligibleCities = findEligibleCitiesForBomber(paratrooper, game);
+
+ // Initialize pathfinder
+ this.pathFinder = new StraightPathFinder(this.mg.map());
+
+ game.displayMessage(
+ `Incoming Paratrooper Attack from ${this.attacker.displayName()}`,
+ MessageType.PARATROOPER_INBOUND,
+ this.targetPlayerID,
+ );
+
+ game.stats().paratrooperAttack(this.attacker, this.troops);
+ }
+
+ tick(ticks: number): void {
+ const game = this.mg;
+ if (this.paratrooperUnitID === null) {
+ return;
+ }
+
+ const paratrooper = game
+ .units(UnitType.Paratrooper)
+ .find((u) => u.id() === this.paratrooperUnitID);
+
+ if (!paratrooper || !paratrooper.isActive()) {
+ this.paratrooperUnitID = null; // Unit was destroyed or became inactive
+ return;
+ }
+
+ if (paratrooper.targetedBySAM()) return;
+
+ const readyInterceptors = this.eligibleCities.filter(
+ (city) =>
+ !game.isCitySamOnCooldown(city.id()) &&
+ game.euclideanDistSquared(paratrooper.tile(), city.tile()) <=
+ game.config().citySamLaunchRange() *
+ game.config().citySamLaunchRange(),
+ );
+
+ if (readyInterceptors.length > 0) {
+ readyInterceptors.sort(
+ (a, b) =>
+ game.euclideanDistSquared(paratrooper.tile(), a.tile()) -
+ game.euclideanDistSquared(paratrooper.tile(), b.tile()),
+ );
+
+ const closestInterceptor = readyInterceptors[0];
+ attemptInterception(paratrooper, game, closestInterceptor);
+ }
+
+ if (this.pathFinder === null) {
+ // This should not happen if init was successful
+ this.paratrooperUnitID = null;
+ return;
+ }
+
+ const speed = game.config().paratrooperSpeed();
+ let currentTile = paratrooper.tile();
+ for (let i = 0; i < speed; i++) {
+ const nextTileResult = this.pathFinder.nextTile(currentTile, this.dst, 1);
+ if (nextTileResult === true) {
+ // Paratrooper reached destination
+ const targetOwner = game.owner(this.dst);
+ if (targetOwner === this.attacker) {
+ // Landed on own territory, add troops to tile
+ this.attacker.addTroops(paratrooper.troops());
+ } else {
+ // Initiate AttackExecution
+ const attackExecution = new AttackExecution(
+ paratrooper.troops(),
+ this.attacker,
+ targetOwner.id(),
+ this.dst,
+ false, // Do not remove troops from attacker, as they are from the paratrooper
+ );
+ game.addExecution(attackExecution);
+ }
+ paratrooper.delete(false);
+
+ return;
+ } else {
+ currentTile = nextTileResult;
+ paratrooper.move(currentTile);
+ }
+ }
+ }
+}
diff --git a/src/core/execution/ParatrooperRetreatExecution.ts b/src/core/execution/ParatrooperRetreatExecution.ts
new file mode 100644
index 000000000..e4638b1c9
--- /dev/null
+++ b/src/core/execution/ParatrooperRetreatExecution.ts
@@ -0,0 +1,34 @@
+import { Execution, Game, Player, UnitType } from "../game/Game";
+
+export class ParatrooperRetreatExecution implements Execution {
+ private active = true;
+
+ constructor(
+ private player: Player,
+ private unitID: number,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ const unit = this.player.units().find((u) => u.id() === this.unitID);
+ if (unit && unit.type() === UnitType.Paratrooper) {
+ unit.delete();
+ }
+ this.active = false;
+ }
+
+ tick(ticks: number): void {
+ // No ongoing tick logic needed, as the unit is deleted in init
+ }
+
+ owner(): Player {
+ return this.player;
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts
index b8b61e521..fbc6ddd47 100644
--- a/src/core/execution/PlayerExecution.ts
+++ b/src/core/execution/PlayerExecution.ts
@@ -1,25 +1,18 @@
-import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import {
Execution,
Game,
- MessageType,
Player,
PlayerType,
UnitType,
UpgradeType,
} from "../game/Game";
-import { GameImpl } from "../game/GameImpl";
-import { GameMap, TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { getTechNodes, isTechAvailable } from "../tech/ResearchTree";
-import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
+import { simpleHash } from "../Util";
export class PlayerExecution implements Execution {
- private readonly ticksPerClusterCalc = 20;
-
private config: Config;
- private lastCalc = 0;
private mg: Game;
private active = true;
private random: PseudoRandom | null = null;
@@ -35,9 +28,6 @@ export class PlayerExecution implements Execution {
init(mg: Game, ticks: number) {
this.mg = mg;
this.config = mg.config();
- this.lastCalc =
- ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
- // Seed RNG for per-player deterministic-ish behavior
this.random = new PseudoRandom(ticks + simpleHash(this.player.id()));
}
@@ -146,19 +136,6 @@ export class PlayerExecution implements Execution {
u.modifyHealth(0.5);
}
});
-
- if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
- if (this.player.lastTileChange() > this.lastCalc) {
- this.lastCalc = ticks;
- const start = performance.now();
- this.removeClusters();
- const end = performance.now();
- if (end - start > 1000) {
- console.log(`player ${this.player.name()}, took ${end - start}ms`);
- }
- }
- }
-
// --- Research system per-tick processing ---
this.tickResearch();
}
@@ -327,201 +304,6 @@ export class PlayerExecution implements Execution {
}
}
- private removeClusters() {
- const clusters = this.calculateClusters();
- clusters.sort((a, b) => b.size - a.size);
-
- const main = clusters.shift();
- if (main === undefined) throw new Error("No clusters");
- this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
- const surroundedBy = this.surroundedBySamePlayer(main);
- if (surroundedBy && !this.player.isFriendly(surroundedBy)) {
- this.removeCluster(main);
- }
-
- for (const cluster of clusters) {
- if (this.isSurrounded(cluster)) {
- this.removeCluster(cluster);
- }
- }
- }
-
- private surroundedBySamePlayer(cluster: Set): false | Player {
- const enemies = new Set();
- for (const tile of cluster) {
- const isOceanShore = this.mg.isOceanShore(tile);
- if (this.mg.isOceanShore(tile) && !isOceanShore) {
- continue;
- }
- if (
- isOceanShore ||
- this.mg.isOnEdgeOfMap(tile) ||
- this.mg.neighbors(tile).some((n) => !this.mg?.hasOwner(n))
- ) {
- return false;
- }
- this.mg
- .neighbors(tile)
- .filter((n) => this.mg?.ownerID(n) !== this.player?.smallID())
- .forEach((p) => this.mg && enemies.add(this.mg.ownerID(p)));
- if (enemies.size !== 1) {
- return false;
- }
- }
- if (enemies.size !== 1) {
- return false;
- }
- const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
- const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
- const clusterBox = calculateBoundingBox(this.mg, cluster);
- if (inscribed(enemyBox, clusterBox)) {
- return enemy;
- }
- return false;
- }
-
- private isSurrounded(cluster: Set): boolean {
- const enemyTiles = new Set();
- for (const tr of cluster) {
- if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
- return false;
- }
- this.mg
- .neighbors(tr)
- .filter(
- (n) =>
- this.mg?.owner(n).isPlayer() &&
- this.mg?.ownerID(n) !== this.player?.smallID(),
- )
- .forEach((n) => enemyTiles.add(n));
- }
- if (enemyTiles.size === 0) {
- return false;
- }
- const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
- const clusterBox = calculateBoundingBox(this.mg, cluster);
- return inscribed(enemyBox, clusterBox);
- }
-
- private removeCluster(cluster: Set) {
- if (
- Array.from(cluster).some(
- (t) => this.mg?.ownerID(t) !== this.player?.smallID(),
- )
- ) {
- // Other removeCluster operations could change tile owners,
- // so double check.
- return;
- }
-
- const capturing = this.getCapturingPlayer(cluster);
- if (capturing === null) {
- return;
- }
-
- const firstTile = cluster.values().next().value;
- if (!firstTile) {
- return;
- }
-
- const filter = (_: GameMap, t: TileRef): boolean =>
- this.mg?.ownerID(t) === this.player?.smallID();
- const tiles = this.mg.bfs(firstTile, filter);
-
- if (this.player.numTilesOwned() === tiles.size) {
- const gold = this.player.gold();
- this.mg.displayMessage(
- `Conquered ${this.player.displayName()} received ${renderNumber(
- gold,
- )} gold`,
- MessageType.CONQUERED_PLAYER,
- capturing.id(),
- gold,
- );
- capturing.addGold(gold);
- this.player.removeGold(gold);
-
- // Record stats
- this.mg.stats().goldWar(capturing, this.player, gold);
- }
-
- for (const tile of tiles) {
- capturing.conquer(tile);
- }
- }
-
- private getCapturingPlayer(cluster: Set): Player | null {
- const neighborsIDs = new Set();
- for (const t of cluster) {
- for (const neighbor of this.mg.neighbors(t)) {
- if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
- neighborsIDs.add(this.mg.ownerID(neighbor));
- }
- }
- }
-
- let largestNeighborAttack: Player | null = null;
- let largestTroopCount: number = 0;
- for (const id of neighborsIDs) {
- const neighbor = this.mg.playerBySmallID(id);
- if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
- continue;
- }
- for (const attack of neighbor.outgoingAttacks()) {
- if (attack.target() === this.player) {
- if (attack.troops() > largestTroopCount) {
- largestTroopCount = attack.troops();
- largestNeighborAttack = neighbor;
- }
- }
- }
- }
- if (largestNeighborAttack !== null) {
- return largestNeighborAttack;
- }
-
- // fall back to getting mode if no attacks
- const mode = getMode(neighborsIDs);
- if (!this.mg.playerBySmallID(mode).isPlayer()) {
- return null;
- }
- const capturing = this.mg.playerBySmallID(mode);
- if (!capturing.isPlayer()) {
- return null;
- }
- return capturing;
- }
-
- private calculateClusters(): Set[] {
- const seen = new Set();
- const border = this.player.borderTiles();
- const clusters: Set[] = [];
- for (const tile of border) {
- if (seen.has(tile)) {
- continue;
- }
-
- const cluster = new Set();
- const queue: TileRef[] = [tile];
- seen.add(tile);
- while (queue.length > 0) {
- const curr = queue.shift();
- if (curr === undefined) throw new Error("curr is undefined");
- cluster.add(curr);
-
- const neighbors = (this.mg as GameImpl).neighborsWithDiag(curr);
- for (const neighbor of neighbors) {
- if (border.has(neighbor) && !seen.has(neighbor)) {
- queue.push(neighbor);
- seen.add(neighbor);
- }
- }
- }
- clusters.push(cluster);
- }
- return clusters;
- }
-
owner(): Player {
if (this.player === null) {
throw new Error("Not initialized");
diff --git a/src/core/execution/PurchaseUpgradeExecution.ts b/src/core/execution/PurchaseUpgradeExecution.ts
index af892302e..b9057ddab 100644
--- a/src/core/execution/PurchaseUpgradeExecution.ts
+++ b/src/core/execution/PurchaseUpgradeExecution.ts
@@ -1,4 +1,4 @@
-import { Execution, Player, UpgradeType } from "../game/Game";
+import { Execution, Player, UnitType, UpgradeType } from "../game/Game";
import { GameImpl } from "../game/GameImpl";
import { RESEARCH_TECH_IDS } from "../tech/TechEffects";
@@ -34,7 +34,7 @@ export class PurchaseUpgradeExecution implements Execution {
return true;
}
- public init(mg: GameImpl, ticks: number): void {
+ init(mg: GameImpl, ticks: number): void {
this.mg = mg;
if (this.player.hasUpgrade(this.upgrade)) {
this._isActive = false;
@@ -56,6 +56,13 @@ export class PurchaseUpgradeExecution implements Execution {
return;
}
+ if (this.upgrade === UpgradeType.SubmarineResearch) {
+ if (this.player.unitCount(UnitType.Port) === 0) {
+ this._isActive = false;
+ return;
+ }
+ }
+
const cost = this.mg.config().upgradeInfo(this.upgrade).cost(this.player);
if (this.player.gold() >= cost) {
this.player.removeGold(cost);
diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts
index 0e32f2ade..fe4a7e2fa 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -227,7 +227,12 @@ export class SAMLauncherExecution implements Execution {
const potentialAirborneTargets = this.mg.nearbyUnits(
this.sam!.tile(),
this.cargoPlaneSearchRadius,
- [UnitType.CargoPlane, UnitType.Bomber, UnitType.FighterJet],
+ [
+ UnitType.CargoPlane,
+ UnitType.Bomber,
+ UnitType.FighterJet,
+ UnitType.Paratrooper,
+ ],
);
if (!this.sam) return;
diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts
index d2f52d431..e35e4faa5 100644
--- a/src/core/execution/SAMMissileExecution.ts
+++ b/src/core/execution/SAMMissileExecution.ts
@@ -48,6 +48,7 @@ export class SAMMissileExecution implements Execution {
UnitType.CargoPlane,
UnitType.Bomber,
UnitType.FighterJet,
+ UnitType.Paratrooper,
];
if (
diff --git a/src/core/execution/SubmarineExecution.ts b/src/core/execution/SubmarineExecution.ts
new file mode 100644
index 000000000..6d6e1f1b1
--- /dev/null
+++ b/src/core/execution/SubmarineExecution.ts
@@ -0,0 +1,289 @@
+import {
+ Execution,
+ Game,
+ isUnit,
+ OwnerComp,
+ Unit,
+ UnitParams,
+ UnitType,
+} from "../game/Game";
+import { TileRef } from "../game/GameMap";
+import { PathFindResultType } from "../pathfinding/AStar";
+import { PathFinder } from "../pathfinding/PathFinding";
+import { PseudoRandom } from "../PseudoRandom";
+import { ShellExecution } from "./ShellExecution";
+
+export class SubmarineExecution implements Execution {
+ private random: PseudoRandom;
+ private submarine: Unit;
+ private mg: Game;
+ private pathfinder: PathFinder;
+ private lastShellAttack = 0;
+ private alreadySentShell = new Set();
+
+ constructor(
+ private input: (UnitParams & OwnerComp) | Unit,
+ ) {}
+
+ init(mg: Game, ticks: number): void {
+ this.mg = mg;
+ this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
+ this.random = new PseudoRandom(mg.ticks());
+ if (isUnit(this.input)) {
+ this.submarine = this.input;
+ } else {
+ const spawn = this.input.owner.canBuild(
+ UnitType.Submarine,
+ this.input.patrolTile,
+ );
+ if (spawn === false) {
+ console.warn(
+ `Failed to spawn submarine for ${this.input.owner.name()} at ${this.input.patrolTile}`,
+ );
+ return;
+ }
+ this.submarine = this.input.owner.buildUnit(UnitType.Submarine, spawn, {
+ patrolTile: this.input.patrolTile,
+ });
+ }
+ }
+
+ tick(ticks: number) {
+ if (this.submarine.health() <= 0) {
+ this.submarine.delete();
+ return;
+ }
+
+ this.updateDetectionState();
+ this.submarine.isAttacking = false;
+
+ const hasPort = this.submarine.owner().unitCount(UnitType.Port) > 0;
+ if (hasPort) {
+ this.submarine.modifyHealth(1);
+ }
+
+ this.submarine.setTargetUnit(this.findTargetUnit());
+
+ this.patrol();
+
+ if (this.submarine.targetUnit() !== undefined) {
+ this.submarine.isAttacking = true;
+ this.submarine.touch();
+ this.shootTarget();
+ return;
+ }
+ }
+
+ private updateDetectionState(): void {
+ const nearbyNavalUnits = this.mg.nearbyUnits(
+ this.submarine.tile()!,
+ this.mg.config().warshipTargettingRange(), // Using warship's range for detection
+ [UnitType.Warship, UnitType.Submarine],
+ ({ unit }) =>
+ unit.owner() !== this.submarine.owner() &&
+ !unit.owner().isFriendly(this.submarine.owner() as any),
+ );
+
+ if (nearbyNavalUnits.length > 0) {
+ this.submarine.isDetectedByNavalUnit = true;
+ } else {
+ this.submarine.isDetectedByNavalUnit = false;
+ }
+ }
+
+ private findTargetUnit(): Unit | undefined {
+ const hasPort = this.submarine.owner().unitCount(UnitType.Port) > 0;
+ const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
+
+ const ships = this.mg.nearbyUnits(
+ this.submarine.tile()!,
+ this.mg.config().warshipTargettingRange(),
+ [
+ UnitType.TransportShip,
+ UnitType.Warship,
+ UnitType.Submarine,
+ UnitType.TradeShip,
+ ],
+ );
+ const potentialTargets: { unit: Unit; distSquared: number }[] = [];
+ for (const { unit, distSquared } of ships) {
+ if (
+ unit.owner() === this.submarine.owner() ||
+ unit === this.submarine ||
+ unit.owner().isFriendly(this.submarine.owner() as any) ||
+ this.alreadySentShell.has(unit)
+ ) {
+ continue;
+ }
+ // Only engage if at war with the target's owner
+ if (!this.submarine.owner().isAtWarWith(unit.owner())) {
+ continue;
+ }
+ if (unit.type() === UnitType.TradeShip) {
+ if (
+ !hasPort ||
+ unit.isSafeFromPirates() ||
+ unit.targetUnit()?.owner() === this.submarine.owner() || // trade ship is coming to my port
+ unit
+ .targetUnit()
+ ?.owner()
+ .isFriendly(this.submarine.owner() as any) // trade ship is coming to my ally
+ ) {
+ continue;
+ }
+ if (
+ this.mg.euclideanDistSquared(
+ this.submarine.patrolTile()!,
+ unit.tile(),
+ ) > patrolRangeSquared
+ ) {
+ // Prevent warship from chasing trade ship that is too far away from
+ // the patrol tile to prevent warships from wandering around the map.
+ continue;
+ }
+ }
+ potentialTargets.push({ unit: unit, distSquared });
+ }
+
+ return potentialTargets.sort((a, b) => {
+ const { unit: unitA, distSquared: distA } = a;
+ const { unit: unitB, distSquared: distB } = b;
+
+ // Prioritize Warships
+ if (
+ unitA.type() === UnitType.Warship &&
+ unitB.type() !== UnitType.Warship
+ )
+ return -1;
+ if (
+ unitA.type() !== UnitType.Warship &&
+ unitB.type() === UnitType.Warship
+ )
+ return 1;
+
+ // Then favor Transport Ships over Trade Ships
+ if (
+ unitA.type() === UnitType.TransportShip &&
+ unitB.type() !== UnitType.TransportShip
+ )
+ return -1;
+ if (
+ unitA.type() !== UnitType.TransportShip &&
+ unitB.type() === UnitType.TransportShip
+ )
+ return 1;
+
+ // If both are the same type, sort by distance (lower `distSquared` means closer)
+ return distA - distB;
+ })[0]?.unit;
+ }
+
+ private shootTarget() {
+ const isPeaceTimerActive =
+ this.mg.peaceTimerEndsAtTick !== null &&
+ this.mg.ticks() < this.mg.peaceTimerEndsAtTick;
+
+ if (isPeaceTimerActive) {
+ this.submarine.setTargetUnit(undefined);
+ return; // Block attack
+ }
+
+ const shellAttackRate = this.mg.config().warshipShellAttackRate();
+ if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
+ this.lastShellAttack = this.mg.ticks();
+ this.mg.addExecution(
+ new ShellExecution(
+ this.submarine.tile(),
+ this.submarine.owner(),
+ this.submarine,
+ this.submarine.targetUnit()!,
+ ),
+ );
+ if (!this.submarine.targetUnit()!.hasHealth()) {
+ // Don't send multiple shells to target that can be oneshotted
+ this.alreadySentShell.add(this.submarine.targetUnit()!);
+ this.submarine.setTargetUnit(undefined);
+ return;
+ }
+ }
+ }
+
+ private patrol() {
+ if (this.submarine.targetTile() === undefined) {
+ this.submarine.setTargetTile(this.randomTile());
+ if (this.submarine.targetTile() === undefined) {
+ return;
+ }
+ }
+
+ const result = this.pathfinder.nextTile(
+ this.submarine.tile(),
+ this.submarine.targetTile()!,
+ );
+ switch (result.type) {
+ case PathFindResultType.Completed:
+ this.submarine.setTargetTile(undefined);
+ this.submarine.move(result.node);
+ break;
+ case PathFindResultType.NextTile:
+ this.submarine.move(result.node);
+ break;
+ case PathFindResultType.Pending:
+ this.submarine.touch();
+ return;
+ case PathFindResultType.PathNotFound:
+ console.warn(`path not found to target tile`);
+ this.submarine.setTargetTile(undefined);
+ break;
+ }
+ }
+
+ isActive(): boolean {
+ return this.submarine?.isActive();
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+
+ randomTile(allowShoreline: boolean = false): TileRef | undefined {
+ let warshipPatrolRange = this.mg.config().warshipPatrolRange();
+ const maxAttemptBeforeExpand: number = 500;
+ let attempts: number = 0;
+ let expandCount: number = 0;
+ while (expandCount < 3) {
+ const x =
+ this.mg.x(this.submarine.patrolTile()!) +
+ this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
+ const y =
+ this.mg.y(this.submarine.patrolTile()!) +
+ this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
+ if (!this.mg.isValidCoord(x, y)) {
+ continue;
+ }
+ const tile = this.mg.ref(x, y);
+ if (
+ !this.mg.isOcean(tile) ||
+ (!allowShoreline && this.mg.isShoreline(tile))
+ ) {
+ attempts++;
+ if (attempts === maxAttemptBeforeExpand) {
+ expandCount++;
+ attempts = 0;
+ warshipPatrolRange =
+ warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
+ }
+ continue;
+ }
+ return tile;
+ }
+ console.warn(
+ `Failed to find random tile for warship for ${this.submarine.owner().name()}`,
+ );
+ if (!allowShoreline) {
+ // If we failed to find a tile on the ocean, try again but allow shoreline
+ return this.randomTile(true);
+ }
+ return undefined;
+ }
+}
diff --git a/src/core/execution/UnitCreationHelper.ts b/src/core/execution/UnitCreationHelper.ts
index 6a5cd992a..cef394386 100644
--- a/src/core/execution/UnitCreationHelper.ts
+++ b/src/core/execution/UnitCreationHelper.ts
@@ -83,7 +83,7 @@ export class UnitCreationHelper {
return (
this.maybeSpawnStructure(UnitType.Airfield, 1) ||
- this.maybeSpawnWarship() ||
+ this.maybeSpawnNavalUnit() ||
this.maybeSpawnSAMLauncher() ||
this.maybeSpawnStructure(UnitType.MissileSilo, 1) ||
this.maybeSpawnDefensePost()
@@ -228,36 +228,38 @@ export class UnitCreationHelper {
return null;
}
- private maybeSpawnWarship(): boolean {
- if (!this.random.chance(50)) {
- return false;
- }
+ private maybeSpawnNavalUnit(): boolean {
+ const warshipCount = this.player.units(UnitType.Warship).length;
+ const submarineCount = this.player.units(UnitType.Submarine).length;
+ const navalCombatUnitCount = warshipCount + submarineCount;
+
const ports = this.player.units(UnitType.Port);
- const ships = this.player.units(UnitType.Warship);
- if (
- ports.length > 0 &&
- ships.length === 0 &&
- this.player.gold() > this.cost(UnitType.Warship)
- ) {
- const port = this.random.randElement(ports);
- const targetTile = this.warshipSpawnTile(port.tile());
- if (targetTile === null) {
- return false;
- }
- const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
- if (canBuild === false) {
- console.warn("cannot spawn destroyer");
- return false;
+ if (ports.length > 0 && navalCombatUnitCount === 0) {
+ const unitToBuild = this.random.chance(50)
+ ? UnitType.Submarine
+ : UnitType.Warship;
+
+ if (this.player.gold() > this.cost(unitToBuild)) {
+ const port = this.random.randElement(ports);
+ const targetTile = this.navalUnitSpawnTile(port.tile());
+ if (targetTile === null) {
+ return false;
+ }
+ const canBuild = this.player.canBuild(unitToBuild, targetTile);
+ if (canBuild === false) {
+ console.warn(`cannot spawn ${unitToBuild}`);
+ return false;
+ }
+ this.mg.addExecution(
+ new ConstructionExecution(this.player, unitToBuild, targetTile),
+ );
+ return true;
}
- this.mg.addExecution(
- new ConstructionExecution(this.player, UnitType.Warship, targetTile),
- );
- return true;
}
return false;
}
- private warshipSpawnTile(portTile: TileRef): TileRef | null {
+ private navalUnitSpawnTile(portTile: TileRef): TileRef | null {
const radius = 250;
for (let attempts = 0; attempts < 50; attempts++) {
const randX = this.random.nextInt(
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index 6f2764d77..616525527 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -6,11 +6,13 @@ import {
Unit,
UnitParams,
UnitType,
+ UpgradeType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
+import { SAMMissileExecution } from "./SAMMissileExecution";
import { ShellExecution } from "./ShellExecution";
export class WarshipExecution implements Execution {
@@ -20,6 +22,9 @@ export class WarshipExecution implements Execution {
private pathfinder: PathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set();
+ private nextAAScanTick = 0;
+ private nextAAMissileFireTick = 0;
+ private pseudoRandom: PseudoRandom;
constructor(
private input: (UnitParams & OwnerComp) | Unit,
@@ -46,6 +51,7 @@ export class WarshipExecution implements Execution {
patrolTile: this.input.patrolTile,
});
}
+ this.pseudoRandom = new PseudoRandom(this.warship.id());
}
tick(ticks: number): void {
@@ -58,6 +64,8 @@ export class WarshipExecution implements Execution {
this.warship.modifyHealth(1);
}
+ this.scanAndEngageAircraft();
+
this.warship.setTargetUnit(this.findTargetUnit());
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
@@ -79,7 +87,12 @@ export class WarshipExecution implements Execution {
const ships = this.mg.nearbyUnits(
this.warship.tile()!,
this.mg.config().warshipTargettingRange(),
- [UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
+ [
+ UnitType.TransportShip,
+ UnitType.Warship,
+ UnitType.TradeShip,
+ UnitType.Submarine,
+ ],
);
const potentialTargets: { unit: Unit; distSquared: number }[] = [];
for (const { unit, distSquared } of ships) {
@@ -115,6 +128,15 @@ export class WarshipExecution implements Execution {
continue;
}
}
+ if (unit.type() === UnitType.Submarine) {
+ const isVisible =
+ (unit.isAttacking ?? false) ||
+ (unit.isDetectedByNavalUnit ?? false) ||
+ this.mg.ticks() - (unit.lastVisibleTick ?? -Infinity) < 30;
+ if (!isVisible) {
+ continue; // Don't target stealthed submarines
+ }
+ }
potentialTargets.push({ unit: unit, distSquared });
}
@@ -122,7 +144,19 @@ export class WarshipExecution implements Execution {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
- // Prioritize Warships
+ // Prioritize Submarines
+ if (
+ unitA.type() === UnitType.Submarine &&
+ unitB.type() !== UnitType.Submarine
+ )
+ return -1;
+ if (
+ unitA.type() !== UnitType.Submarine &&
+ unitB.type() === UnitType.Submarine
+ )
+ return 1;
+
+ // Then Warships
if (
unitA.type() === UnitType.Warship &&
unitB.type() !== UnitType.Warship
@@ -296,4 +330,85 @@ export class WarshipExecution implements Execution {
}
return undefined;
}
+
+ private scanAndEngageAircraft(): void {
+ // Guard Clause: Check for the upgrade first.
+ if (!this.warship.owner().hasUpgrade(UpgradeType.WarshipAntiAir)) {
+ return;
+ }
+
+ // Throttling: Only scan periodically to save performance.
+ if (this.mg.ticks() < this.nextAAScanTick) {
+ return;
+ }
+ this.nextAAScanTick =
+ this.mg.ticks() + this.mg.config().warshipAAScanInterval();
+
+ // Target Scan & Squared Distance: Use squared values to avoid expensive sqrt operations.
+ const rangeSq = this.mg.config().warshipAARange() ** 2;
+ const nearbyAircraft = this.mg.nearbyUnits(
+ this.warship.tile(),
+ this.mg.config().warshipAARange(),
+ [
+ UnitType.Bomber,
+ UnitType.FighterJet,
+ UnitType.CargoPlane,
+ UnitType.Paratrooper,
+ ],
+ ({ unit, distSquared }) =>
+ !unit.owner().isFriendly(this.warship.owner()) &&
+ !unit.targetedBySAM() &&
+ distSquared <= rangeSq,
+ );
+
+ if (nearbyAircraft.length === 0) {
+ return;
+ }
+
+ // Optimized Prioritization (No Sorting): Loop once to find the best target.
+ const priority = {
+ [UnitType.Paratrooper]: 1,
+ [UnitType.Bomber]: 2,
+ [UnitType.FighterJet]: 3,
+ [UnitType.CargoPlane]: 4,
+ };
+ let bestTarget: Unit | null = null;
+ let bestPriority = 4; // Start with a value higher than any valid priority
+
+ for (const { unit } of nearbyAircraft) {
+ const unitPriority = priority[unit.type()];
+ if (unitPriority < bestPriority) {
+ bestPriority = unitPriority;
+ bestTarget = unit;
+ }
+ }
+
+ // Firing Logic (Decoupled Cooldown)
+ if (bestTarget) {
+ if (this.mg.ticks() < this.nextAAMissileFireTick) {
+ return;
+ }
+
+ const healthPercent =
+ this.warship.health() / (this.warship.info().maxHealth ?? 1);
+ const hit =
+ this.pseudoRandom.next() <
+ this.mg.config().warshipAAHittingChance() * healthPercent;
+
+ if (hit) {
+ this.mg.addExecution(
+ new SAMMissileExecution(
+ this.warship.tile(),
+ this.warship.owner(),
+ this.warship,
+ bestTarget,
+ ),
+ );
+ bestTarget.setTargetedBySAM(true);
+ }
+
+ this.nextAAMissileFireTick =
+ this.mg.ticks() + this.mg.config().warshipAACooldown();
+ }
+ }
}
diff --git a/src/core/execution/utils/CityAntiAirUtils.ts b/src/core/execution/utils/CityAntiAirUtils.ts
new file mode 100644
index 000000000..0be6a4b3e
--- /dev/null
+++ b/src/core/execution/utils/CityAntiAirUtils.ts
@@ -0,0 +1,61 @@
+import { Game, Player, Unit, UnitType, UpgradeType } from "../../game/Game";
+import { SAMMissileExecution } from "../SAMMissileExecution";
+
+/**
+ * Finds all enemy cities with the CityAntiAir upgrade within the nuke's blast radius.
+ */
+export function findEligibleCitiesForNuke(nuke: Unit, game: Game): Unit[] {
+ const nukeOwner = nuke.owner();
+ const blastRadius = game.config().nukeMagnitudes(nuke.type()).outer;
+
+ return game
+ .nearbyUnits(nuke.targetTile()!, blastRadius, UnitType.City, ({ unit }) => {
+ const cityOwner = unit.owner();
+ return (
+ !nukeOwner.isFriendly(cityOwner as Player) &&
+ cityOwner.hasUpgrade(UpgradeType.CityAntiAir)
+ );
+ })
+ .map((result) => result.unit);
+}
+
+/**
+ * Finds all enemy cities with the CityAntiAir upgrade within launch range of a bomber's target.
+ */
+export function findEligibleCitiesForBomber(bomber: Unit, game: Game): Unit[] {
+ const bomberOwner = bomber.owner();
+ const searchRadius = game.config().citySamLaunchRange();
+
+ if (!bomber.targetTile()) {
+ return [];
+ }
+
+ return game
+ .nearbyUnits(
+ bomber.targetTile()!,
+ searchRadius,
+ UnitType.City,
+ ({ unit }) => {
+ const cityOwner = unit.owner();
+ return (
+ !bomberOwner.isFriendly(cityOwner as Player) &&
+ cityOwner.hasUpgrade(UpgradeType.CityAntiAir)
+ );
+ },
+ )
+ .map((result) => result.unit);
+}
+
+/**
+ * Attempts to have a single city intercept an aircraft or nuke.
+ */
+export function attemptInterception(target: Unit, game: Game, city: Unit) {
+ if (!city.isActive() || game.isCitySamOnCooldown(city.id())) {
+ return;
+ }
+
+ target.setTargetedBySAM(true);
+ const sam = new SAMMissileExecution(city.tile(), city.owner(), city, target);
+ game.addExecution(sam);
+ game.setCitySamCooldown(city.id(), game.config().citySamCooldown());
+}
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index ce034c72c..557ae8b10 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -144,6 +144,7 @@ export interface UnitInfo {
export enum UnitType {
TransportShip = "Transport",
Warship = "Warship",
+ Submarine = "Submarine",
Shell = "Shell",
SAMMissile = "SAMMissile",
Port = "Port",
@@ -162,6 +163,7 @@ export enum UnitType {
Airfield = "Air Field",
CargoPlane = "Cargo Plane",
Bomber = "Bomber",
+ Paratrooper = "Paratrooper",
FighterJet = "Fighter Jet", // Represents a Fighter Jet unit.
}
@@ -177,15 +179,20 @@ export enum UpgradeType {
StructureInsurance = "StructureInsurance",
Automation = "Automation",
- // Dummy Water Upgrades
+ // Water Upgrades
+ SubmarineResearch = "SubmarineResearch",
+ NuclearSubmarineResearch = "NuclearSubmarineResearch",
WaterUpgrade1 = "WaterUpgrade1",
WaterUpgrade2 = "WaterUpgrade2",
+ WarshipAntiAir = "WarshipAntiAir",
WaterUpgrade3 = "WaterUpgrade3",
- // Dummy Air Upgrades
+ // Air Upgrades
AirUpgrade1 = "AirUpgrade1",
AirUpgrade2 = "AirUpgrade2",
AirUpgrade3 = "AirUpgrade3",
+ CityAntiAir = "CityAntiAir",
+ FighterJetNavalTargeting = "FighterJetNavalTargeting",
// Dummy Economy Upgrades
EconomyUpgrade1 = "EconomyUpgrade1",
@@ -223,6 +230,10 @@ export interface UnitParamsMap {
patrolTile: TileRef;
};
+ [UnitType.Submarine]: {
+ patrolTile: TileRef;
+ };
+
[UnitType.Shell]: Record;
[UnitType.SAMMissile]: Record;
@@ -272,6 +283,11 @@ export interface UnitParamsMap {
targetTile: TileRef;
};
+ [UnitType.Paratrooper]: {
+ troops?: number;
+ targetTile?: TileRef;
+ };
+
[UnitType.FighterJet]: {
patrolTile: TileRef;
};
@@ -492,6 +508,12 @@ export interface Unit {
// Insurance (structure units)
insure(player: Player | null): void;
+
+ // Submarines
+ lastVisibleTick?: number;
+ isDetectedByNavalUnit?: boolean;
+ isAttacking?: boolean;
+ isPeriodicallyVisible(): boolean;
}
export interface TerraNullius {
@@ -523,7 +545,7 @@ export interface Player {
isAlive(): boolean;
isTraitor(): boolean;
markTraitor(): void;
- largestClusterBoundingBox: { min: Cell; max: Cell } | null;
+
lastTileChange(): Tick;
isDisconnected(): boolean;
@@ -624,7 +646,7 @@ export interface Player {
lastAggressionTick(other: Player): Tick;
isOnSameTeam(other: Player): boolean;
// Either allied or on same team.
- isFriendly(other: Player): boolean;
+ isFriendly(other: Player | PlayerView): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
@@ -742,6 +764,12 @@ export interface Game extends GameMap {
config(): Config;
peaceTimerEndsAtTick: Tick | null;
+ // City SAM Cooldowns
+ citySamCooldowns: Map;
+ setCitySamCooldown(cityId: number, ticks: number): void;
+ isCitySamOnCooldown(cityId: number): boolean;
+ tickCitySamCooldowns(): void;
+
// Units
units(...types: UnitType[]): Unit[];
unitsAt(tile: TileRef): Unit[];
@@ -785,6 +813,7 @@ export interface Game extends GameMap {
// Optional as it's not initialized before the end of spawn phase
stats(): Stats;
bomberExplosion(tile: TileRef, radius: number, owner: Player): void;
+ conquer(newOwner: Player, tile: TileRef): void;
}
export interface PlayerActions {
@@ -838,6 +867,7 @@ export enum MessageType {
NUKE_INBOUND,
HYDROGEN_BOMB_INBOUND,
NAVAL_INVASION_INBOUND,
+ PARATROOPER_INBOUND,
SAM_MISS,
SAM_HIT,
CAPTURED_ENEMY_UNIT,
@@ -880,6 +910,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record = {
[MessageType.NUKE_INBOUND]: MessageCategory.ATTACK,
[MessageType.HYDROGEN_BOMB_INBOUND]: MessageCategory.ATTACK,
[MessageType.NAVAL_INVASION_INBOUND]: MessageCategory.ATTACK,
+ [MessageType.PARATROOPER_INBOUND]: MessageCategory.ATTACK,
[MessageType.SAM_MISS]: MessageCategory.ATTACK,
[MessageType.SAM_HIT]: MessageCategory.ATTACK,
[MessageType.CAPTURED_ENEMY_UNIT]: MessageCategory.ATTACK,
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 2e145dcd2..31e321c72 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -77,7 +77,7 @@ export class GameImpl implements Game {
private nextPlayerID = 1;
private _nextUnitID = 1;
- private updates: GameUpdates = createGameUpdatesMap();
+ private updates: GameUpdates = this.createGameUpdatesMap();
private unitGrid: UnitGrid;
private roadManager: RoadManager;
private _roads = new Map();
@@ -219,6 +219,16 @@ export class GameImpl implements Game {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
}
+ unit(id: number): Unit | undefined {
+ for (const player of this._players.values()) {
+ const unit = player.units().find((u) => u.id() === id);
+ if (unit) {
+ return unit;
+ }
+ }
+ return undefined;
+ }
+
unitCount(type: UnitType): number {
let total = 0;
for (const player of this._players.values()) {
@@ -337,7 +347,8 @@ export class GameImpl implements Game {
}
executeNextTick(): GameUpdates {
- this.updates = createGameUpdatesMap();
+ this.tickCitySamCooldowns();
+ this.updates = this.createGameUpdatesMap();
this.execs.forEach((e) => {
if (
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
@@ -410,6 +421,35 @@ export class GameImpl implements Game {
return hash;
}
+ citySamCooldowns: Map = new Map();
+
+ setCitySamCooldown(cityId: number, ticks: number): void {
+ this.citySamCooldowns.set(cityId, ticks);
+ this.addUpdate({
+ type: GameUpdateType.CitySamCooldown,
+ cityId,
+ cooldown: ticks,
+ });
+ const city = this.unit(cityId);
+ if (city) {
+ city.touch();
+ }
+ }
+
+ isCitySamOnCooldown(cityId: number): boolean {
+ return (this.citySamCooldowns.get(cityId) ?? 0) > 0;
+ }
+
+ tickCitySamCooldowns(): void {
+ for (const [cityId, ticks] of this.citySamCooldowns.entries()) {
+ if (ticks > 0) {
+ this.citySamCooldowns.set(cityId, ticks - 1);
+ } else {
+ this.citySamCooldowns.delete(cityId);
+ }
+ }
+ }
+
terraNullius(): TerraNullius {
return this._terraNullius;
}
@@ -549,25 +589,33 @@ export class GameImpl implements Game {
return ns;
}
- conquer(owner: PlayerImpl, tile: TileRef): void {
+ public conquer(newOwner: Player, tile: TileRef) {
if (!this.isLand(tile)) {
throw Error(`cannot conquer water`);
}
- const previousOwner = this.owner(tile) as TerraNullius | PlayerImpl;
- if (previousOwner.isPlayer()) {
- previousOwner._lastTileChange = this._ticks;
- previousOwner._tiles.delete(tile);
- previousOwner._borderTiles.delete(tile);
- }
- this._map.setOwnerID(tile, owner.smallID());
- owner._tiles.add(tile);
- const numTiles = owner.numTilesOwned();
- owner.setProductivity(
- (owner.productivity() * (numTiles - 1)) / numTiles + 1 / numTiles,
+ const currentOwner = this.owner(tile);
+
+ if (currentOwner.isPlayer()) {
+ (currentOwner as PlayerImpl)._lastTileChange = this._ticks;
+ (currentOwner as PlayerImpl)._tiles.delete(tile);
+ (currentOwner as PlayerImpl)._borderTiles.delete(tile);
+ }
+ this._map.setOwnerID(tile, newOwner.smallID());
+ (newOwner as PlayerImpl)._tiles.add(tile);
+ const numTiles = (newOwner as PlayerImpl).numTilesOwned();
+ (newOwner as PlayerImpl).setProductivity(
+ ((newOwner as PlayerImpl).productivity() * (numTiles - 1)) / numTiles +
+ 1 / numTiles,
);
- owner._lastTileChange = this._ticks;
+ (newOwner as PlayerImpl)._lastTileChange = this._ticks;
this.updateBorders(tile);
this._map.setFallout(tile, false);
+
+ this.addUpdate({
+ type: GameUpdateType.TileOwnerChanged,
+ tile: tile,
+ newOwnerID: newOwner.id(),
+ });
this.addUpdate({
type: GameUpdateType.Tile,
update: this.toTileUpdate(tile),
@@ -978,15 +1026,14 @@ export class GameImpl implements Game {
public markPlayerNodesForReconnection(player: Player): void {
this.roadManager.markPlayerNodesForReconnection(player);
}
-}
-// Or a more dynamic approach that will catch new enum values:
-const createGameUpdatesMap = (): GameUpdates => {
- const map = {} as GameUpdates;
- Object.values(GameUpdateType)
- .filter((key) => !isNaN(Number(key))) // Filter out reverse mappings
- .forEach((key) => {
- map[key as GameUpdateType] = [];
- });
- return map;
-};
+ private createGameUpdatesMap(): GameUpdates {
+ const map = {} as GameUpdates;
+ Object.values(GameUpdateType)
+ .filter((key) => !isNaN(Number(key))) // Filter out reverse mappings
+ .forEach((key) => {
+ map[key as GameUpdateType] = [];
+ });
+ return map;
+ }
+}
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 35ec5bef9..b5eef97e6 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -29,6 +29,7 @@ export interface ErrorUpdate {
}
export enum GameUpdateType {
+ SubmarinePing,
Tile,
Unit,
Player,
@@ -48,6 +49,8 @@ export enum GameUpdateType {
BomberExplosion,
Roads,
CargoTrucks,
+ TileOwnerChanged,
+ CitySamCooldown,
}
export interface SerializedCargoTruck {
@@ -73,7 +76,13 @@ export interface RoadsUpdate {
removed: string[];
}
+export interface SubmarinePingUpdate {
+ type: GameUpdateType.SubmarinePing;
+ unitId: number;
+}
+
export type GameUpdate =
+ | SubmarinePingUpdate
| TileUpdateWrapper
| UnitUpdate
| PlayerUpdate
@@ -91,7 +100,15 @@ export type GameUpdate =
| UnitIncomingUpdate
| BomberExplosionUpdate
| RoadsUpdate
- | CargoTrucksUpdate;
+ | CargoTrucksUpdate
+ | TileOwnerChangedUpdate
+ | CitySamCooldownUpdate;
+
+export interface CitySamCooldownUpdate {
+ type: GameUpdateType.CitySamCooldown;
+ cityId: number;
+ cooldown: number;
+}
export interface BomberExplosionUpdate {
type: GameUpdateType.BomberExplosion;
@@ -126,6 +143,9 @@ export interface UnitUpdate {
ticksLeftInCooldown?: Tick;
returning?: boolean;
cooldownDuration?: Tick;
+ isAttacking?: boolean;
+ isDetectedByNavalUnit?: boolean;
+ targetedBySAM?: boolean;
}
export interface AttackUpdate {
@@ -272,6 +292,12 @@ export interface AllianceExtensionAcceptedUpdate {
allianceID: number;
}
+export interface TileOwnerChangedUpdate {
+ type: GameUpdateType.TileOwnerChanged;
+ tile: TileRef;
+ newOwnerID: PlayerID;
+}
+
export interface AllianceViewData {
requestorID: number;
recipientID: number;
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 7208501d7..1d71d44b8 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -1,4 +1,5 @@
import { PlayerListChangedEvent } from "../../client/events/PlayerListChangedEvent";
+import { UnitCooldownEndedEvent } from "../../client/events/UnitCooldownEndedEvent";
import { SpatialIndex } from "../../client/graphics/SpatialIndex";
import { Config } from "../configuration/Config";
import { EventBus } from "../EventBus";
@@ -32,6 +33,7 @@ import { GameMap, TileRef, TileUpdate } from "./GameMap";
import {
AllianceViewData,
AttackUpdate,
+ CitySamCooldownUpdate,
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
@@ -140,6 +142,18 @@ export class UnitView {
info(): UnitInfo {
return this.gameView.unitInfo(this.type());
}
+
+ isAttacking(): boolean {
+ return this.data.isAttacking ?? false;
+ }
+
+ isDetectedByNavalUnit(): boolean {
+ return this.data.isDetectedByNavalUnit ?? false;
+ }
+
+ targetedBySAM(): boolean {
+ return this.data.targetedBySAM ?? false;
+ }
}
export class PlayerView {
@@ -341,15 +355,15 @@ export class PlayerView {
roadNetPixelsPerSecond(): number {
return this.data.roadNetPixelsPerSecond ?? 0;
}
- isAlliedWith(other: PlayerView): boolean {
+ isAlliedWith(other: Player | PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
- isOnSameTeam(other: PlayerView): boolean {
- return this.data.team !== undefined && this.data.team === other.data.team;
+ isOnSameTeam(other: Player | PlayerView): boolean {
+ return this.data.team !== undefined && this.data.team === other.team();
}
- isFriendly(other: PlayerView): boolean {
+ isFriendly(other: Player | PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
@@ -415,6 +429,8 @@ export class GameView implements GameMap {
private _myPlayer: PlayerView | null = null;
private _focusedPlayer: PlayerView | null = null;
private _alliances: AllianceViewData[] = [];
+ private _submarinePings: Map = new Map();
+ private citySamCooldowns = new Map();
private unitGrid: UnitGrid;
private structureIndex: SpatialIndex;
@@ -469,6 +485,18 @@ export class GameView implements GameMap {
if (gu.alliances) {
this._alliances = gu.alliances;
}
+ gu.updates[GameUpdateType.SubmarinePing].forEach((update) => {
+ this._submarinePings.set(update.unitId, this.ticks());
+ });
+ (
+ gu.updates[GameUpdateType.CitySamCooldown] as CitySamCooldownUpdate[]
+ ).forEach((update) => {
+ if (update.cooldown > 0) {
+ this.citySamCooldowns.set(update.cityId, update.cooldown);
+ } else {
+ this.citySamCooldowns.delete(update.cityId);
+ }
+ });
gu.updates[GameUpdateType.Player].forEach((pu) => {
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
@@ -515,6 +543,10 @@ export class GameView implements GameMap {
}
});
+ gu.updates[GameUpdateType.SubmarinePing].forEach((update) => {
+ this._submarinePings.set(update.unitId, this.ticks());
+ });
+
// Fingerprint AFTER the update
const newAlivePlayerIds = new Set(
Array.from(this._players.values())
@@ -553,6 +585,24 @@ export class GameView implements GameMap {
return this._alliances;
}
+ public isCitySamOnCooldown(cityId: number): boolean {
+ return (this.citySamCooldowns.get(cityId) ?? 0) > 0;
+ }
+
+ public tick(): void {
+ for (const [cityId, ticks] of this.citySamCooldowns.entries()) {
+ if (ticks > 0) {
+ this.citySamCooldowns.set(cityId, ticks - 1);
+ } else {
+ this.citySamCooldowns.delete(cityId);
+ const city = this.unit(cityId);
+ if (city) {
+ this.eventBus.emit(new UnitCooldownEndedEvent(city));
+ }
+ }
+ }
+ }
+
recentlyUpdatedTiles(): TileRef[] {
return this.updatedTiles;
}
@@ -777,4 +827,13 @@ export class GameView implements GameMap {
setFocusedPlayer(player: PlayerView | null): void {
this._focusedPlayer = player;
}
+
+ isUnitPeriodicallyVisible(unitId: number): boolean {
+ const lastPing = this._submarinePings.get(unitId);
+ if (lastPing === undefined) {
+ return false;
+ }
+ // Assumes 3 seconds visibility, 10 ticks per second
+ return this.ticks() - lastPing < 30;
+ }
}
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index f5b874020..1cd6008c2 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -23,7 +23,6 @@ import {
AllPlayers,
Attack,
BuildableUnit,
- Cell,
ColoredTeams,
Embargo,
EmojiMessage,
@@ -151,8 +150,6 @@ export class PlayerImpl implements Player {
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
- largestClusterBoundingBox: { min: Cell; max: Cell } | null;
-
toUpdate(): PlayerUpdate {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
@@ -1201,6 +1198,16 @@ export class PlayerImpl implements Player {
}
}
+ // Test-specific override: Force canBuild for bombers if enabled in TestConfig
+ if (
+ this.mg.config().forceCanBuildBomberInTests?.() &&
+ unitType === UnitType.Bomber
+ ) {
+ // Assuming game.ref(1,1) is a valid airfield tile for the attacker in tests
+ // This bypasses the normal canBuild checks for bombers in tests
+ return this.mg.ref(1, 1);
+ }
+
if (this.mg.config().isUnitDisabled(unitType)) {
return false;
}
@@ -1214,14 +1221,15 @@ export class PlayerImpl implements Player {
if (!this.mg.hasOwner(targetTile)) {
return false;
}
- return this.nukeSpawn(targetTile);
+ return this.nukeSpawn(targetTile, unitType);
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
- return this.nukeSpawn(targetTile);
+ return this.nukeSpawn(targetTile, unitType);
case UnitType.MIRVWarhead:
return targetTile;
case UnitType.Port:
return this.portSpawn(targetTile, validTiles);
+ case UnitType.Submarine:
case UnitType.Warship:
return this.warshipSpawn(targetTile);
case UnitType.Shell:
@@ -1242,6 +1250,7 @@ export class PlayerImpl implements Player {
return this.landBasedStructureSpawn(targetTile, validTiles);
case UnitType.CargoPlane:
case UnitType.Bomber:
+ case UnitType.Paratrooper:
return this.cargoPlaneSpawn(targetTile);
case UnitType.FighterJet:
return this.fighterJetSpawn(targetTile);
@@ -1250,7 +1259,7 @@ export class PlayerImpl implements Player {
}
}
- nukeSpawn(tile: TileRef): TileRef | false {
+ nukeSpawn(tile: TileRef, nukeType: UnitType): TileRef | false {
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) {
@@ -1258,9 +1267,18 @@ export class PlayerImpl implements Player {
}
}
// only get missilesilos that are not on cooldown
- const spawns = this.units(UnitType.MissileSilo)
- .filter((silo) => {
- return !silo.isInCooldown();
+ const potentialSpawns: Unit[] = this.units(UnitType.MissileSilo);
+ if (
+ nukeType === UnitType.AtomBomb &&
+ this.hasUpgrade(UpgradeType.NuclearSubmarineResearch)
+ ) {
+ const nuclearSubmarines = this.units(UnitType.Submarine);
+ potentialSpawns.push(...nuclearSubmarines);
+ }
+
+ const spawns = potentialSpawns
+ .filter((unit) => {
+ return !unit.isInCooldown();
})
.sort(distSortUnit(this.mg, tile));
if (spawns.length === 0) {
diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts
index 29bfeba48..95806c2fd 100644
--- a/src/core/game/Stats.ts
+++ b/src/core/game/Stats.ts
@@ -60,6 +60,9 @@ export interface Stats {
troops: number | bigint,
): void;
+ // Player launches a paratrooper attack
+ paratrooperAttack(player: Player, troops: number | bigint): void;
+
// Player launches bomb at target
bombLaunch(
player: Player,
diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts
index a059e08c7..5d8a94f29 100644
--- a/src/core/game/StatsImpl.ts
+++ b/src/core/game/StatsImpl.ts
@@ -198,6 +198,10 @@ export class StatsImpl implements Stats {
this._addBoat(player, "trans", BOAT_INDEX_DESTROY, 1);
}
+ paratrooperAttack(player: Player, troops: BigIntLike): void {
+ this._addBoat(player, "para", BOAT_INDEX_SENT, 1);
+ }
+
bombLaunch(
player: Player,
target: Player | TerraNullius,
diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts
index c64fcb18a..a5e769d9d 100644
--- a/src/core/game/UnitImpl.ts
+++ b/src/core/game/UnitImpl.ts
@@ -40,6 +40,17 @@ export class UnitImpl implements Unit {
private _insuredBy: Player | null = null;
// Transport-ship specific: track intended target player for cancellation on peace
private _boatTargetPlayerID: PlayerID | null = null;
+ public lastVisibleTick?: number;
+ isDetectedByNavalUnit?: boolean;
+ isAttacking?: boolean;
+
+ isPeriodicallyVisible(): boolean {
+ if (this.lastVisibleTick === undefined) {
+ return false;
+ }
+ // 3 seconds * 10 ticks/sec = 30 ticks
+ return this.mg.ticks() - this.lastVisibleTick < 30;
+ }
constructor(
private _type: UnitType,
@@ -100,6 +111,8 @@ export class UnitImpl implements Unit {
return this._patrolTile;
}
+ tick() {}
+
isUnit(): this is Unit {
return true;
}
@@ -139,6 +152,8 @@ export class UnitImpl implements Unit {
ticksLeftInCooldown: this.ticksLeftInCooldown() ?? undefined,
cooldownDuration: this._cooldownDuration ?? undefined,
returning: this.returning(),
+ isAttacking: this.isAttacking,
+ isDetectedByNavalUnit: this.isDetectedByNavalUnit,
};
}
diff --git a/src/core/tech/ResearchTree.ts b/src/core/tech/ResearchTree.ts
index 32e1c3b04..f708beae1 100644
--- a/src/core/tech/ResearchTree.ts
+++ b/src/core/tech/ResearchTree.ts
@@ -57,6 +57,16 @@ const extras: TechNode[] = [
"Unlocks the Scorched Earth decision, letting you raze roads and reset economic techs.",
cost: costForLevel(2),
},
+ {
+ id: "Air-2B",
+ name: "Paratroopers",
+ category: "Air",
+ level: 2,
+ requiresAllOf: ["Air-1"],
+ description:
+ "Unlocks Paratroopers, allowing you to launch surprise attacks from the sky. Requires an Airfield.",
+ cost: costForLevel(2),
+ },
{
id: "Sea-4B",
name: getTechMeta("Sea-4B", { strict: false })?.name ?? "Sea Tech 4B",
@@ -90,6 +100,11 @@ const tree: TechNode[] = (() => {
sea5.requiresAllOf = undefined;
sea5.requiresOneOf = ["Sea-4", "Sea-4B"];
}
+ const air3 = t.find((x) => x.id === "Air-3");
+ if (air3) {
+ air3.requiresAllOf = undefined;
+ air3.requiresOneOf = ["Air-2", "Air-2B"];
+ }
return t;
})();
diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts
index 226c42ab4..976f5c777 100644
--- a/src/core/tech/TechEffects.ts
+++ b/src/core/tech/TechEffects.ts
@@ -3,13 +3,19 @@ import { Game, Player, UpgradeType } from "../game/Game";
// Central tech IDs for research tree items that have gameplay effects.
// Keep IDs aligned with ResearchTreeModal generation (e.g., "Land-1").
export const RESEARCH_TECH_IDS = {
+ FIGHTER_JET_NAVAL_TARGETING: "Air-1",
+ WARSHIP_ANTI_AIR: "Sea-1",
WWII_LESSONS: "Land-1",
URBAN_PLANNING: "Land-2",
+ CITY_ANTI_AIR: "Air-2",
SCORCHED_EARTH: "Land-2B",
POST_WAR_RECONSTRUCTION: "Economy-1",
INTERNATIONAL_TRADE: "Economy-2",
STRUCTURE_INSURANCE: "Economy-3",
AUTOMATION: "Economy-4",
+ PARATROOPERS: "Air-2B",
+ SUBMARINE_WARFARE: "Sea-2",
+ NUCLEAR_SUBMARINES: "Sea-3",
} as const;
export interface TechMeta {
@@ -43,6 +49,25 @@ export type TechDefinition = {
// Unified registry containing both metadata and effects per tech
export const TECHS: Readonly> = Object.freeze({
+ [RESEARCH_TECH_IDS.WARSHIP_ANTI_AIR]: {
+ meta: {
+ name: "Warship Anti-Air",
+ description:
+ "Equips Warships with an anti-air (AA) missile system to engage nearby enemy aircraft (Bombers, Fighter Jets, Cargo Planes). Does not intercept nuclear missiles. Range: 60 tiles. Cooldown: 5.0 seconds. Hit Chance: 80% base.",
+ },
+ effects: {
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) {
+ player.addUpgrade?.(UpgradeType.WarshipAntiAir);
+ }
+ },
+ onRevoke: (player) => {
+ if (player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) {
+ player.removeUpgrade?.(UpgradeType.WarshipAntiAir);
+ }
+ },
+ },
+ },
[RESEARCH_TECH_IDS.WWII_LESSONS]: {
meta: {
name: "WWII Lessons Learned",
@@ -128,6 +153,25 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
+ [RESEARCH_TECH_IDS.CITY_ANTI_AIR]: {
+ meta: {
+ name: "City Anti-Air",
+ description:
+ "Allows cities to defend themselves against aerial threats. Does not defend against MIRVs.",
+ },
+ effects: {
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) {
+ player.addUpgrade?.(UpgradeType.CityAntiAir);
+ }
+ },
+ onRevoke: (player) => {
+ if (player.hasUpgrade?.(UpgradeType.CityAntiAir)) {
+ player.removeUpgrade?.(UpgradeType.CityAntiAir);
+ }
+ },
+ },
+ },
[RESEARCH_TECH_IDS.STRUCTURE_INSURANCE]: {
meta: {
name: "Structure Insurance",
@@ -182,6 +226,68 @@ export const TECHS: Readonly> = Object.freeze({
},
},
},
+ [RESEARCH_TECH_IDS.FIGHTER_JET_NAVAL_TARGETING]: {
+ meta: {
+ name: "Fighter Anti-Ship",
+ description:
+ "Equips Fighter Jets with advanced targeting systems to engage and destroy enemy naval units (Warships, Transport Ships, Trade Ships).",
+ },
+ effects: {
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.FighterJetNavalTargeting)) {
+ player.addUpgrade?.(UpgradeType.FighterJetNavalTargeting);
+ }
+ },
+ onRevoke: (player) => {
+ if (player.hasUpgrade?.(UpgradeType.FighterJetNavalTargeting)) {
+ player.removeUpgrade?.(UpgradeType.FighterJetNavalTargeting);
+ }
+ },
+ },
+ },
+ [RESEARCH_TECH_IDS.PARATROOPERS]: {
+ meta: {
+ name: "Paratroopers",
+ description:
+ "Unlocks Paratroopers, allowing you to launch surprise attacks from the sky. Requires an Airfield.",
+ },
+ },
+ [RESEARCH_TECH_IDS.SUBMARINE_WARFARE]: {
+ meta: {
+ name: "Submarine Warfare",
+ description: "Unlocks Submarines, which are invisible to most units.",
+ },
+ effects: {
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.SubmarineResearch)) {
+ player.addUpgrade?.(UpgradeType.SubmarineResearch);
+ }
+ },
+ onRevoke: (player) => {
+ if (player.hasUpgrade?.(UpgradeType.SubmarineResearch)) {
+ player.removeUpgrade?.(UpgradeType.SubmarineResearch);
+ }
+ },
+ },
+ },
+ [RESEARCH_TECH_IDS.NUCLEAR_SUBMARINES]: {
+ meta: {
+ name: "Nuclear Submarines",
+ description: "Allows Submarines to launch Atomic Bombs.",
+ },
+ effects: {
+ onComplete: (player) => {
+ if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) {
+ player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch);
+ }
+ },
+ onRevoke: (player) => {
+ if (player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) {
+ player.removeUpgrade?.(UpgradeType.NuclearSubmarineResearch);
+ }
+ },
+ },
+ },
});
// Back-compat export for existing UI code: derive TECH_METADATA from TECHS
export const TECH_METADATA: Readonly> = Object.freeze(
diff --git a/tests/Submarine.test.ts b/tests/Submarine.test.ts
new file mode 100644
index 000000000..3f8193722
--- /dev/null
+++ b/tests/Submarine.test.ts
@@ -0,0 +1,268 @@
+import { MoveSubmarineExecution } from "../src/core/execution/MoveSubmarineExecution";
+import { SubmarineExecution } from "../src/core/execution/SubmarineExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../src/core/game/Game";
+import { setup } from "./util/Setup";
+import { executeTicks } from "./util/utils";
+
+const coastX = 7;
+let game: Game;
+let player1: Player;
+let player2: Player;
+
+describe("Submarine", () => {
+ beforeEach(async () => {
+ game = await setup(
+ "half_land_half_ocean",
+ {
+ infiniteGold: true,
+ instantBuild: true,
+ },
+ [
+ new PlayerInfo(
+ "us",
+ "boat dude",
+ PlayerType.Human,
+ null,
+ "player_1_id",
+ ),
+ new PlayerInfo(
+ "us",
+ "boat dude",
+ PlayerType.Human,
+ null,
+ "player_2_id",
+ ),
+ ],
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ player1 = game.player("player_1_id");
+ player2 = game.player("player_2_id");
+ });
+
+ test("Submarine heals only if player has port", async () => {
+ const maxHealth = game.config().unitInfo(UnitType.Submarine).maxHealth;
+ if (typeof maxHealth !== "number") {
+ expect(typeof maxHealth).toBe("number");
+ throw new Error("unreachable");
+ }
+
+ const port = player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 10),
+ {
+ patrolTile: game.ref(coastX + 1, 10),
+ },
+ );
+ game.addExecution(new SubmarineExecution(submarine));
+
+ game.executeNextTick();
+
+ expect(submarine.health()).toBe(maxHealth);
+ submarine.modifyHealth(-10);
+ expect(submarine.health()).toBe(maxHealth - 10);
+ game.executeNextTick();
+ expect(submarine.health()).toBe(maxHealth - 9);
+
+ port.delete();
+
+ game.executeNextTick();
+ expect(submarine.health()).toBe(maxHealth - 9);
+ });
+
+ test("Submarine destroys trade ship if player has port", async () => {
+ const portTile = game.ref(coastX, 10);
+ player1.buildUnit(UnitType.Port, portTile, {});
+ game.addExecution(
+ new SubmarineExecution(
+ player1.buildUnit(UnitType.Submarine, portTile, {
+ patrolTile: portTile,
+ }),
+ ),
+ );
+
+ const tradeShip = player2.buildUnit(
+ UnitType.TradeShip,
+ game.ref(coastX + 1, 7),
+ {
+ targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
+ },
+ );
+
+ player1.setWarWith(player2);
+
+ expect(tradeShip.owner().id()).toBe(player2.id());
+ // Let plenty of time for A* to execute
+ for (let i = 0; i < 10; i++) {
+ game.executeNextTick();
+ }
+ expect(tradeShip.isActive()).toBe(false);
+ });
+
+ test("Submarine does not destroy trade if player has no port", async () => {
+ game.addExecution(
+ new SubmarineExecution(
+ player1.buildUnit(UnitType.Submarine, game.ref(coastX + 1, 11), {
+ patrolTile: game.ref(coastX + 1, 11),
+ }),
+ ),
+ );
+
+ const tradeShip = player2.buildUnit(
+ UnitType.TradeShip,
+ game.ref(coastX + 1, 11),
+ {
+ targetUnit: player1.buildUnit(UnitType.Port, game.ref(coastX, 11), {}),
+ },
+ );
+
+ expect(tradeShip.owner().id()).toBe(player2.id());
+ // Let plenty of time for warship to potentially capture trade ship
+ for (let i = 0; i < 10; i++) {
+ game.executeNextTick();
+ }
+
+ expect(tradeShip.owner().id()).toBe(player2.id());
+ });
+
+ test("Submarine does not target trade ships that are safe from pirates", async () => {
+ // build port so submarine can target trade ships
+ player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
+
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 10),
+ {
+ patrolTile: game.ref(coastX + 1, 10),
+ },
+ );
+ game.addExecution(new SubmarineExecution(submarine));
+
+ const tradeShip = player2.buildUnit(
+ UnitType.TradeShip,
+ game.ref(coastX + 1, 10),
+ {
+ targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
+ },
+ );
+
+ tradeShip.setSafeFromPirates();
+
+ executeTicks(game, 10);
+
+ expect(tradeShip.owner().id()).toBe(player2.id());
+ });
+
+ test("Submarine moves to new patrol tile", async () => {
+ game.config().warshipTargettingRange = () => 1;
+
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 10),
+ {
+ patrolTile: game.ref(coastX + 1, 10),
+ },
+ );
+
+ game.addExecution(new SubmarineExecution(submarine));
+
+ game.addExecution(
+ new MoveSubmarineExecution(
+ player1,
+ submarine.id(),
+ game.ref(coastX + 5, 15),
+ ),
+ );
+
+ executeTicks(game, 10);
+
+ expect(submarine.patrolTile()).toBe(game.ref(coastX + 5, 15));
+ });
+
+ test("Submarine does not target trade ships outside of patrol range", async () => {
+ game.config().warshipTargettingRange = () => 3;
+
+ // build port so submarine can target trade ships
+ player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
+
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 10),
+ {
+ patrolTile: game.ref(coastX + 1, 10),
+ },
+ );
+ game.addExecution(new SubmarineExecution(submarine));
+
+ const tradeShip = player2.buildUnit(
+ UnitType.TradeShip,
+ game.ref(coastX + 1, 15),
+ {
+ targetUnit: player2.buildUnit(UnitType.Port, game.ref(coastX, 10), {}),
+ },
+ );
+
+ executeTicks(game, 10);
+
+ // Trade ship should not be captured
+ expect(tradeShip.owner().id()).toBe(player2.id());
+ });
+
+ test("MoveSubmarineExecution fails if player is not the owner", async () => {
+ const originalPatrolTile = game.ref(coastX + 1, 10);
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 5),
+ {
+ patrolTile: originalPatrolTile,
+ },
+ );
+ new MoveSubmarineExecution(
+ player2,
+ submarine.id(),
+ game.ref(coastX + 5, 15),
+ ).init(game, 0);
+ expect(submarine.patrolTile()).toBe(originalPatrolTile);
+ });
+
+ test("MoveSubmarineExecution fails if submarine is not active", async () => {
+ const originalPatrolTile = game.ref(coastX + 1, 10);
+ const submarine = player1.buildUnit(
+ UnitType.Submarine,
+ game.ref(coastX + 1, 5),
+ {
+ patrolTile: originalPatrolTile,
+ },
+ );
+ submarine.delete();
+ new MoveSubmarineExecution(
+ player1,
+ submarine.id(),
+ game.ref(coastX + 5, 15),
+ ).init(game, 0);
+ expect(submarine.patrolTile()).toBe(originalPatrolTile);
+ });
+
+ test("MoveSubmarineExecution fails gracefully if submarine not found", async () => {
+ const exec = new MoveSubmarineExecution(
+ player1,
+ 123,
+ game.ref(coastX + 5, 15),
+ );
+
+ // Verify that no error is thrown.
+ exec.init(game, 0);
+
+ expect(exec.isActive()).toBe(false);
+ });
+});
diff --git a/tests/core/execution/CityAntiAir.test.ts b/tests/core/execution/CityAntiAir.test.ts
new file mode 100644
index 000000000..2d609160e
--- /dev/null
+++ b/tests/core/execution/CityAntiAir.test.ts
@@ -0,0 +1,145 @@
+import { BomberExecution } from "../../../src/core/execution/BomberExecution";
+import { NukeExecution } from "../../../src/core/execution/NukeExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+ UpgradeType,
+} from "../../../src/core/game/Game";
+import { setup } from "../../util/Setup";
+import { executeTicks } from "../../util/utils";
+
+describe("CityAntiAir", () => {
+ let game: Game;
+ let attacker: Player;
+ let defender: Player;
+
+ beforeEach(async () => {
+ game = await setup(
+ "BigPlains",
+ { infiniteGold: true, instantBuild: true },
+ [
+ new PlayerInfo(
+ "us",
+ "attacker",
+ PlayerType.Human,
+ "client_id1",
+ "attacker_id",
+ ),
+ new PlayerInfo(
+ "us",
+ "defender",
+ PlayerType.Human,
+ "client_id2",
+ "defender_id",
+ ),
+ ],
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ attacker = game.player("attacker_id");
+ defender = game.player("defender_id");
+ });
+
+ it("should allow a city with the upgrade to intercept a nuke", () => {
+ // Arrange: Defender gets the upgrade and a city
+ defender.addUpgrade(UpgradeType.CityAntiAir);
+ const city = defender.buildUnit(UnitType.City, game.ref(10, 10), {});
+
+ const nukeExec = new NukeExecution(
+ UnitType.AtomBomb,
+ attacker,
+ city.tile(),
+ game.ref(1, 1),
+ );
+ game.addExecution(nukeExec);
+
+ // Act: Run enough ticks for the interception to occur
+ executeTicks(game, 3);
+
+ // Assert
+ expect(game.isCitySamOnCooldown(city.id())).toBe(true);
+ });
+
+ it("should NOT allow a city without the upgrade to intercept a nuke", () => {
+ // Arrange: Defender has a city but NO upgrade
+ const city = defender.buildUnit(UnitType.City, game.ref(10, 10), {});
+
+ const nukeExec = new NukeExecution(
+ UnitType.AtomBomb,
+ attacker,
+ city.tile(),
+ game.ref(1, 1),
+ );
+ game.addExecution(nukeExec);
+
+ // Act: Run a few ticks, not enough for the nuke to detonate
+ executeTicks(game, 3);
+
+ // Assert: Nuke is still active and city is not on cooldown
+ expect(nukeExec.isActive()).toBe(true);
+ expect(game.isCitySamOnCooldown(city.id())).toBe(false);
+ });
+
+ it("should respect the cooldown period", () => {
+ // Arrange
+ defender.addUpgrade(UpgradeType.CityAntiAir);
+ const city = defender.buildUnit(UnitType.City, game.ref(10, 10), {});
+ const nukeExec1 = new NukeExecution(
+ UnitType.AtomBomb,
+ attacker,
+ city.tile(),
+ game.ref(1, 1),
+ );
+ game.addExecution(nukeExec1);
+
+ // Act: First nuke is intercepted
+ executeTicks(game, 3);
+
+ // Assert: Cooldown is active
+ expect(game.isCitySamOnCooldown(city.id())).toBe(true);
+
+ // Arrange: Launch a second nuke while cooldown is active
+ const nukeExec2 = new NukeExecution(
+ UnitType.AtomBomb,
+ attacker,
+ city.tile(),
+ game.ref(1, 2),
+ );
+ game.addExecution(nukeExec2);
+
+ // Act: Run a few more ticks
+ executeTicks(game, 3);
+
+ // Assert: Second nuke is NOT intercepted
+ expect(nukeExec2.isActive()).toBe(true);
+ });
+
+ it("should allow a city with the upgrade to intercept a bomber", () => {
+ // Arrange
+ defender.addUpgrade(UpgradeType.CityAntiAir);
+ const city = defender.buildUnit(UnitType.City, game.ref(10, 10), {});
+
+ const airfield = attacker.buildUnit(UnitType.Airfield, game.ref(1, 1), {});
+
+ const bomberExec = new BomberExecution(
+ attacker,
+ airfield,
+ city.tile(),
+ new Map(),
+ );
+ game.addExecution(bomberExec);
+
+ // Act: Run enough ticks for interception to occur and be processed
+ executeTicks(game, 10);
+
+ // Assert
+ expect(bomberExec.isActive()).toBe(false);
+ expect(game.isCitySamOnCooldown(city.id())).toBe(true);
+ });
+});
diff --git a/tests/core/execution/FighterJet.test.ts b/tests/core/execution/FighterJet.test.ts
new file mode 100644
index 000000000..01198085e
--- /dev/null
+++ b/tests/core/execution/FighterJet.test.ts
@@ -0,0 +1,125 @@
+import { FighterJetExecution } from "../../../src/core/execution/FighterJetExecution";
+import { WarshipExecution } from "../../../src/core/execution/WarshipExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+ UpgradeType,
+} from "../../../src/core/game/Game";
+import { setup } from "../../util/Setup";
+import { executeTicks } from "../../util/utils";
+
+describe("FighterJet Naval Targeting", () => {
+ let game: Game;
+ let attacker: Player;
+ let defender: Player;
+
+ beforeEach(async () => {
+ game = await setup(
+ "half_land_half_ocean",
+ { infiniteGold: true, instantBuild: true },
+ [
+ new PlayerInfo(
+ "us",
+ "attacker",
+ PlayerType.Human,
+ "client_id1",
+ "attacker_id",
+ ),
+ new PlayerInfo(
+ "us",
+ "defender",
+ PlayerType.Human,
+ "client_id2",
+ "defender_id",
+ ),
+ ],
+ );
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ attacker = game.player("attacker_id");
+ defender = game.player("defender_id");
+
+ // Attacker and Defender need an airfield to use fighters and bombers
+ attacker.buildUnit(UnitType.Airfield, game.ref(1, 1), {});
+ defender.buildUnit(UnitType.Airfield, game.ref(10, 1), {});
+ });
+
+ test("should NOT target ships without the upgrade", () => {
+ const fighter = attacker.buildUnit(UnitType.FighterJet, game.ref(1, 2), {
+ patrolTile: game.ref(1, 2),
+ });
+ const warship = defender.buildUnit(UnitType.Warship, game.ref(1, 5), {
+ patrolTile: game.ref(1, 5),
+ });
+ game.addExecution(new WarshipExecution(warship));
+ game.addExecution(new FighterJetExecution(fighter));
+
+ executeTicks(game, 15);
+
+ expect(fighter.targetUnit()).toBeUndefined();
+ });
+
+ test("should target and one-shot a TransportShip with the upgrade", () => {
+ attacker.addUpgrade(UpgradeType.FighterJetNavalTargeting);
+ const fighter = attacker.buildUnit(UnitType.FighterJet, game.ref(1, 2), {
+ patrolTile: game.ref(1, 2),
+ });
+ const transportShip = defender.buildUnit(
+ UnitType.TransportShip,
+ game.ref(1, 5),
+ {},
+ );
+ game.addExecution(new FighterJetExecution(fighter));
+
+ executeTicks(game, 25); // 10 for scan + 15 for attack
+
+ expect(transportShip.isActive()).toBe(false);
+ });
+
+ test("should damage a Warship with the upgrade", () => {
+ attacker.addUpgrade(UpgradeType.FighterJetNavalTargeting);
+ const fighter = attacker.buildUnit(UnitType.FighterJet, game.ref(1, 2), {
+ patrolTile: game.ref(1, 2),
+ });
+ const warship = defender.buildUnit(UnitType.Warship, game.ref(1, 5), {
+ patrolTile: game.ref(1, 5),
+ });
+ game.addExecution(new WarshipExecution(warship));
+ const initialHealth = warship.health();
+ game.addExecution(new FighterJetExecution(fighter));
+
+ executeTicks(game, 25);
+
+ expect(warship.health()).toBe(initialHealth - 225);
+ });
+
+ test("should prioritize aircraft (FighterJet) over ships", () => {
+ attacker.addUpgrade(UpgradeType.FighterJetNavalTargeting);
+ const fighter = attacker.buildUnit(UnitType.FighterJet, game.ref(1, 2), {
+ patrolTile: game.ref(1, 2),
+ });
+ const warship = defender.buildUnit(UnitType.Warship, game.ref(1, 5), {
+ patrolTile: game.ref(1, 5),
+ });
+ game.addExecution(new WarshipExecution(warship));
+ const enemyFighter = defender.buildUnit(
+ UnitType.FighterJet,
+ game.ref(1, 6),
+ {
+ patrolTile: game.ref(1, 6),
+ },
+ );
+ game.addExecution(new FighterJetExecution(enemyFighter));
+ game.addExecution(new FighterJetExecution(fighter));
+
+ executeTicks(game, 15);
+
+ expect(fighter.targetUnit()?.id()).toBe(enemyFighter.id());
+ });
+});
diff --git a/tests/core/execution/WarshipExecution.test.ts b/tests/core/execution/WarshipExecution.test.ts
new file mode 100644
index 000000000..7ca93f928
--- /dev/null
+++ b/tests/core/execution/WarshipExecution.test.ts
@@ -0,0 +1,173 @@
+import { SAMMissileExecution } from "../../../src/core/execution/SAMMissileExecution";
+import { WarshipExecution } from "../../../src/core/execution/WarshipExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ Unit,
+ UnitType,
+ UpgradeType,
+} from "../../../src/core/game/Game";
+import { PseudoRandom } from "../../../src/core/PseudoRandom";
+import { setup } from "../../util/Setup";
+import { executeTicks } from "../../util/utils";
+
+// Test suite for the Warship's new Anti-Air capability
+describe("WarshipExecution AA Capability", () => {
+ let game: Game;
+ let player1: Player; // Our warship owner
+ let player2: Player; // Our aircraft owner
+ let warship: Unit;
+
+ // This block runs before each test to create a clean game world
+ beforeEach(async () => {
+ // Use the existing 'setup' helper to create the game
+ game = await setup(
+ "half_land_half_ocean", // A map with water
+ { infiniteGold: true, instantBuild: true },
+ [
+ new PlayerInfo("p1", "Player 1", PlayerType.Human, null, "p1_id"),
+ new PlayerInfo("p2", "Player 2", PlayerType.Human, null, "p2_id"),
+ ],
+ );
+
+ // Fast-forward through the spawn phase
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+
+ // Get references to our players
+ player1 = game.player("p1_id");
+ player2 = game.player("p2_id");
+
+ // Create the warship for player1 at a known valid sea coordinate
+ warship = player1.buildUnit(UnitType.Warship, game.ref(7, 10), {
+ patrolTile: game.ref(7, 10),
+ });
+
+ // Add the WarshipExecution to the game's execution loop
+ game.addExecution(new WarshipExecution(warship));
+ });
+
+ // Test Case: No Upgrade
+ test("should not engage aircraft without the AA upgrade", () => {
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+ player2.buildUnit(UnitType.Bomber, game.ref(11, 11), {
+ targetTile: game.ref(0, 0),
+ });
+ executeTicks(game, 10);
+ expect(addExecutionSpy).not.toHaveBeenCalledWith(
+ expect.any(SAMMissileExecution),
+ );
+ });
+
+ // Test Case: With Upgrade
+ test("should engage a bomber with the AA upgrade", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); // Guarantee hit
+ player2.buildUnit(UnitType.Bomber, game.ref(11, 11), {
+ targetTile: game.ref(0, 0),
+ });
+ executeTicks(game, 10);
+ expect(addExecutionSpy).toHaveBeenCalledWith(
+ expect.any(SAMMissileExecution),
+ );
+ });
+
+ // Test Case: Range Check
+ test("should not engage aircraft outside of AA range", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+
+ // Mock the AA range to be a very small, controllable value
+ jest.spyOn(game.config(), "warshipAARange").mockReturnValue(5);
+
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1);
+
+ // Place bomber at a safe coordinate outside the mocked range of 5
+ player2.buildUnit(UnitType.Bomber, game.ref(0, 0), {
+ targetTile: game.ref(0, 0),
+ });
+
+ executeTicks(game, 10);
+
+ expect(addExecutionSpy).not.toHaveBeenCalledWith(
+ expect.any(SAMMissileExecution),
+ );
+ });
+
+ // Test Case: Cooldown Check
+ test("should respect the AA cooldown", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); // Guarantee hits
+ player2.buildUnit(UnitType.Bomber, game.ref(11, 11), {
+ targetTile: game.ref(0, 0),
+ });
+
+ // Fire the first shot
+ executeTicks(game, 10);
+ expect(addExecutionSpy).toHaveBeenCalledTimes(1);
+
+ // Create a new target immediately. Cooldown is 50 ticks.
+ player2.buildUnit(UnitType.Bomber, game.ref(12, 12), {
+ targetTile: game.ref(0, 0),
+ });
+ executeTicks(game, 40); // Not enough time for cooldown
+
+ // Assert it hasn't fired again
+ expect(addExecutionSpy).toHaveBeenCalledTimes(1);
+ });
+
+ // Test Case: Target Priority
+ test("should prioritize a Paratrooper over a Bomber", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); // Guarantee hit
+
+ const bomber = player2.buildUnit(UnitType.Bomber, game.ref(11, 11), {
+ targetTile: game.ref(0, 0),
+ });
+ const paratrooper = player2.buildUnit(
+ UnitType.Paratrooper,
+ game.ref(12, 12),
+ { troops: 100, targetTile: game.ref(1, 1) },
+ );
+
+ executeTicks(game, 10);
+
+ expect(paratrooper.targetedBySAM()).toBe(true);
+ expect(bomber.targetedBySAM()).toBe(false);
+ });
+
+ // Test Case: No Nuke Targeting
+ test("should ignore nuke units", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1);
+ player2.buildUnit(UnitType.AtomBomb, game.ref(11, 11), {});
+ executeTicks(game, 10);
+ expect(addExecutionSpy).not.toHaveBeenCalledWith(
+ expect.any(SAMMissileExecution),
+ );
+ });
+
+ // Test Case: Anti-Overkill
+ test("should not fire at a target already targeted by another SAM", () => {
+ player1.addUpgrade(UpgradeType.WarshipAntiAir);
+ const addExecutionSpy = jest.spyOn(game, "addExecution");
+ jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1);
+
+ const bomber = player2.buildUnit(UnitType.Bomber, game.ref(11, 11), {
+ targetTile: game.ref(0, 0),
+ });
+ bomber.setTargetedBySAM(true); // Simulate another SAM locking on
+
+ executeTicks(game, 10);
+
+ expect(addExecutionSpy).not.toHaveBeenCalledWith(
+ expect.any(SAMMissileExecution),
+ );
+ });
+});
diff --git a/tests/core/tech/computeResearchLevel.test.ts b/tests/core/tech/computeResearchLevel.test.ts
index 7e08d3b9c..5431143be 100644
--- a/tests/core/tech/computeResearchLevel.test.ts
+++ b/tests/core/tech/computeResearchLevel.test.ts
@@ -41,7 +41,8 @@ describe("computeResearchLevel", () => {
const halfL2Count = Math.floor(level2.length / 2);
const chosenL2 = level2.slice(0, halfL2Count);
const T = computeResearchLevel([...level1, ...chosenL2]);
- // additive = 1 + 1 + 0.5 = 2.5; highestLevel = 2 => (2+1)=3; blended = 0.8*2.5 + 0.2*3 = 2.6
- expect(T).toBeCloseTo(2.6, 2);
+ const ratioL2 = halfL2Count / level2.length;
+ const expected = 0.8 * (1 + 1 + ratioL2) + 0.2 * (2 + 1);
+ expect(T).toBeCloseTo(expected, 6);
});
});
diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts
index 08ae95b31..f8f180793 100644
--- a/tests/util/TestConfig.ts
+++ b/tests/util/TestConfig.ts
@@ -13,6 +13,10 @@ export class TestConfig extends DefaultConfig {
private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4;
+ forceCanBuildBomberInTests(): boolean {
+ return true;
+ }
+
samNukeHittingChance(): number {
return 1;
}