diff --git a/resources/images/AirAttackIconWhite.svg b/resources/images/AirAttackIconWhite.svg new file mode 100644 index 000000000..10e408251 --- /dev/null +++ b/resources/images/AirAttackIconWhite.svg @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/resources/images/submarine.svg b/resources/images/submarine.svg new file mode 100644 index 000000000..fbf22af58 --- /dev/null +++ b/resources/images/submarine.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/resources/lang/en.json b/resources/lang/en.json index 729034eeb..421fc810b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -247,6 +247,7 @@ "defense_post": "Defense Post", "port": "Port", "warship": "Warship", + "submarine": "Submarine", "missile_silo": "Missile Silo", "sam_launcher": "SAM Launcher", "atom_bomb": "Atom Bomb", @@ -255,6 +256,7 @@ "hospital": "Hospital", "academy": "Military Academy", "airfield": "Airfield", + "air_field": "Airfield", "fighter_jet": "Fighter Jet" }, "user_setting": { @@ -458,12 +460,13 @@ "missile_silo": "Used to launch nukes", "sam_launcher": "Defends against incoming nukes and planes", "warship": "Captures trade ships, destroys ships and boats", + "submarine": "Stealth unit that destroys ships and boats", "port": "Sends trade ships to generate gold", "defense_post": "Increase defenses of nearby borders", "city": "Increase max population", "hospital": "Lowers troop casualties from combat", "academy": "Increases troop speed and enemy losses in combat", - "airfield": "Sends bomber planes to bomb other players", + "airfield": "Send bombers, fighterjets and paratroopers", "fighter_jet": "Destroys bombers and fighters jets" }, "not_enough_money": "Not enough money" @@ -530,7 +533,12 @@ "accept_alliance": "Accept", "reject_alliance": "Reject", "alliance_renewed": "Your alliance with {name} has been renewed", - "ignore": "Ignore" + "ignore": "Ignore", + "paratrooper_sent": "Paratrooper" + }, + "game_messages": { + "max_paratrooper_units_reached": "Maximum number of paratrooper planes reached.", + "incoming_paratrooper_attack": "Incoming Paratrooper Attack from {attackerName}" }, "unit_info_modal": { "structure_info": "Structure Info", diff --git a/resources/sprites/cargoplane.png b/resources/sprites/cargoplane.png index 0575aa9ba..1836e2fb2 100644 Binary files a/resources/sprites/cargoplane.png and b/resources/sprites/cargoplane.png differ diff --git a/resources/sprites/paratrooper.png b/resources/sprites/paratrooper.png new file mode 100644 index 000000000..1836e2fb2 Binary files /dev/null and b/resources/sprites/paratrooper.png differ diff --git a/resources/sprites/submarine.png b/resources/sprites/submarine.png new file mode 100644 index 000000000..2d15f9369 Binary files /dev/null and b/resources/sprites/submarine.png differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index cc03e9efe..2f5539efd 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -286,6 +286,7 @@ export class ClientGameRunner { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); this.gameView.update(gu); + this.gameView.tick(); this.renderer.tick(); if (gu.updates[GameUpdateType.Win].length > 0) { diff --git a/src/client/ResearchTreeModal.ts b/src/client/ResearchTreeModal.ts index bd42d7b95..f648ae9af 100644 --- a/src/client/ResearchTreeModal.ts +++ b/src/client/ResearchTreeModal.ts @@ -1,5 +1,5 @@ -import { LitElement, html } from "lit"; -import { customElement, property, query } from "lit/decorators.js"; +import { LitElement, html, type PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; import flaskIcon from "../../proprietary/images/flask.png"; import { EventBus } from "../core/EventBus"; import { UpgradeType } from "../core/game/Game"; @@ -12,6 +12,13 @@ import { } from "../core/tech/ResearchTree"; import { RESEARCH_TECH_IDS } from "../core/tech/TechEffects"; import "./components/baseComponents/Modal"; +import { + INVESTMENT_REQUEST_EVENT, + INVESTMENT_SYNC_EVENT, + INVESTMENT_SYNC_REQUEST_EVENT, + type InvestmentRequestDetail, + type InvestmentSyncDetail, +} from "./events/InvestmentEvents"; import { CloseViewEvent } from "./InputHandler"; import { SendPurchaseUpgradeIntentEvent, @@ -19,6 +26,8 @@ import { } from "./Transport"; import { renderNumber } from "./Utils"; +type ResearchTab = Category | "Overview"; + // Category and TechNode are imported from core so client stays in sync @customElement("research-tree-modal") @@ -40,22 +49,48 @@ export class ResearchTreeModal extends LitElement { private categories: Category[] = Array.from( new Set(this.techs.map((t) => t.category)), ) as Category[]; - // Fixed column widths per category (px). Adjust as needed. - private readonly categoryColumnWidths: Record = { - Land: 360, - Sea: 360, - Air: 360, - Nuclear: 360, - Economy: 360, - }; + private readonly tabOrder: ResearchTab[] = [ + "Land", + "Sea", + "Air", + "Nuclear", + "Economy", + "Overview", + ]; + + @state() + private activeTab: ResearchTab = "Land"; + + @state() + private roadInvestmentRate = 0; + + @state() + private researchInvestmentRate = 0; + + @state() + private lockRoad = false; + + @state() + private lockResearch = false; + + @state() + private roadInvestmentEnabled = false; connectedCallback(): void { super.connectedCallback(); + window.addEventListener( + INVESTMENT_SYNC_EVENT, + this.handleInvestmentSync as EventListener, + ); + if (this.visible) { + this.requestInvestmentSync(); + } if (this.visible) this.open(); } open() { this.modalEl?.open(); + this.requestInvestmentSync(); // Perform a full layout pass on the next frame after opening requestAnimationFrame(() => this.updateLayout()); // Start a light refresh loop to reflect game state (gold/upgrades) while open @@ -190,6 +225,212 @@ export class ResearchTreeModal extends LitElement { `; } + private getOrderedTabs(): ResearchTab[] { + const available = new Set(this.categories); + const ordered = this.tabOrder.filter((cat) => { + if (cat === "Overview") return true; + return available.has(cat); + }); + if (!ordered.includes("Overview") && available.size > 0) + ordered.push("Overview"); + return ordered.length ? ordered : [...available]; + } + + private getActiveCategory(): Category | null { + if (this.activeTab === "Overview") return null; + const tabs = this.getOrderedTabs(); + if (!tabs.length) return null; + return tabs.includes(this.activeTab) + ? (this.activeTab as Category) + : (tabs[0] as Category); + } + + private onTabClick(cat: ResearchTab) { + if (cat === this.activeTab) return; + this.activeTab = cat; + } + + private handleInvestmentSync = (event: Event) => { + const { detail } = event as CustomEvent; + if (!detail) return; + this.roadInvestmentRate = detail.road; + this.researchInvestmentRate = detail.research; + this.lockRoad = detail.lockRoad; + this.lockResearch = detail.lockResearch; + this.roadInvestmentEnabled = detail.roadEnabled; + }; + + private requestInvestmentSync() { + window.dispatchEvent(new CustomEvent(INVESTMENT_SYNC_REQUEST_EVENT)); + } + + private dispatchInvestmentRequest(detail: InvestmentRequestDetail) { + window.dispatchEvent( + new CustomEvent(INVESTMENT_REQUEST_EVENT, { + detail, + }), + ); + } + + private handleInvestmentInput(slider: "road" | "research", event: Event) { + const input = event.target as HTMLInputElement; + const value = Math.max( + 0, + Math.min(1, (parseInt(input.value || "0", 10) || 0) / 100), + ); + const currentValue = + slider === "road" ? this.roadInvestmentRate : this.researchInvestmentRate; + const locked = slider === "road" ? this.lockRoad : this.lockResearch; + const enabled = slider === "road" ? this.canUseRoadSlider() : true; + if (locked || !enabled) { + input.value = Math.round(currentValue * 100).toString(); + return; + } + this.dispatchInvestmentRequest({ type: "set", slider, value }); + } + + private handleInvestmentToggle(slider: "road" | "research") { + if (slider === "road" && !this.canUseRoadSlider()) return; + this.dispatchInvestmentRequest({ type: "toggle-lock", slider }); + } + + private canUseRoadSlider(): boolean { + if (this.roadInvestmentEnabled) return true; + const me = this.game?.myPlayer?.(); + return me?.hasUpgrade?.(UpgradeType.Roads) ?? false; + } + + private renderRoadSlider(me: PlayerView | null) { + const hasRoads = this.canUseRoadSlider(); + const displayValue = hasRoads ? this.roadInvestmentRate : 0; + const percent = Math.round(displayValue * 100); + const quality = me?.roadNetworkQuality?.() ?? 100; + const completion = me?.roadNetworkCompletion?.() ?? 100; + const tooltip = hasRoads + ? this.lockRoad + ? "Slider is locked. Double-click to unlock." + : "Double-click slider to lock." + : "Research Post-War Reconstruction to enable road investment."; + const breakEvenMarker = this.renderRoadBreakEvenMarker(me, hasRoads); + return html` +
+ +
+
+
+ ${breakEvenMarker} + this.handleInvestmentInput("road", e)} + @dblclick=${() => hasRoads && this.handleInvestmentToggle("road")} + /> +
+
${tooltip}
+
+ `; + } + + private renderRoadBreakEvenMarker(me: PlayerView | null, enabled: boolean) { + if (!enabled || !me) return ""; + const config = this.game?.config?.(); + if (!config) return ""; + const pxPerSecond = me.roadNetPixelsPerSecond?.() ?? 0; + const base = config.roadConstructionBaseCost(); + const maintMult = config.roadMaintenanceMultiplier(); + const length = me.roadNetworkLength?.() ?? 0; + const quality = me.roadNetworkQuality?.() ?? 100; + const maintenancePerSecond = + (length * base * maintMult * Math.max(0.1, quality / 100)) / 60; + const grossPerSecond = pxPerSecond * base; + let breakEven = 0; + if (grossPerSecond > 0) breakEven = maintenancePerSecond / grossPerSecond; + else breakEven = maintenancePerSecond > 0 ? 1 : 0; + if (!Number.isFinite(breakEven)) breakEven = 0; + breakEven = Math.max(0, Math.min(1, breakEven)); + if (breakEven <= 0 || breakEven >= 1) return ""; + const leftPct = (breakEven * 100).toFixed(2); + return html`
`; + } + + private renderResearchSlider() { + const percent = Math.round(this.researchInvestmentRate * 100); + const tooltip = this.lockResearch + ? "Slider is locked. Double-click to unlock." + : "Double-click slider to lock."; + return html` +
+ +
+
+
+ this.handleInvestmentInput("research", e)} + @dblclick=${() => this.handleInvestmentToggle("research")} + /> +
+
${tooltip}
+
+ `; + } + private computePositions(): { [id: string]: DOMRect } { const map: { [id: string]: DOMRect } = {}; const cards = this.renderRoot.querySelectorAll( @@ -202,72 +443,59 @@ export class ResearchTreeModal extends LitElement { return map; } - // Apply fixed widths to each level grid so columns line up between rows - private applyCategoryWidths() { - const tree = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!tree) return; - const template = this.categories - .map((cat) => `${this.categoryColumnWidths[cat]}px`) - .join(" "); - tree - .querySelectorAll(".level-grid") - .forEach( - (grid) => ((grid as HTMLElement).style.gridTemplateColumns = template), - ); + // Orchestrate layout updates and edge redraw + private updateLayout() { + requestAnimationFrame(() => this.drawEdges()); } - // Position the colored category bands to match the computed column geometry - private updateCategoryBandPositions() { - const tree = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!tree) return; - const bands = this.renderRoot.querySelectorAll( - ".category-bands .category-band", - ) as NodeListOf; - const firstGrid = tree.querySelector(".level-grid") as HTMLElement | null; - if (!firstGrid || bands.length !== this.categories.length) return; - const rootRect = tree.getBoundingClientRect(); - const scrollLeft = tree.scrollLeft; - const contentHeight = tree.scrollHeight; - const slots = firstGrid.querySelectorAll( - ":scope > .category-slot", - ) as NodeListOf; - slots.forEach((slot, i) => { - const r = slot.getBoundingClientRect(); - const left = r.left - rootRect.left + scrollLeft; - const width = r.width; - const band = bands[i]; - band.style.position = "absolute"; - band.style.left = `${left}px`; - band.style.width = `${width}px`; - band.style.top = "0"; - band.style.bottom = "auto"; - band.style.height = `${contentHeight}px`; - }); - // Reveal the bands immediately after positioning - const bandsContainer = this.renderRoot.querySelector( - ".category-bands", - ) as HTMLElement | null; - if (bandsContainer) { - bandsContainer.style.height = `${contentHeight}px`; - bandsContainer.style.bottom = "auto"; - bandsContainer.style.visibility = "visible"; + private renderAllView( + levels: number[], + researched: Set, + categoryColors: Record, + percentages: Map, + ) { + if (!this.categories.length) { + return html`
No research categories found.
`; } - } - - // Orchestrate layout updates and edge redraw - private updateLayout() { - // Apply fixed widths and then position bands/edges - requestAnimationFrame(() => { - this.applyCategoryWidths(); - requestAnimationFrame(() => { - this.updateCategoryBandPositions(); - this.drawEdges(); - }); - }); + return html` +
+ ${this.categories.map((cat) => { + const accent = categoryColors[cat] ?? "rgba(59,130,246,0.06)"; + return html`
+
${cat}
+ ${levels.map((lvl) => { + const techs = this.techs.filter( + (t) => t.category === cat && t.level === lvl, + ); + return html`
+
L${lvl}
+
+ ${techs.length + ? techs.map((tech) => { + const isResearched = researched.has(tech.id); + const pct = percentages.get(tech.id) ?? 0; + return html`
+ ${tech.name} (${pct}%) + ${isResearched + ? html`` + : ""} +
`; + }) + : html`
`} +
+
`; + })} +
`; + })} +
+ `; } private drawEdges() { @@ -275,25 +503,33 @@ export class ResearchTreeModal extends LitElement { ".line-layer", ) as HTMLElement | null; if (!container) return; - const svg = container.querySelector("svg")!; + const svg = container.querySelector("svg"); + if (!svg) return; while (svg.firstChild) svg.removeChild(svg.firstChild); + if (this.activeTab === "Overview") return; + const activeCategory = this.getActiveCategory(); + if (!activeCategory) return; + + const visibleTechs = this.techs.filter( + (t) => t.category === activeCategory, + ); + if (!visibleTechs.length) return; + const pos = this.computePositions(); const treeEl = this.renderRoot.querySelector( ".tree-container", - ) as HTMLElement; + ) as HTMLElement | null; + if (!treeEl) return; const rootRect = treeEl.getBoundingClientRect(); const scrollLeft = treeEl.scrollLeft; const scrollTop = treeEl.scrollTop; - // Compute highlight path based on priority and current researched const me = this.game?.myPlayer?.(); const researched = this.researchedIDsFromGame(); const priority = me?.researchPriorityTech?.() ?? null; - 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 byId = new Map(visibleTechs.map((n) => [n.id, n] as const)); const buildMissingPrereqPath = (targetId: string): Set => { const path = new Set(); const seen = new Set(); @@ -302,12 +538,8 @@ export class ResearchTreeModal extends LitElement { 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), - ); + const reqAll = (node.requiresAllOf ?? []).filter((p) => byId.has(p)); + const reqOne = (node.requiresOneOf ?? []).filter((p) => byId.has(p)); for (const r of reqAll) { if (!researched.has(r)) { path.add(r); @@ -325,12 +557,12 @@ export class ResearchTreeModal extends LitElement { } } }; - if (targetId) dfs(targetId); + if (targetId && byId.has(targetId)) dfs(targetId); return path; }; const highlightNodes = new Set(); - if (priority) { + if (priority && byId.has(priority)) { highlightNodes.add(priority); const missing = buildMissingPrereqPath(priority); for (const id of missing) highlightNodes.add(id); @@ -340,16 +572,16 @@ export class ResearchTreeModal extends LitElement { const a = pos[fromId]; const b = pos[toId]; if (!a || !b) return; - const x1 = a.left - rootRect.left + scrollLeft + a.width / 2; - const y1 = a.top - rootRect.top + scrollTop + a.height; - const x2 = b.left - rootRect.left + scrollLeft + b.width / 2; - const y2 = b.top - rootRect.top + scrollTop; + const x1 = a.right - rootRect.left + scrollLeft; + const y1 = a.top - rootRect.top + scrollTop + a.height / 2; + const x2 = b.left - rootRect.left + scrollLeft; + const y2 = b.top - rootRect.top + scrollTop + b.height / 2; + const midX = (x1 + x2) / 2; const path = document.createElementNS( "http://www.w3.org/2000/svg", "path", ); - const mx = (x1 + x2) / 2; - const d = `M ${x1},${y1} C ${mx},${y1 + 20} ${mx},${y2 - 20} ${x2},${y2}`; + const d = `M ${x1},${y1} L ${midX},${y1} L ${midX},${y2} L ${x2},${y2}`; path.setAttribute("d", d); path.setAttribute("fill", "none"); const isHighlighted = @@ -361,21 +593,17 @@ export class ResearchTreeModal extends LitElement { svg.appendChild(path); }; - for (const t of this.techs) { - const sameCat = (p: string) => - this.techs.find((x) => x.id === p)?.category === t.category; - t.requiresAllOf ??= []; - t.requiresOneOf ??= []; - - const reqAll = t.requiresAllOf.filter(sameCat); - const reqOne = t.requiresOneOf.filter(sameCat); + for (const t of visibleTechs) { + const reqAll = (t.requiresAllOf ?? []).filter((id) => byId.has(id)); + const reqOne = (t.requiresOneOf ?? []).filter((id) => byId.has(id)); for (const p of reqAll) addLine(p, t.id, "req"); for (const p of reqOne) addLine(p, t.id, "oneof"); } } - protected firstUpdated(): void { + protected firstUpdated(_changed: PropertyValues): void { + super.firstUpdated(_changed); setTimeout(() => this.updateLayout(), 0); window.addEventListener("resize", this.handleResize); // Watch scroll on the whole tree container (both axes) @@ -387,6 +615,7 @@ export class ResearchTreeModal extends LitElement { passive: true, } as any, ); + this.requestInvestmentSync(); } disconnectedCallback(): void { @@ -395,6 +624,10 @@ export class ResearchTreeModal extends LitElement { // content no longer scrolls for this modal; listener removed const tree = this.renderRoot.querySelector(".tree-container"); tree?.removeEventListener("scroll", this.handleResize as any); + window.removeEventListener( + INVESTMENT_SYNC_EVENT, + this.handleInvestmentSync as EventListener, + ); } private handleResize = () => { @@ -422,9 +655,65 @@ export class ResearchTreeModal extends LitElement { }; const me = this.game?.myPlayer?.(); const priority = me?.researchPriorityTech?.() ?? null; + const tabs = this.getOrderedTabs(); + const isAllView = this.activeTab === "Overview"; + const activeCategory = this.getActiveCategory(); + const activeTechs = activeCategory + ? this.techs.filter((t) => t.category === activeCategory) + : []; + const activeMap = new Map(activeTechs.map((n) => [n.id, n] as const)); + const percentByTechId = (() => { + const map = new Map(); + for (const tech of this.techs) { + const cost = Math.max(1, tech.cost || 1); + const beakers = me?.researchBeakers?.(tech.id) ?? 0; + let pct = Math.floor((beakers / cost) * 100); + if (!Number.isFinite(pct)) pct = 0; + pct = Math.max(0, Math.min(100, pct)); + if (researched.has(tech.id)) pct = 100; + map.set(tech.id, pct); + } + return map; + })(); + const highlightTrail = (() => { + const set = new Set(); + if (!priority || !activeCategory || !activeMap.has(priority)) return set; + const seen = new Set(); + const dfs = (tid: string) => { + if (seen.has(tid)) return; + seen.add(tid); + const node = activeMap.get(tid); + if (!node) return; + const reqAll = (node.requiresAllOf ?? []).filter((p) => + activeMap.has(p), + ); + const reqOne = (node.requiresOneOf ?? []).filter((p) => + activeMap.has(p), + ); + for (const r of reqAll) { + if (!researched.has(r)) { + set.add(r); + dfs(r); + } + } + if (reqOne.length > 0 && !reqOne.some((p) => researched.has(p))) { + const sorted = [...reqOne].sort( + (a, b) => + (activeMap.get(a)?.level ?? 0) - (activeMap.get(b)?.level ?? 0), + ); + const choice = sorted[0]; + if (choice && !researched.has(choice)) { + set.add(choice); + dfs(choice); + } + } + }; + set.add(priority); + dfs(priority); + return set; + })(); return html` - ${this.renderLegend()} -
-
- ${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` -
-
Tech Level ${lvl}
-
- ${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` -
-
- ${tech.description - ? html`
+
- ${tech.description} -
` - : ""} - ${(() => { - 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()} - research cost + ${tech.name}
- ${isResearched - ? html`
Status: Completed
` - : html`
- Progress: ${b.toLocaleString()} / - ${tech.cost.toLocaleString()} - (${pct}%) -
`} -
`; - })()} -
-
- ${tech.name} -
-
- ${tech.cost.toLocaleString()} - research cost -
- ${!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.description + ? html`
+ ${tech.description}
` - : ""; - })() - : ""} -
- ${tech.requiresAllOf?.length - ? html`Requires: - ${tech.requiresAllOf.length}` - : ""} - ${tech.requiresOneOf?.length - ? html`One of: - ${tech.requiresOneOf.length}` - : ""} - ${priority === tech.id && !isResearched - ? html`Priority` - : ""} -
- - ${action} -
- `; - })} -
-
- `; - })} -
-
- `, - )} -
+ : ""} + ${(() => { + 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()} + research cost +
+ ${isResearched + ? html`
+ Status: Completed +
` + : html`
+ Progress: + ${b.toLocaleString()} / + ${tech.cost.toLocaleString()} + (${pct}%) +
`} +
`; + })()} +
+
+ ${tech.name} +
+
+ ${tech.cost.toLocaleString()} + research cost +
+
+ ${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` +
+ Tech unlocked + ${this.current.name} +
+

${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; }