From 5cfe865408cec0576fd30b0825680737c53f8eea Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Fri, 12 Dec 2025 01:03:26 +0100 Subject: [PATCH 01/16] Refactor Research Tree: Remove Economy, update UI with icons and tooltips --- resources/icons/research/air.svg | 1 + resources/icons/research/land.svg | 1 + resources/icons/research/nuclear.svg | 1 + resources/icons/research/sea.svg | 1 + src/client/ResearchTreeModal.ts | 2168 ++++---------------- src/client/StatisticsModal.ts | 8 +- src/client/TechTooltips.ts | 61 + src/client/stats/StatDefinitions.ts | 4 +- src/core/tech/PolicyDirectives.ts | 118 +- src/core/tech/ResearchTree.ts | 84 +- src/core/tech/TechEffects.ts | 306 +-- src/core/tech/TechIds.ts | 51 +- tests/core/game/PlayerImpl.tech.test.ts | 40 +- tests/core/tech/EconomyTechEffects.test.ts | 66 +- 14 files changed, 587 insertions(+), 2323 deletions(-) create mode 100644 resources/icons/research/air.svg create mode 100644 resources/icons/research/land.svg create mode 100644 resources/icons/research/nuclear.svg create mode 100644 resources/icons/research/sea.svg create mode 100644 src/client/TechTooltips.ts diff --git a/resources/icons/research/air.svg b/resources/icons/research/air.svg new file mode 100644 index 000000000..bb48c2e87 --- /dev/null +++ b/resources/icons/research/air.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/land.svg b/resources/icons/research/land.svg new file mode 100644 index 000000000..95c8b841c --- /dev/null +++ b/resources/icons/research/land.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/nuclear.svg b/resources/icons/research/nuclear.svg new file mode 100644 index 000000000..9cc7e1f26 --- /dev/null +++ b/resources/icons/research/nuclear.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/research/sea.svg b/resources/icons/research/sea.svg new file mode 100644 index 000000000..7a1ef1897 --- /dev/null +++ b/resources/icons/research/sea.svg @@ -0,0 +1 @@ + diff --git a/src/client/ResearchTreeModal.ts b/src/client/ResearchTreeModal.ts index 06262e2c3..77d6faaeb 100644 --- a/src/client/ResearchTreeModal.ts +++ b/src/client/ResearchTreeModal.ts @@ -1,21 +1,18 @@ import { html, LitElement, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; -import flaskIcon from "../../proprietary/images/flask.png"; +import airIcon from "../../resources/icons/research/air.svg"; +import landIcon from "../../resources/icons/research/land.svg"; +import nuclearIcon from "../../resources/icons/research/nuclear.svg"; +import seaIcon from "../../resources/icons/research/sea.svg"; import { EventBus } from "../core/EventBus"; -import { UpgradeType } from "../core/game/Game"; -import { GameView, PlayerView } from "../core/game/GameView"; -import { - getAllPolicyDirectives, - getUnlockedDirectives, - type PolicyDirective, -} from "../core/tech/PolicyDirectives"; +import { GameView } from "../core/game/GameView"; import { getTechNodes, isTechAvailable as serverIsTechAvailable, type Category, type TechNode, } from "../core/tech/ResearchTree"; -import { getTechMeta, RESEARCH_TECH_IDS } from "../core/tech/TechEffects"; +import { getTechMeta } from "../core/tech/TechEffects"; import "./components/baseComponents/Modal"; import { INVESTMENT_REQUEST_EVENT, @@ -25,27 +22,23 @@ import { type InvestmentSyncDetail, } from "./events/InvestmentEvents"; import { CloseViewEvent } from "./InputHandler"; -import { - SendMarkPolicyDirectivesSeenIntentEvent, - SendPolicyDirectiveSelectIntentEvent, - SendResearchTreeSelectIntentEvent, - SendScorchedEarthIntentEvent, -} from "./Transport"; -import { renderNumber, translateText } from "./Utils"; - -type ResearchTab = Category | "Overview" | "Policy Directives"; +import { getDetailedTechTooltip } from "./TechTooltips"; +import { SendResearchTreeSelectIntentEvent } from "./Transport"; /** Helper to get display name/description from TechEffects */ function getTechDisplay(tech: TechNode): { name: string; + shortDescription?: string; description?: string; } { const meta = getTechMeta(tech.id, { strict: false }); - return { name: meta?.name ?? tech.id, description: meta?.description }; + return { + name: meta?.name ?? tech.id, + shortDescription: meta?.shortDescription ?? meta?.description, + description: meta?.description, + }; } -// Category and TechNode are imported from core so client stays in sync - @customElement("research-tree-modal") export class ResearchTreeModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { @@ -54,45 +47,20 @@ export class ResearchTreeModal extends LitElement { }; @property({ type: Boolean }) visible: boolean = false; - // Injected from parent so we can read upgrades and send intents @property({ attribute: false }) game!: GameView; @property({ attribute: false }) eventBus!: EventBus; - // Local polling while modal is open to keep UI in sync with game state private refreshTimer: number | null = null; - private techs: TechNode[] = [...getTechNodes()]; - private categories: Category[] = Array.from( - new Set(this.techs.map((t) => t.category)), - ) as Category[]; - private readonly tabOrder: ResearchTab[] = [ - "Land", - "Sea", - "Air", - "Nuclear", - "Economy", - "Policy Directives", - "Overview", - ]; - - @state() - private activeTab: ResearchTab = "Land"; - - @state() - private roadInvestmentRate = 0; + // Fixed category ordering: Land, Sea, Air, Nuclear + private categories: Category[] = ["Land", "Sea", "Air", "Nuclear"]; @state() private researchInvestmentRate = 0; - @state() - private lockRoad = false; - @state() private lockResearch = false; - @state() - private roadInvestmentEnabled = false; - connectedCallback(): void { super.connectedCallback(); window.addEventListener( @@ -108,12 +76,10 @@ export class ResearchTreeModal extends LitElement { 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 this.refreshTimer ??= window.setInterval(() => this.requestUpdate(), 500); this.eventBus.on(CloseViewEvent, this.close); } + close = () => { this.modalEl?.close(); if (this.refreshTimer !== null) { @@ -122,148 +88,46 @@ export class ResearchTreeModal extends LitElement { } this.eventBus.off(CloseViewEvent, this.close); }; + show() { this.visible = true; this.open(); } + hide() { this.visible = false; this.close(); } - // Placeholder tree removed: client uses server-authoritative tree - private isAvailable(id: string, researched: Set): boolean { return serverIsTechAvailable(id, researched); } - // No mapping to existing UpgradeType; research tree is separate - private researchedIDsFromGame(): Set { const res = new Set(); const me = this.game?.myPlayer?.(); if (!me) return res; - // Use new per-match researched techs for (const t of this.techs) if (me.hasResearchedTech(t.id)) res.add(t.id); return res; } private onTechClick(id: string) { if (!this.game || !this.eventBus) return; - const tech = this.techs.find((t) => t.id === id)!; const me = this.game.myPlayer(); if (!me) return; const researched = this.researchedIDsFromGame(); - // Allow prioritizing even if unavailable; still ignore already researched - if (me.hasResearchedTech?.(id)) return; // already researched + if (me.hasResearchedTech?.(id)) return; - // Clicking sets this as the current research priority (server handles distribution) this.eventBus.emit(new SendResearchTreeSelectIntentEvent(id)); this.requestUpdate(); } - private onActivateScorchedEarth(event: Event): void { - event.stopPropagation(); - event.preventDefault(); - if (!this.game || !this.eventBus) return; - const me = this.game.myPlayer(); - if (!me || this.game.inSpawnPhase()) return; - if (me.hasUpgrade?.(UpgradeType.ScorchedEarth)) return; - this.eventBus.emit(new SendScorchedEarthIntentEvent()); - } - - private renderScorchedEarthAction( - tech: TechNode, - me: PlayerView | null, - isResearched: boolean, - ) { - // Show Scorched Earth button on Mechanized Warfare Doctrine (Land-2) when researched - if ( - tech.id !== RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE || - !me || - !isResearched - ) { - return ""; - } - const config = this.game?.config?.(); - if (!config) return ""; - const activationCost = config.scorchedEarthActivationCost(me); - const gold = me.gold(); - const hasUpgrade = me.hasUpgrade(UpgradeType.ScorchedEarth); - const disabled = - hasUpgrade || this.game?.inSpawnPhase?.() || gold < activationCost; - const tooltip = hasUpgrade - ? "Scorched Earth already active." - : gold < activationCost - ? "Earn more gold to activate Scorched Earth." - : "Activate to raze your road network."; - return html` - - `; - } - - private renderLegend() { - return html` -
- Required - Requires one of - Researched - Unmet requirements are grayed out -
- `; - } - - private getOrderedTabs(): ResearchTab[] { - const available = new Set(this.categories); - const ordered = this.tabOrder.filter((cat) => { - if (cat === "Overview" || cat === "Policy Directives") 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; - - // Mark policy directives as seen when viewing the tab - if (cat === "Policy Directives" && this.eventBus) { - const me = this.game?.myPlayer?.(); - if (me?.hasUnseenPolicyDirectives?.()) { - this.eventBus.emit(new SendMarkPolicyDirectivesSeenIntentEvent()); - } - } - } - 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() { @@ -278,582 +142,65 @@ export class ResearchTreeModal extends LitElement { ); } - 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 tooltipKey = hasRoads - ? this.lockRoad - ? "research_tree.investment.slider_locked" - : "research_tree.investment.slider_unlocked" - : "research_tree.investment.road_disabled"; - const tooltip = translateText(tooltipKey); - 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 lockTooltip = translateText( - this.lockResearch - ? "research_tree.investment.slider_locked" - : "research_tree.investment.slider_unlocked", - ); - const goalTooltip = translateText("research_tree.investment.research_goal"); - const tooltip = `${goalTooltip} ${lockTooltip}`; + return html` -
- -
-
-
+
+ Investment: +
this.handleInvestmentInput("research", e)} - @dblclick=${() => this.handleInvestmentToggle("research")} + class="research-slider" + @input=${(e: Event) => this.handleInvestmentInput(e)} />
-
${tooltip}
-
- `; - } - - private computePositions(): { [id: string]: DOMRect } { - const map: { [id: string]: DOMRect } = {}; - const cards = this.renderRoot.querySelectorAll( - ".tech[data-id]", - ) as NodeListOf; - cards.forEach((el) => { - const id = el.dataset.id!; - map[id] = el.getBoundingClientRect(); - }); - return map; - } - - // Orchestrate layout updates and edge redraw - private updateLayout() { - requestAnimationFrame(() => this.drawEdges()); - } - - private adjustTooltipPosition(target: HTMLElement) { - const tooltip = target.querySelector(".tooltip") as HTMLElement | null; - const container = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!tooltip || !container) return; - - const containerRect = container.getBoundingClientRect(); - const rect = target.getBoundingClientRect(); - const tooltipRect = tooltip.getBoundingClientRect(); - const tooltipHeight = - tooltipRect.height || tooltip.offsetHeight || tooltip.scrollHeight || 0; - const tooltipWidth = - tooltipRect.width || tooltip.offsetWidth || tooltip.scrollWidth || 0; - const gap = 12; - const spaceAbove = rect.top - containerRect.top; - const spaceBelow = containerRect.bottom - rect.bottom; - - let position: "above" | "below" = "above"; - if (spaceAbove < tooltipHeight + gap && spaceBelow > spaceAbove) { - position = "below"; - } - - tooltip.dataset.position = position; - - // Clamp horizontal position so tooltips stay inside the container - const centerX = rect.left + rect.width / 2; - const margin = 20; - const availableWidth = Math.max(0, containerRect.width - margin * 2); - if (availableWidth <= 0) return; - const halfWidth = Math.min(tooltipWidth / 2, availableWidth / 2); - const minCenter = containerRect.left + margin + halfWidth; - const maxCenter = containerRect.right - margin - halfWidth; - const clampedCenter = Math.min(maxCenter, Math.max(minCenter, centerX)); - const shift = clampedCenter - centerX; - tooltip.style.setProperty("--tooltip-shift", `${shift}px`); - - // If still overflowing above, flip below as a final safeguard - if (position === "above" && spaceAbove < tooltipHeight + gap) { - tooltip.dataset.position = "below"; - } - } - - private renderAllView( - levels: number[], - researched: Set, - categoryColors: Record, - percentages: Map, - ) { - if (!this.categories.length) { - return html`
No research categories found.
`; - } - return html` -
- ${this.categories.map((cat) => { - const accent = - categoryColors[cat] ?? - "color-mix(in srgb, var(--ui-info) 6%, transparent)"; - 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; - const display = getTechDisplay(tech); - return html`
- ${display.name} (${pct}%) - ${isResearched - ? html`โœ”` - : ""} -
`; - }) - : html`
โ€”
`} -
-
`; - })} -
`; - })}
`; } - private renderPolicyDirectivesView() { - const me = this.game?.myPlayer?.(); - if (!me) { - return html` -
-
Loading...
-
- `; - } - - // Get all policy directives, filter out those where a choice has already been made - const allDirectives = getAllPolicyDirectives(); - const pendingDirectives = allDirectives.filter( - (d) => me.getPolicyChoice?.(d.id) === null, - ); - const unlockedDirectives = getUnlockedDirectives((techId) => - me.hasResearchedTech(techId), - ); - - // Sort so unlocked (available) directives appear first - const sortedDirectives = [...pendingDirectives].sort((a, b) => { - const aUnlocked = unlockedDirectives.some((d) => d.id === a.id); - const bUnlocked = unlockedDirectives.some((d) => d.id === b.id); - if (aUnlocked && !bUnlocked) return -1; - if (!aUnlocked && bUnlocked) return 1; - return 0; - }); - - if (pendingDirectives.length === 0) { - return html` -
-
- No pending policy directives. New directives will appear here when - you research certain technologies. -
-
- `; - } - - return html` -
-
-

- Policy Directives become available when you research certain - technologies. Choose a policy to receive additional bonuses. -

-
-
- ${sortedDirectives.map((directive) => { - const isUnlocked = unlockedDirectives.some( - (d) => d.id === directive.id, - ); - const currentChoice = me.getPolicyChoice?.(directive.id) ?? null; - return this.renderPolicyDirective( - directive, - isUnlocked, - currentChoice, - ); - })} -
-
- `; - } - - private renderPolicyDirective( - directive: PolicyDirective, - isUnlocked: boolean, - currentChoice: string | null, - ) { - return html` -
-
-

${directive.name}

- ${!isUnlocked - ? html` - ๐Ÿ”’ Requires: - ${getTechMeta(directive.unlockedByTech, { strict: false }) - ?.name ?? directive.unlockedByTech} - ` - : ""} -
-

${directive.description}

-
- ${directive.options.map((option) => { - const isSelected = currentChoice === option.id; - // Disable if not unlocked OR if a choice has already been made (one-time selection) - const hasChoiceMade = currentChoice !== null; - const isDisabled = !isUnlocked || hasChoiceMade; - return html` - - `; - })} -
-
- `; - } - - private onPolicyOptionClick(directiveId: string, optionId: string) { - if (!this.game || !this.eventBus) return; - const me = this.game.myPlayer?.(); - if (!me) return; - - // Don't allow selection if any choice has already been made (one-time selection) - const currentChoice = me.getPolicyChoice?.(directiveId); - if (currentChoice !== null && currentChoice !== undefined) return; - - // Emit the intent to select this policy - this.eventBus.emit( - new SendPolicyDirectiveSelectIntentEvent(directiveId, optionId), - ); - this.requestUpdate(); - } - - private drawEdges() { - const container = this.renderRoot.querySelector( - ".line-layer", - ) as HTMLElement | null; - if (!container) return; - 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, + private handleInvestmentInput(event: Event) { + const input = event.target as HTMLInputElement; + const value = Math.max( + 0, + Math.min(1, (parseInt(input.value || "0", 10) || 0) / 100), ); - if (!visibleTechs.length) return; - - const pos = this.computePositions(); - const treeEl = this.renderRoot.querySelector( - ".tree-container", - ) as HTMLElement | null; - if (!treeEl) return; - const rootRect = treeEl.getBoundingClientRect(); - const scrollLeft = treeEl.scrollLeft; - const scrollTop = treeEl.scrollTop; - - const me = this.game?.myPlayer?.(); - const researched = this.researchedIDsFromGame(); - const priority = me?.researchPriorityTech?.() ?? null; - - 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(); - 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) => byId.has(p)); - const reqOne = (node.requiresOneOf ?? []).filter((p) => byId.has(p)); - 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), - ); - const choice = sorted[0]; - if (choice && !researched.has(choice)) { - path.add(choice); - dfs(choice); - } - } - }; - if (targetId && byId.has(targetId)) dfs(targetId); - return path; - }; - - const highlightNodes = new Set(); - if (priority && byId.has(priority)) { - highlightNodes.add(priority); - const missing = buildMissingPrereqPath(priority); - for (const id of missing) highlightNodes.add(id); - } - - const addLine = (fromId: string, toId: string, cls: string) => { - const a = pos[fromId]; - const b = pos[toId]; - if (!a || !b) return; - 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 d = `M ${x1},${y1} L ${midX},${y1} L ${midX},${y2} L ${x2},${y2}`; - path.setAttribute("d", d); - path.setAttribute("fill", "none"); - const isHighlighted = - highlightNodes.has(fromId) && highlightNodes.has(toId); - path.setAttribute( - "class", - `edge ${cls} ${isHighlighted ? "highlight" : ""}`, - ); - svg.appendChild(path); - }; - - 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"); + const locked = this.lockResearch; + if (locked) { + input.value = Math.round(this.researchInvestmentRate * 100).toString(); + return; } + this.dispatchInvestmentRequest({ type: "set", slider: "research", value }); } 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) - const tree = this.renderRoot.querySelector(".tree-container"); - tree?.addEventListener( - "scroll", - this.handleResize as any, - { - passive: true, - } as any, - ); this.requestInvestmentSync(); } disconnectedCallback(): void { super.disconnectedCallback(); - window.removeEventListener("resize", this.handleResize); - // 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 = () => { - this.updateLayout(); - }; - - // No per-match listener needed; UI reflects game state directly - - protected updated(): void { - // Schedule a layout update on the next animation frame - requestAnimationFrame(() => this.updateLayout()); - } - render() { - const levels = Array.from(new Set(this.techs.map((t) => t.level))).sort( - (a, b) => a - b, - ); const researched = this.researchedIDsFromGame(); const categoryColors: Record = { - Land: "color-mix(in srgb, var(--ui-info) 12%, transparent)", - Sea: "color-mix(in srgb, var(--ui-info) 10%, transparent)", - Air: "color-mix(in srgb, var(--ui-secondary) 12%, transparent)", - Nuclear: "color-mix(in srgb, var(--ui-alert) 12%, transparent)", - Economy: "color-mix(in srgb, var(--ui-success) 12%, transparent)", + Land: "#2ecc71", + Sea: "#3498db", + Air: "#9b59b6", + Nuclear: "#e74c3c", }; const me = this.game?.myPlayer?.(); const priority = me?.researchPriorityTech?.() ?? null; - const tabs = this.getOrderedTabs(); - const isAllView = this.activeTab === "Overview"; - const isPolicyDirectivesView = this.activeTab === "Policy Directives"; - 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) { @@ -867,1212 +214,399 @@ export class ResearchTreeModal extends LitElement { } 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; - })(); + + // Group techs by category for the grid layout + const techsByCategory = new Map(); + for (const cat of this.categories) { + techsByCategory.set( + cat, + this.techs.filter((t) => t.category === cat), + ); + } return html` - ${this.renderLegend()} -
-
-
- ${tabs.map((cat) => { - const isAllTab = cat === "Overview"; - const isPolicyTab = cat === "Policy Directives"; - const isActive = isAllTab - ? isAllView - : isPolicyTab - ? isPolicyDirectivesView - : cat === activeCategory; - const tabTooltip = translateText( - `research_tree.tab_tooltip.${cat.toLowerCase().replace(" ", "_")}`, - ); - const showPolicyBadge = - isPolicyTab && me?.hasUnseenPolicyDirectives?.(); - return html``; - })} -
-
${this.renderResearchSlider()}
+ +
+
+
Research
+ ${this.renderResearchSlider()}
-
-
- ${isPolicyDirectivesView - ? this.renderPolicyDirectivesView() - : isAllView - ? this.renderAllView( - levels, - researched, - categoryColors, - percentByTechId, - ) - : activeCategory - ? html`
+ ${this.categories.map((cat) => { + const techs = techsByCategory.get(cat) ?? []; + const catClass = `cat-${cat.toLowerCase()}`; + + const icons: Record = { + Land: landIcon, + Sea: seaIcon, + Air: airIcon, + Nuclear: nuclearIcon, + }; + const iconSrc = icons[cat]; + + return html` +
+
+ ${iconSrc + ? html`
` + : ""} + ${cat.toUpperCase()} +
+ ${techs.map((tech) => { + const available = this.isAvailable(tech.id, researched); + const isResearched = researched.has(tech.id); + const pct = percentByTechId.get(tech.id) ?? 0; + const isPriority = priority === tech.id; + const display = getTechDisplay(tech); + const tooltip = getDetailedTechTooltip(tech.id); + + const rowClass = [ + "tech-row", + isResearched ? "researched" : "", + !available && !isResearched ? "locked" : "", + ] + .filter(Boolean) + .join(" "); + + const barClass = `bar-${cat.toLowerCase()}`; + + return html` +
this.onTechClick(tech.id)} + title=${tooltip} > - ${levels - .filter((lvl) => - this.techs.some( - (t) => - t.level === lvl && - t.category === activeCategory, - ), - ) - .map((lvl) => { - const techsForLevel = this.techs.filter( - (t) => - t.level === lvl && - t.category === activeCategory, - ); - return html`
-
Tech Level ${lvl}
-
- ${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 classes = [ - "tech", - available ? "" : "locked", - isResearched ? "researched" : "", - inHighlight ? "priority" : "", - ] - .filter(Boolean) - .join(" "); - const action = this.renderScorchedEarthAction( - tech, - me ?? null, - isResearched, - ); - return html`
- ${(() => { - const display = getTechDisplay(tech); - return html``; - })()} - ${action} -
`; - })} -
-
`; - })} -
` - : html`
- No research categories found. -
`} - ${!isAllView && !isPolicyDirectivesView - ? html`
` - : ""} -
+
+
${display.name}
+
+ ${display.shortDescription ?? ""} +
+
+
+
+ ${pct > 0 && pct < 100 + ? html`${pct}%` + : ""} +
+ +
+ `; + })} +
+ `; + })}
diff --git a/src/client/StatisticsModal.ts b/src/client/StatisticsModal.ts index b156e43db..b45c97ee1 100644 --- a/src/client/StatisticsModal.ts +++ b/src/client/StatisticsModal.ts @@ -250,13 +250,7 @@ export class StatisticsModal extends LitElement { ] : []; - const categories: Category[] = [ - "Land", - "Sea", - "Air", - "Nuclear", - "Economy", - ]; + const categories: Category[] = ["Land", "Sea", "Air", "Nuclear"]; const nodes = getTechNodes(); const techsByCategory: Array<[string, string]> = sel ? categories.map((cat) => { diff --git a/src/client/TechTooltips.ts b/src/client/TechTooltips.ts new file mode 100644 index 000000000..0323e19d0 --- /dev/null +++ b/src/client/TechTooltips.ts @@ -0,0 +1,61 @@ +import { + BOMBER_UPGRADES, + FIGHTER_UPGRADES, + SUBMARINE_UPGRADES, + WARSHIP_UPGRADES, +} from "../core/game/UnitUpgrades"; +import { RESEARCH_TECH_IDS } from "../core/tech/TechIds"; + +export function getDetailedTechTooltip(techId: string): string { + switch (techId) { + // --- SEA --- + case RESEARCH_TECH_IDS.SEA_MISSILE_NAVY: { + const w2 = WARSHIP_UPGRADES[1]; + const s2 = SUBMARINE_UPGRADES[1]; + return `Unlocks:\nโ€ข Warship L2 (HP: ${w2.maxHealth}, Dmg: ${w2.damageMin}-${w2.damageMax})\nโ€ข Submarine L2 (HP: ${s2.maxHealth}, Dmg: ${s2.damageMin}-${s2.damageMax})`; + } + case RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET: { + const w3 = WARSHIP_UPGRADES[2]; + return `Unlocks:\nโ€ข Warship L3 (HP: ${w3.maxHealth}, Dmg: ${w3.damageMin}-${w3.damageMax})\nโ€ข Ship Anti-Air: Defends fleet against air attacks.`; + } + case RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES: + return `Unlocks:\nโ€ข Nuclear Submarines: Submarines can launch nuclear missiles while submerged.`; + + // --- LAND --- + case RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS: + return `Unlocks:\nโ€ข Roads: Increases movement speed and generates trade income.\nโ€ข Hospitals: Increases population growth rate.`; + case RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY: + return `Unlocks:\nโ€ข Military Academy: Allows training of advanced units.\nโ€ข City Anti-Air: Defends city against air attacks.`; + case RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS: + return `Unlocks:\nโ€ข SAM Systems: Long-range surface-to-air missile defense.\nโ€ข SAM L2: Increased range and accuracy.`; + case RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE: + return `Unlocks:\nโ€ข Doomsday Device: Automatically launches all nukes if you are defeated.\nโ€ข SAM L3: Maximum range and accuracy.`; + + // --- AIR --- + case RESEARCH_TECH_IDS.AIR_PARATROOPERS: { + const f2 = FIGHTER_UPGRADES[1]; + return `Unlocks:\nโ€ข Paratroopers: Can drop infantry behind enemy lines.\nโ€ข Fighter L2 (HP: ${f2.maxHealth}, Dmg: ${f2.damageMin}-${f2.damageMax})`; + } + case RESEARCH_TECH_IDS.AIR_ADVANCED_JETS: { + const f3 = FIGHTER_UPGRADES[2]; + const b2 = BOMBER_UPGRADES[1]; + return `Unlocks:\nโ€ข Fighter L3 (HP: ${f3.maxHealth}, Dmg: ${f3.damageMin}-${f3.damageMax})\nโ€ข Bomber L2 (HP: ${b2.maxHealth}, Dmg: ${b2.damageMin}-${b2.damageMax}, Range: ${b2.targetRange})`; + } + case RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE: { + const f4 = FIGHTER_UPGRADES[3]; + const b3 = BOMBER_UPGRADES[2]; + return `Unlocks:\nโ€ข Fighter L4 (HP: ${f4.maxHealth}, Dmg: ${f4.damageMin}-${f4.damageMax})\nโ€ข Bomber L3 (HP: ${b3.maxHealth}, Dmg: ${b3.damageMin}-${b3.damageMax}, Range: ${b3.targetRange})\nโ€ข Naval Strike: Fighters can attack naval units.`; + } + + // --- NUCLEAR --- + case RESEARCH_TECH_IDS.NUCLEAR_FISSION: + return `Unlocks:\nโ€ข Atom Bomb: Basic nuclear weapon.\nโ€ข Missile Silo: Launch facility for nuclear weapons.`; + case RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING: + return `Unlocks:\nโ€ข Hydrogen Bomb: High-yield nuclear weapon. Larger blast radius.`; + case RESEARCH_TECH_IDS.MIRV_TECHNOLOGY: + return `Unlocks:\nโ€ข MIRV: Multiple Independent Reentry Vehicles. Harder to intercept.`; + + default: + return "No detailed information available."; + } +} diff --git a/src/client/stats/StatDefinitions.ts b/src/client/stats/StatDefinitions.ts index f03b6a4ed..c1fe48a27 100644 --- a/src/client/stats/StatDefinitions.ts +++ b/src/client/stats/StatDefinitions.ts @@ -184,14 +184,12 @@ export function computeStatValue( case "Land Techs": case "Sea Techs": case "Air Techs": - case "Nuclear Techs": - case "Economy Techs": { + case "Nuclear Techs": { const labelToCat: Record = { "Land Techs": "Land", "Sea Techs": "Sea", "Air Techs": "Air", "Nuclear Techs": "Nuclear", - "Economy Techs": "Economy", }; const cat = labelToCat[label]; const nodes = getTechNodes(); diff --git a/src/core/tech/PolicyDirectives.ts b/src/core/tech/PolicyDirectives.ts index 6eb2ac427..3e0546db5 100644 --- a/src/core/tech/PolicyDirectives.ts +++ b/src/core/tech/PolicyDirectives.ts @@ -7,11 +7,8 @@ import { RESEARCH_TECH_IDS } from "./TechIds"; // Policy directive identifiers export const POLICY_DIRECTIVE_IDS = { - NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "policy_research_industrial", + // Keep only Trade Policy framework - others were removed/simplified TRADE_POLICY_FRAMEWORK: "policy_trade_policy", - DIGITAL_ADMINISTRATION_SYSTEMS: "policy_digital_administration", - MECHANIZED_WARFARE_DOCTRINE: "policy_mechanized_warfare", - NIGHT_VISION_THERMAL_C3I: "policy_night_vision_thermal", } as const; export type PolicyDirectiveId = @@ -73,32 +70,6 @@ export interface PolicyDirective { export const POLICY_DIRECTIVES: Readonly< Record > = Object.freeze({ - [POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: { - id: POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - name: "National Research & Industrial Foundations", - description: - "Choose your nation's priority between industrial expansion and scientific institutions.", - unlockedByTech: RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - options: [ - { - id: "industrial_expansion", - name: "Industrial Expansion Priority", - description: "+5% domestic income, +20% construction speed", - effects: { - domesticIncomeMul: 1.05, - constructionSpeedMul: 1.2, - }, - }, - { - id: "scientific_institution", - name: "Scientific Institution Priority", - description: "+30% research spending effectiveness", - effects: { - researchEffectivenessMul: 1.3, - }, - }, - ], - }, [POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK]: { id: POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, name: "Trade Policy Framework", @@ -126,93 +97,6 @@ export const POLICY_DIRECTIVES: Readonly< }, ], }, - [POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: { - id: POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS, - name: "Digital Administration & Economic Coordination Systems", - description: - "Choose your nation's approach to digital administration and economic coordination.", - unlockedByTech: RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS, - options: [ - { - id: "market_optimization", - name: "Market Optimization Systems", - description: "+10% domestic income, -10% maintenance costs", - effects: { - domesticIncomeMul: 1.1, - // TODO: maintenanceCostMul: 0.90, // 10% reduction when maintenance is implemented - }, - }, - { - id: "central_planning", - name: "Central Planning Automation", - description: - "+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed", - effects: { - domesticIncomeMul: 1.05, - infrastructureSpendingEffectivenessMul: 1.2, - constructionSpeedMul: 1.1, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE]: { - id: POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE, - name: "Mechanized Warfare Doctrine", - description: - "Choose your tactical doctrine emphasis for mechanized operations.", - unlockedByTech: RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE, - options: [ - { - id: "mobile_infantry", - name: "Mobile Infantry Tactics", - description: - "-10% your losses when attacking, +10% enemy losses when they attack you", - effects: { - attackerLossMul: 0.9, - attackerLossMulOnDefense: 1.1, - }, - }, - { - id: "armored_breakthrough", - name: "Armored Breakthrough Doctrine", - description: - "+10% enemy losses when you attack, -10% your losses when defending", - effects: { - enemyLossMulOnAttack: 1.1, - defenderLossMul: 0.9, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I]: { - id: POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I, - name: "Night Vision, Thermal Imaging & Digital C3I", - description: - "Choose your night combat doctrine with thermal imaging and digital command systems.", - unlockedByTech: RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I, - options: [ - { - id: "high_tempo_maneuver", - name: "High-Tempo Maneuver Warfare", - description: - "+10% enemy losses when you attack, -10% your losses when attacking", - effects: { - enemyLossMulOnAttack: 1.1, - attackerLossMul: 0.9, - }, - }, - { - id: "precision_defensive", - name: "Precision Defensive Fire Doctrine", - description: - "+10% enemy losses when they attack you, -10% your losses when defending", - effects: { - attackerLossMulOnDefense: 1.1, - defenderLossMul: 0.9, - }, - }, - ], - }, }); /** diff --git a/src/core/tech/ResearchTree.ts b/src/core/tech/ResearchTree.ts index f709d4002..b8e85c54b 100644 --- a/src/core/tech/ResearchTree.ts +++ b/src/core/tech/ResearchTree.ts @@ -1,4 +1,4 @@ -export type Category = "Land" | "Sea" | "Air" | "Nuclear" | "Economy"; +export type Category = "Land" | "Sea" | "Air" | "Nuclear"; /** * Core tech node for tree structure - metadata (name, description) is in TechEffects.ts @@ -28,7 +28,7 @@ const baseLevels: TechNode[] = (() => { return nodes; })(); -// Nuclear branch techs (explicit definitions) +// Nuclear branch techs (explicit definitions) - 4 levels const nuclearTechs: TechNode[] = [ { id: "Nuclear-1", category: "Nuclear", level: 1, cost: costForLevel(1) }, { @@ -45,6 +45,7 @@ const nuclearTechs: TechNode[] = [ requiresAllOf: ["Nuclear-2"], cost: costForLevel(3), }, + // Level 4 - TBD (placeholder for future tech) { id: "Nuclear-4", category: "Nuclear", @@ -54,11 +55,11 @@ const nuclearTechs: TechNode[] = [ }, ]; -// Sea branch techs (explicit definitions) - Simplified linear tree +// Sea branch techs (explicit definitions) - 4 levels const seaTechs: TechNode[] = [ - // Level 1 - Early Missile Navy (unlocks Warship L2, Sub L2) + // Level 1 - Missile Navy (unlocks Warship L2, Sub L2) { id: "Sea-1", category: "Sea", level: 1, cost: costForLevel(1) }, - // Level 2 - Submarine Silent Service Modernization (unlocks Sub L3) + // Level 2 - Advanced Fleet (unlocks Warship L3, Ship Anti-Air) { id: "Sea-2", category: "Sea", @@ -66,7 +67,7 @@ const seaTechs: TechNode[] = [ requiresAllOf: ["Sea-1"], cost: costForLevel(2), }, - // Level 3 - SSBN Programs (unlocks SSBNs) + // Level 3 - Nuclear Submarines (unlocks Subs can launch nukes) { id: "Sea-3", category: "Sea", @@ -74,7 +75,7 @@ const seaTechs: TechNode[] = [ requiresAllOf: ["Sea-2"], cost: costForLevel(3), }, - // Level 4 - Modern Fleet Sensor & SAM Integration (unlocks Warship L3, Ship SAM) + // Level 4 - TBD (placeholder for future tech) { id: "Sea-4", category: "Sea", @@ -84,11 +85,13 @@ const seaTechs: TechNode[] = [ }, ]; -// Land branch techs (explicit definitions) - Simplified linear tree +// Land branch techs (explicit definitions) - 4 levels +// Reordered: Land-1 now Roads & Hospitals (moved from former Economy), +// and existing Land techs shift up one level. const landTechs: TechNode[] = [ - // Level 1 - Post-WW2 Ground Forces Modernization (unlocks Military Academy, AA Guns) + // Level 1 - Roads & Hospitals (previously Economy-1) { id: "Land-1", category: "Land", level: 1, cost: costForLevel(1) }, - // Level 2 - Mechanized Warfare Doctrine (unlocks Scorched Earth, policy directive) + // Level 2 - Military Academy { id: "Land-2", category: "Land", @@ -96,7 +99,7 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-1"], cost: costForLevel(2), }, - // Level 3 - Air-Defense Grid Expansion (unlocks SAM Level 2) + // Level 3 - SAM Systems { id: "Land-3", category: "Land", @@ -104,7 +107,7 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-2"], cost: costForLevel(3), }, - // Level 4 - Integrated SAM & Battlefield Command Systems (unlocks SAM Level 3) + // Level 4 - Doomsday Device { id: "Land-4", category: "Land", @@ -112,21 +115,13 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-3"], cost: costForLevel(4), }, - // Level 5 - Night Vision, Thermal Imaging & Digital C3I (policy directive) - { - id: "Land-5", - category: "Land", - level: 5, - requiresAllOf: ["Land-4"], - cost: costForLevel(5), - }, ]; -// Air branch techs (explicit definitions) - Simplified linear tree +// Air branch techs (explicit definitions) - 4 levels const airTechs: TechNode[] = [ - // Level 1 - Early Jet Aviation Framework (unlocks Paratroopers) + // Level 1 - Paratroopers (unlocks Paratroopers, Fighter L2) { id: "Air-1", category: "Air", level: 1, cost: costForLevel(1) }, - // Level 2 - Supersonic Airframe Development (unlocks Fighter L2, Bomber L2) + // Level 2 - Advanced Jets (unlocks Fighter L3, Bomber L2) { id: "Air-2", category: "Air", @@ -134,7 +129,7 @@ const airTechs: TechNode[] = [ requiresAllOf: ["Air-1"], cost: costForLevel(2), }, - // Level 3 - Pulse-Doppler Radar & BVR Combat (unlocks Fighter L3, Naval Strike) + // Level 3 - Naval Strike (unlocks Fighter L4, Bomber L3, Naval Strike) { id: "Air-3", category: "Air", @@ -142,7 +137,7 @@ const airTechs: TechNode[] = [ requiresAllOf: ["Air-2"], cost: costForLevel(3), }, - // Level 4 - Fly-By-Wire Platforms & Advanced Maneuverability (unlocks Fighter L4, Bomber L3) + // Level 4 - TBD (placeholder for future tech) { id: "Air-4", category: "Air", @@ -152,51 +147,12 @@ const airTechs: TechNode[] = [ }, ]; -// Economy branch techs (explicit definitions) - Linear 5-level tree -const economyTechs: TechNode[] = [ - // Level 1 - National Reconstruction Program (1950s): Roads, Hospitals, +20% infrastructure effectiveness, +20% road effects - { id: "Economy-1", category: "Economy", level: 1, cost: costForLevel(1) }, - // Level 2 - National Research & Industrial Foundations (1960s): Research Labs, policy directive - { - id: "Economy-2", - category: "Economy", - level: 2, - requiresAllOf: ["Economy-1"], - cost: costForLevel(2), - }, - // Level 3 - Trade Policy Framework (1970s): policy directive (Open Trade vs Autarky) - { - id: "Economy-3", - category: "Economy", - level: 3, - requiresAllOf: ["Economy-2"], - cost: costForLevel(3), - }, - // Level 4 - National Infrastructure Modernization (1980s): +20% infrastructure effectiveness, -20% maintenance, +10% construction speed - { - id: "Economy-4", - category: "Economy", - level: 4, - requiresAllOf: ["Economy-3"], - cost: costForLevel(4), - }, - // Level 5 - Digital Administration & Economic Coordination Systems (Early 1990s): policy directive - { - id: "Economy-5", - category: "Economy", - level: 5, - requiresAllOf: ["Economy-4"], - cost: costForLevel(5), - }, -]; - // Compose full tree const tree: TechNode[] = [ ...baseLevels, ...nuclearTechs, ...seaTechs, ...landTechs, - ...economyTechs, ...airTechs, ]; diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts index 22b98ac69..09513e6e4 100644 --- a/src/core/tech/TechEffects.ts +++ b/src/core/tech/TechEffects.ts @@ -11,6 +11,7 @@ export { RESEARCH_TECH_IDS } from "./TechIds"; export interface TechMeta { name: string; + shortDescription?: string; description?: string; } @@ -91,10 +92,11 @@ export type TechDefinition = { // Unified registry containing both metadata and effects per tech export const TECHS: Readonly> = Object.freeze({ - // Sea techs - Level 1: Early Missile Navy - [RESEARCH_TECH_IDS.EARLY_MISSILE_NAVY]: { + // Sea techs - Level 1: Missile Navy + [RESEARCH_TECH_IDS.SEA_MISSILE_NAVY]: { meta: { - name: "Early Missile Navy", + name: "Missile Navy", + shortDescription: "Warship L2, Sub L2", description: "Develop guided missile technology for naval warfare. Unlocks Warship Level 2, Submarine Level 2.", }, @@ -109,27 +111,32 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Sea techs - Level 2: Submarine Silent Service Modernization - [RESEARCH_TECH_IDS.SUBMARINE_SILENT_SERVICE]: { + // Sea techs - Level 2: Advanced Fleet + [RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET]: { meta: { - name: "Submarine Silent Service Modernization", + name: "Advanced Fleet", + shortDescription: "Warship L3, Ship AA", description: - "Advanced quieting and acoustic stealth for submarines. Unlocks Submarine Level 3.", + "Advanced naval systems and fleet integration. Unlocks Warship Level 3, Ship Anti-Air Systems.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) { - player.addUpgrade?.(UpgradeType.SubmarineLevel3); + if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) { + player.addUpgrade?.(UpgradeType.WarshipLevel3); + } + if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) { + player.addUpgrade?.(UpgradeType.WarshipAntiAir); } }, }, }, - // Sea techs - Level 3: SSBN Programs - [RESEARCH_TECH_IDS.SSBN_PROGRAMS]: { + // Sea techs - Level 3: Nuclear Submarines + [RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES]: { meta: { - name: "SSBN Programs", + name: "Nuclear Submarines", + shortDescription: "Subs can launch nukes", description: - "Ballistic missile submarine programs for strategic deterrence. Unlocks SSBNs (Submarines can launch nuclear weapons).", + "Ballistic missile submarine programs for strategic deterrence. Unlocks Submarines can launch nuclear weapons.", }, effects: { onComplete: (player) => { @@ -139,156 +146,62 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Sea techs - Level 4: Modern Fleet Sensor & SAM Integration - [RESEARCH_TECH_IDS.MODERN_FLEET_SENSOR_SAM]: { - meta: { - name: "Modern Fleet Sensor & SAM Integration", - description: - "Advanced sensor suites and integrated air defense systems for the fleet. Unlocks Warship Level 3, Ship SAM Systems.", - }, - effects: { - onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) { - player.addUpgrade?.(UpgradeType.WarshipLevel3); - } - if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) { - player.addUpgrade?.(UpgradeType.WarshipAntiAir); - } - }, - }, - }, - [RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION]: { + // Sea techs - Level 4: TBD + [RESEARCH_TECH_IDS.SEA_TBD_LEVEL4]: { meta: { - name: "Post-WW2 Ground Forces Modernization", - description: - "Doctrine refined by hard-won experience improves offensive capabilities and tactical efficiency. Effects: Enables Military Academy, AA Guns. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when you attack, -10% your losses when defending.", - }, - effects: { - onComplete: (player, game) => { - if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { - player.addUpgrade?.(UpgradeType.MilitaryAcademy); - } - if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) { - player.addUpgrade?.(UpgradeType.CityAntiAir); - // Start the city AA execution to fire bullets at planes - game.addExecution(new CityAAExecution(player)); - } - }, - attack: (mods) => { - mods.defenderLossMul *= 1.1; // enemy (defender) takes 10% more losses when we attack - }, - defense: (mods) => { - mods.defenderLossMul *= 0.9; // we take 10% less losses when defending - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, + name: "Sea Level 4 - TBD", + shortDescription: "Placeholder sea tech", + description: "Placeholder for future sea-based technology.", }, }, - [RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM]: { + // Land techs - Level 1: Roads & Hospitals (moved from Economy) + [RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS]: { meta: { - name: "National Reconstruction Program", + name: "Roads & Hospitals", + shortDescription: "Unlocks Roads, Hospitals", description: - "Revitalize infrastructure and industry by mobilizing civilian labor and resources to rebuild the national economy. Effects: Enables Roads, Hospitals. +20% infrastructure spending effectiveness, +20% stronger road effects.", + "Infrastructure and medical systems. Unlocks Roads, Hospitals.", }, effects: { onComplete: (player, game) => { - // Unlock Roads upgrade and trigger reconnection if (!player.hasUpgrade?.(UpgradeType.Roads)) { player.addUpgrade?.(UpgradeType.Roads); game.markPlayerNodesForReconnection?.(player); } - if (player.hasUpgrade?.(UpgradeType.ScorchedEarth)) { - player.removeUpgrade?.(UpgradeType.ScorchedEarth); - } - // Unlock Hospitals if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) { player.addUpgrade?.(UpgradeType.HospitalResearch); } }, - infrastructureEffectiveness: (mods) => { - mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness - }, - roadEffect: (mods) => { - mods.effectMul *= 1.2; // +20% stronger road effects - }, }, }, - // Economy Level 2 tech - National Research & Industrial Foundations (1960s) - [RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: { + // Land techs - Level 2: Military Academy + [RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY]: { meta: { - name: "National Research & Industrial Foundations", + name: "Military Academy", + shortDescription: "Academy building, City AA", description: - "Establish national research institutions and industrial base. Effects: Enables Research Labs. Policy Directive: Industrial Expansion Priority (+5% domestic income, +20% construction speed) or Scientific Institution Priority (+30% research spending effectiveness).", + "Establish military training infrastructure. Unlocks Military Academy building, City Anti-Air systems.", }, effects: { - onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.ResearchLabResearch)) { - player.addUpgrade?.(UpgradeType.ResearchLabResearch); + onComplete: (player, game) => { + if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { + player.addUpgrade?.(UpgradeType.MilitaryAcademy); + } + if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) { + player.addUpgrade?.(UpgradeType.CityAntiAir); + // Start the city AA execution to fire bullets at planes + game.addExecution(new CityAAExecution(player)); } }, - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Economy Level 3 tech - Trade Policy Framework (1970s) - [RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK]: { - meta: { - name: "Trade Policy Framework", - description: - "Establish trade agreements and commercial policies. Policy Directive: Open Trade Policy (+5% trade income, +5% trade ship income) or Autarky Doctrine (disables international trade, +20% domestic income).", - }, - effects: { - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Economy Level 4 tech - National Infrastructure Modernization (1980s) - [RESEARCH_TECH_IDS.NATIONAL_INFRASTRUCTURE_MODERNIZATION]: { - meta: { - name: "National Infrastructure Modernization", - description: - "Modernize national infrastructure with advanced technology. Effects: +20% infrastructure spending effectiveness, -20% maintenance costs, +10% construction speed.", - }, - effects: { - infrastructureEffectiveness: (mods) => { - mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness - }, - constructionSpeed: (mods) => { - mods.speedMul *= 1.1; // +10% construction speed - }, - // TODO: -20% maintenance costs when maintenance is implemented - }, - }, - // Economy Level 5 tech - Digital Administration & Economic Coordination Systems (Early 1990s) - [RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: { - meta: { - name: "Digital Administration & Economic Coordination Systems", - description: - "Digital systems for administration and economic coordination. Policy Directive: Market Optimization Systems (+10% domestic income, -10% maintenance costs) or Central Planning Automation (+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed).", - }, - effects: { - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Land Level 2 tech - Mechanized Warfare Doctrine (1960s) - [RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE]: { - meta: { - name: "Mechanized Warfare Doctrine", - description: - "Develop doctrine for mechanized infantry and armored operations. Effects: Unlocks Scorched Earth. +5% offensive speed. Policy Directive (20%): Mobile Infantry Tactics (-10% your losses attacking, +10% enemy losses when they attack you) or Armored Breakthrough Doctrine (+10% enemy losses when you attack, -10% your losses when defending).", - }, - effects: { - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, - // Policy directive effects are applied via getPolicyChoice }, }, - // Land Level 3 tech - Air-Defense Grid Expansion (1970s) - [RESEARCH_TECH_IDS.AIR_DEFENSE_GRID_EXPANSION]: { + // Land techs - Level 3: SAM Systems + [RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS]: { meta: { - name: "Air-Defense Grid Expansion", + name: "SAM Systems", + shortDescription: "AA Guns, SAM L2", description: - "Expand air defense networks with improved SAM coverage. Effects: Enables SAM Level 2. +5% offensive speed. Casualty Effects (20%): +15% enemy losses when they attack you, -5% your losses when defending.", + "Surface-to-air missile system deployment. Unlocks AA Guns, SAM Level 2.", }, effects: { onComplete: (player) => { @@ -296,110 +209,72 @@ export const TECHS: Readonly> = Object.freeze({ player.addUpgrade?.(UpgradeType.SAMLevel2); } }, - defense: (mods) => { - mods.attackerLossMul *= 1.15; // enemy takes 15% more losses when they attack us - mods.defenderLossMul *= 0.95; // we take 5% less losses when defending - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, }, }, - // Land Level 4 tech - Integrated SAM & Battlefield Command Systems (1980s) - [RESEARCH_TECH_IDS.INTEGRATED_SAM_BATTLEFIELD_COMMAND]: { + // Land techs - Level 4: Doomsday Device + [RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE]: { meta: { - name: "Integrated SAM & Battlefield Command Systems", + name: "Doomsday Device", + shortDescription: "SAM L3, Doomsday", description: - "Integrate SA-10, Patriot-era SAM platforms with C3I systems. Effects: Enables SAM Level 3. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when they attack you, -10% your losses when attacking.", + "Ultimate defensive deterrent system. Unlocks SAM Level 3, Doomsday Device.", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.SAMLevel3)) { player.addUpgrade?.(UpgradeType.SAMLevel3); } + if (!player.hasUpgrade?.(UpgradeType.DoomsdayDeviceResearch)) { + player.addUpgrade?.(UpgradeType.DoomsdayDeviceResearch); + } }, - defense: (mods) => { - mods.attackerLossMul *= 1.1; // enemy takes 10% more losses when they attack us - }, - attack: (mods) => { - mods.attackerLossMul *= 0.9; // we take 10% less losses when attacking - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, - }, - }, - // Land Level 5 tech - Night Vision, Thermal Imaging & Digital C3I (Early 1990s) - [RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I]: { - meta: { - name: "Night Vision, Thermal Imaging & Digital C3I", - description: - "Equip forces with night vision, thermal imaging, and digital command systems for 24-hour combat capability. Effects: +5% offensive speed. Policy Directive (20%): High-Tempo Maneuver Warfare (+10% enemy losses when you attack, -10% your losses when attacking) or Precision Defensive Fire Doctrine (+10% enemy losses when they attack you, -10% your losses when defending).", - }, - effects: { - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, - // Policy directive effects are applied via getPolicyChoice }, }, - // Air techs - Level 1: Early Jet Aviation Framework - [RESEARCH_TECH_IDS.EARLY_JET_AVIATION_FRAMEWORK]: { + // Air techs - Level 1: Paratroopers + [RESEARCH_TECH_IDS.AIR_PARATROOPERS]: { meta: { - name: "Early Jet Aviation Framework", + name: "Paratroopers", + shortDescription: "Paratroopers, Fighter L2", description: - "Establish jet aviation infrastructure and doctrine. Unlocks Paratroopers.", + "Airborne infantry and assault doctrine. Unlocks Paratroopers, Fighter Level 2.", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.AirUpgrade1)) { player.addUpgrade?.(UpgradeType.AirUpgrade1); } - }, - }, - }, - // Air techs - Level 2: Supersonic Airframe Development - [RESEARCH_TECH_IDS.SUPERSONIC_AIRFRAME_DEVELOPMENT]: { - meta: { - name: "Supersonic Airframe Development", - description: - "Develop supersonic aircraft designs. Unlocks Fighter Level 2, Bomber Level 2.", - }, - effects: { - onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.FighterLevel2)) { player.addUpgrade?.(UpgradeType.FighterLevel2); } - if (!player.hasUpgrade?.(UpgradeType.BomberLevel2)) { - player.addUpgrade?.(UpgradeType.BomberLevel2); - } }, }, }, - // Air techs - Level 3: Pulse-Doppler Radar & BVR Combat - [RESEARCH_TECH_IDS.PULSE_DOPPLER_RADAR_BVR]: { + // Air techs - Level 2: Advanced Jets + [RESEARCH_TECH_IDS.AIR_ADVANCED_JETS]: { meta: { - name: "Pulse-Doppler Radar & BVR Combat", + name: "Advanced Jets", + shortDescription: "Fighter L3, Bomber L2", description: - "Advanced radar and beyond-visual-range combat systems. Unlocks Fighter Level 3, Naval Strike Capability.", + "Next-generation aircraft systems. Unlocks Fighter Level 3, Bomber Level 2.", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.FighterLevel3)) { player.addUpgrade?.(UpgradeType.FighterLevel3); } - if (!player.hasUpgrade?.(UpgradeType.FighterJetNavalTargeting)) { - player.addUpgrade?.(UpgradeType.FighterJetNavalTargeting); + if (!player.hasUpgrade?.(UpgradeType.BomberLevel2)) { + player.addUpgrade?.(UpgradeType.BomberLevel2); } }, }, }, - // Air techs - Level 4: Fly-By-Wire Platforms & Advanced Maneuverability - [RESEARCH_TECH_IDS.FLY_BY_WIRE_PLATFORMS]: { + // Air techs - Level 3: Naval Strike + [RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE]: { meta: { - name: "Fly-By-Wire Platforms & Advanced Maneuverability", + name: "Naval Strike", + shortDescription: "Fighter L4, Bomber L3, Anti-ship", description: - "Digital flight control systems for maximum aircraft performance. Unlocks Fighter Level 4, Bomber Level 3.", + "Advanced naval attack capability for aircraft. Unlocks Fighter Level 4, Bomber Level 3, Naval strike weapons.", }, effects: { onComplete: (player) => { @@ -409,25 +284,41 @@ export const TECHS: Readonly> = Object.freeze({ if (!player.hasUpgrade?.(UpgradeType.BomberLevel3)) { player.addUpgrade?.(UpgradeType.BomberLevel3); } + if (!player.hasUpgrade?.(UpgradeType.FighterJetNavalTargeting)) { + player.addUpgrade?.(UpgradeType.FighterJetNavalTargeting); + } }, }, }, + // Air techs - Level 4: TBD + [RESEARCH_TECH_IDS.AIR_TBD_LEVEL4]: { + meta: { + name: "Air Level 4 - TBD", + shortDescription: "Placeholder air tech", + description: "Placeholder for future air-based technology.", + }, + }, + // Nuclear techs - Level 1: Nuclear Fission [RESEARCH_TECH_IDS.NUCLEAR_FISSION]: { meta: { name: "Nuclear Fission", - description: "Enables: Atom Bomb", + shortDescription: "Enables Atom Bomb, Silo", + description: "Enables: Atom Bomb, Silo", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.NuclearFission)) { player.addUpgrade?.(UpgradeType.NuclearFission); } + // Note: MissileSilo building is unlocked via gameplay progression }, }, }, + // Nuclear techs - Level 2: Thermonuclear Staging [RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING]: { meta: { name: "Thermonuclear Staging", + shortDescription: "Enables Hydrogen Bomb", description: "Enables: Hydrogen Bomb", }, effects: { @@ -438,9 +329,11 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, + // Nuclear techs - Level 3: MIRV Technology [RESEARCH_TECH_IDS.MIRV_TECHNOLOGY]: { meta: { name: "MIRV Technology", + shortDescription: "Enables MIRV", description: "Enables: MIRV", }, effects: { @@ -451,17 +344,12 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - [RESEARCH_TECH_IDS.DOOMSDAY_DEVICE]: { + // Nuclear techs - Level 4: TBD + [RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4]: { meta: { - name: "Doomsday Device", - description: "Enables: Doomsday Device", - }, - effects: { - onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.DoomsdayDeviceResearch)) { - player.addUpgrade?.(UpgradeType.DoomsdayDeviceResearch); - } - }, + name: "Nuclear Level 4 - TBD", + shortDescription: "Placeholder nuclear tech", + description: "Placeholder for future nuclear technology.", }, }, }); diff --git a/src/core/tech/TechIds.ts b/src/core/tech/TechIds.ts index 2835309cf..93815bea8 100644 --- a/src/core/tech/TechIds.ts +++ b/src/core/tech/TechIds.ts @@ -1,49 +1,54 @@ /** * Central tech IDs for research tree items. * This file has NO dependencies to prevent circular imports. - * Keep IDs aligned with ResearchTreeModal generation (e.g., "Land-1"). + * Keep IDs aligned with ResearchTree definitions (e.g., "Land-1"). */ export const RESEARCH_TECH_IDS = { - // Air techs - Level 1 + // Air techs + AIR_PARATROOPERS: "Air-1", + AIR_ADVANCED_JETS: "Air-2", + AIR_NAVAL_STRIKE: "Air-3", + AIR_TBD_LEVEL4: "Air-4", + // Sea techs + SEA_MISSILE_NAVY: "Sea-1", + SEA_ADVANCED_FLEET: "Sea-2", + SEA_NUCLEAR_SUBMARINES: "Sea-3", + SEA_TBD_LEVEL4: "Sea-4", + // Land techs + LAND_ROADS_HOSPITALS: "Land-1", + LAND_MILITARY_ACADEMY: "Land-2", + LAND_SAM_SYSTEMS: "Land-3", + LAND_DOOMSDAY_DEVICE: "Land-4", + // Economy techs (legacy; category removed, map roads to Land-1 for back-compat) + ECONOMY_ROADS_HOSPITALS: "Land-1", + ECONOMY_INTERNATIONAL_TRADE: "Economy-2", + ECONOMY_INSURANCE: "Economy-3", + ECONOMY_TBD_LEVEL4: "Economy-4", + // Nuclear techs + NUCLEAR_FISSION: "Nuclear-1", + THERMONUCLEAR_STAGING: "Nuclear-2", + MIRV_TECHNOLOGY: "Nuclear-3", + NUCLEAR_TBD_LEVEL4: "Nuclear-4", + + // Legacy mappings for backwards compatibility during migration EARLY_JET_AVIATION_FRAMEWORK: "Air-1", - // Air techs - Level 2 SUPERSONIC_AIRFRAME_DEVELOPMENT: "Air-2", - // Air techs - Level 3 PULSE_DOPPLER_RADAR_BVR: "Air-3", - // Air techs - Level 4 FLY_BY_WIRE_PLATFORMS: "Air-4", - // Sea techs - Level 1 EARLY_MISSILE_NAVY: "Sea-1", - // Sea techs - Level 2 SUBMARINE_SILENT_SERVICE: "Sea-2", - // Sea techs - Level 3 SSBN_PROGRAMS: "Sea-3", - // Sea techs - Level 4 MODERN_FLEET_SENSOR_SAM: "Sea-4", - // Land techs - Level 1 POST_WW2_GROUND_FORCES_MODERNIZATION: "Land-1", - // Land techs - Level 2 MECHANIZED_WARFARE_DOCTRINE: "Land-2", - // Land techs - Level 3 AIR_DEFENSE_GRID_EXPANSION: "Land-3", - // Land techs - Level 4 INTEGRATED_SAM_BATTLEFIELD_COMMAND: "Land-4", - // Land techs - Level 5 NIGHT_VISION_THERMAL_C3I: "Land-5", - // Economy techs - Level 1 (1950s) NATIONAL_RECONSTRUCTION_PROGRAM: "Economy-1", - // Economy techs - Level 2 (1960s) NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "Economy-2", - // Economy techs - Level 3 (1970s) TRADE_POLICY_FRAMEWORK: "Economy-3", - // Economy techs - Level 4 (1980s) NATIONAL_INFRASTRUCTURE_MODERNIZATION: "Economy-4", - // Economy techs - Level 5 (Early 1990s) DIGITAL_ADMINISTRATION_SYSTEMS: "Economy-5", - // Nuclear techs - NUCLEAR_FISSION: "Nuclear-1", - THERMONUCLEAR_STAGING: "Nuclear-2", - MIRV_TECHNOLOGY: "Nuclear-3", DOOMSDAY_DEVICE: "Nuclear-4", } as const; diff --git a/tests/core/game/PlayerImpl.tech.test.ts b/tests/core/game/PlayerImpl.tech.test.ts index 6d17a27de..3d649a5f5 100644 --- a/tests/core/game/PlayerImpl.tech.test.ts +++ b/tests/core/game/PlayerImpl.tech.test.ts @@ -5,48 +5,38 @@ import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects"; import { playerInfo, setup } from "../../util/Setup"; describe("PlayerImpl.removeResearchedTechsByCategory", () => { - it("revokes economy techs and clears associated progress", async () => { + it("revokes land techs and clears associated progress", async () => { const game = (await setup("ocean_and_land")) as GameImpl; const info = playerInfo("Tester", PlayerType.Human); game.addPlayer(info); const player = game.player(info.id) as PlayerImpl; - player.addResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - player.addResearchBeakers("Economy-4", 500, 1_000); - player.setResearchPriority("Economy-4"); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE); + player.addResearchBeakers("Land-4", 500, 1_000); + player.setResearchPriority("Land-4"); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.researchBeakers("Economy-4")).toBe(500); + expect(player.researchBeakers("Land-4")).toBe(500); - player.removeResearchedTechsByCategory("Economy"); + player.removeResearchedTechsByCategory("Land"); expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ), - ).toBe(true); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS), ).toBe(false); expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY), ).toBe(false); + expect(player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS)).toBe( + false, + ); // Upgrades are NOT removed by removeResearchedTechsByCategory - only techs and progress expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.researchBeakers("Economy-4")).toBe(0); + expect(player.researchBeakers("Land-4")).toBe(0); expect(player.researchPriority()).toBeNull(); }); }); diff --git a/tests/core/tech/EconomyTechEffects.test.ts b/tests/core/tech/EconomyTechEffects.test.ts index d3cb3980f..433d223fc 100644 --- a/tests/core/tech/EconomyTechEffects.test.ts +++ b/tests/core/tech/EconomyTechEffects.test.ts @@ -1,75 +1,27 @@ import { PlayerType, UpgradeType } from "../../../src/core/game/Game"; import { GameImpl } from "../../../src/core/game/GameImpl"; import { PlayerImpl } from "../../../src/core/game/PlayerImpl"; -import { POLICY_DIRECTIVE_IDS } from "../../../src/core/tech/PolicyDirectives"; import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects"; import { playerInfo, setup } from "../../util/Setup"; -describe("Economy tech integrations", () => { - it("enables Roads after researching National Reconstruction Program", async () => { +describe("Land infrastructure tech integrations", () => { + it("enables Roads after researching Roads & Hospitals", async () => { const info = playerInfo("builder", PlayerType.Human); const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; expect(player.hasUpgrade(UpgradeType.Roads)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); }); - it("enables InternationalTrade after choosing Open Trade policy", async () => { - const info = playerInfo("trader", PlayerType.Human); - const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; - const player = game.player(info.id) as PlayerImpl; - - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - // Tech alone doesn't grant the upgrade anymore - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - - // Choosing Open Trade policy grants the upgrade - player.setPolicyChoice( - POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - "open_trade", - ); - player.addUpgrade(UpgradeType.InternationalTrade); // Simulating what execution does - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(true); - }); - - it("Autarky policy does not grant InternationalTrade", async () => { - const info = playerInfo("autarky", PlayerType.Human); - const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; - const player = game.player(info.id) as PlayerImpl; - - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - - // Choosing Autarky policy does NOT grant the upgrade - player.setPolicyChoice( - POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - "autarky", - ); - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - }); - - // TEMPORARILY DISABLED: Structure insurance tests - // it("refunds 33% of a structure's cost on destruction with Infrastructure Recovery Fund", ...) - // it("refunds insured structures when conquered", ...) - - it("enables HospitalResearch after researching National Reconstruction Program", async () => { + it("enables HospitalResearch after researching Roads & Hospitals", async () => { const info = playerInfo("health", PlayerType.Human); const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; - // Hospitals are unlocked at Level 1 now (National Reconstruction Program) expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); }); @@ -78,15 +30,13 @@ describe("Economy tech integrations", () => { const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - player.removeResearchedTechsByCategory("Economy"); + player.removeResearchedTechsByCategory("Land"); // Techs are removed but upgrades remain expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS), ).toBe(false); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); }); From c859e436abc33e891023a1486cb502b115317463 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 02:01:43 +0100 Subject: [PATCH 02/16] chore: refine tech tooltips and effects - Tweaked research tree UI text and tooltip content (short vs detailed) - Updated tech tooltips with clearer unlock summaries and numbers - Refactored tech effects unlock logic and cleaned definitions --- src/client/ResearchTreeModal.ts | 4 +- src/client/TechTooltips.ts | 39 +++--- src/core/tech/TechEffects.ts | 208 ++++++++++++++++++-------------- 3 files changed, 143 insertions(+), 108 deletions(-) diff --git a/src/client/ResearchTreeModal.ts b/src/client/ResearchTreeModal.ts index 77d6faaeb..d40e75bd8 100644 --- a/src/client/ResearchTreeModal.ts +++ b/src/client/ResearchTreeModal.ts @@ -147,7 +147,9 @@ export class ResearchTreeModal extends LitElement { return html`
- Investment: + Investment: ${percent}%
> = Object.freeze({ - // Sea techs - Level 1: Missile Navy + // Sea techs - Level 1: Maritime Warfare [RESEARCH_TECH_IDS.SEA_MISSILE_NAVY]: { meta: { - name: "Missile Navy", - shortDescription: "Warship L2, Sub L2", + name: "Maritime Warfare", + shortDescription: "Cruisers, Diesel-Electric Subs", description: - "Develop guided missile technology for naval warfare. Unlocks Warship Level 2, Submarine Level 2.", + "Develop naval warfare capabilities. Unlocks Cruisers, Diesel-Electric Submarines.", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.WarshipLevel2)) { player.addUpgrade?.(UpgradeType.WarshipLevel2); } - if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) { - player.addUpgrade?.(UpgradeType.SubmarineLevel2); + if (!player.hasUpgrade?.(UpgradeType.SubmarineResearch)) { + player.addUpgrade?.(UpgradeType.SubmarineResearch); + } + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel1)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel1); } }, }, }, - // Sea techs - Level 2: Advanced Fleet + // Sea techs - Level 2: Fleet Modernization [RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET]: { meta: { - name: "Advanced Fleet", - shortDescription: "Warship L3, Ship AA", + name: "Fleet Modernization", + shortDescription: "Aegis, Tactical Subs", description: - "Advanced naval systems and fleet integration. Unlocks Warship Level 3, Ship Anti-Air Systems.", + "Advanced naval systems and fleet integration. Unlocks Aegis Warships and Tactical Submarines.", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) { player.addUpgrade?.(UpgradeType.WarshipLevel3); } - if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) { - player.addUpgrade?.(UpgradeType.WarshipAntiAir); + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel2); } }, }, }, - // Sea techs - Level 3: Nuclear Submarines + // Sea techs - Level 3: Submarine Dominance [RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES]: { meta: { - name: "Nuclear Submarines", - shortDescription: "Subs can launch nukes", + name: "Submarine Dominance", + shortDescription: "Attack Subs, Ship Anti-Air", description: - "Ballistic missile submarine programs for strategic deterrence. Unlocks Submarines can launch nuclear weapons.", + "Advanced submarine technology and fleet air defense. Unlocks Attack Submarines and Ship Anti-Air Systems.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) { - player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch); + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel3); + } + if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) { + player.addUpgrade?.(UpgradeType.WarshipAntiAir); } }, }, }, - // Sea techs - Level 4: TBD + // Sea techs - Level 4: Strategic Deterrent [RESEARCH_TECH_IDS.SEA_TBD_LEVEL4]: { meta: { - name: "Sea Level 4 - TBD", - shortDescription: "Placeholder sea tech", - description: "Placeholder for future sea-based technology.", + name: "Strategic Deterrent", + shortDescription: "Nuclear Sub", + description: + "Ballistic missile submarine programs for strategic deterrence. Unlocks Nuclear Submarines (can launch nuclear weapons while submerged).", + }, + effects: { + onComplete: (player) => { + if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) { + player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch); + } + }, }, }, - // Land techs - Level 1: Roads & Hospitals (moved from Economy) + // Land techs - Level 1: Road Network [RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS]: { meta: { - name: "Roads & Hospitals", - shortDescription: "Unlocks Roads, Hospitals", + name: "Road Network", + shortDescription: "Roads, Trade Routes", description: - "Infrastructure and medical systems. Unlocks Roads, Hospitals.", + "Infrastructure development. Unlocks Roads and Trade Routes.", }, effects: { onComplete: (player, game) => { @@ -168,99 +182,93 @@ export const TECHS: Readonly> = Object.freeze({ player.addUpgrade?.(UpgradeType.Roads); game.markPlayerNodesForReconnection?.(player); } - if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) { - player.addUpgrade?.(UpgradeType.HospitalResearch); - } }, }, }, - // Land techs - Level 2: Military Academy + // Land techs - Level 2: Ground Air Defense [RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY]: { meta: { - name: "Military Academy", - shortDescription: "Academy building, City AA", + name: "Ground Air Defense", + shortDescription: "City Anti-Air, Improved SAM", description: - "Establish military training infrastructure. Unlocks Military Academy building, City Anti-Air systems.", + "Defensive infrastructure. Unlocks City Anti-Air systems and Improved SAM.", }, effects: { onComplete: (player, game) => { - if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { - player.addUpgrade?.(UpgradeType.MilitaryAcademy); - } if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) { player.addUpgrade?.(UpgradeType.CityAntiAir); // Start the city AA execution to fire bullets at planes game.addExecution(new CityAAExecution(player)); } + if (!player.hasUpgrade?.(UpgradeType.SAMLevel2)) { + player.addUpgrade?.(UpgradeType.SAMLevel2); + } }, }, }, - // Land techs - Level 3: SAM Systems + // Land techs - Level 3: Modern Air Defense [RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS]: { meta: { - name: "SAM Systems", - shortDescription: "AA Guns, SAM L2", + name: "Modern Air Defense", + shortDescription: "Advanced SAM, Hospitals", description: - "Surface-to-air missile system deployment. Unlocks AA Guns, SAM Level 2.", + "Upgraded defensive and medical systems. Unlocks Advanced SAM and Hospitals.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.SAMLevel2)) { - player.addUpgrade?.(UpgradeType.SAMLevel2); + if (!player.hasUpgrade?.(UpgradeType.SAMLevel3)) { + player.addUpgrade?.(UpgradeType.SAMLevel3); + } + if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) { + player.addUpgrade?.(UpgradeType.HospitalResearch); } }, }, }, - // Land techs - Level 4: Doomsday Device + // Land techs - Level 4: Military Academy [RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE]: { meta: { - name: "Doomsday Device", - shortDescription: "SAM L3, Doomsday", + name: "Military Academy", + shortDescription: "Academy building", description: - "Ultimate defensive deterrent system. Unlocks SAM Level 3, Doomsday Device.", + "Establish military training infrastructure. Unlocks Military Academy building.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.SAMLevel3)) { - player.addUpgrade?.(UpgradeType.SAMLevel3); - } - if (!player.hasUpgrade?.(UpgradeType.DoomsdayDeviceResearch)) { - player.addUpgrade?.(UpgradeType.DoomsdayDeviceResearch); + if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { + player.addUpgrade?.(UpgradeType.MilitaryAcademy); } }, }, }, - // Air techs - Level 1: Paratroopers + // Air techs - Level 1: Early Air Power [RESEARCH_TECH_IDS.AIR_PARATROOPERS]: { meta: { - name: "Paratroopers", - shortDescription: "Paratroopers, Fighter L2", + name: "Early Air Power", + shortDescription: "Gen 1 Fighters, Paratroopers", description: - "Airborne infantry and assault doctrine. Unlocks Paratroopers, Fighter Level 2.", + "Airborne infantry and assault doctrine. Unlocks 1st Generation Fighters, Paratroopers.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.AirUpgrade1)) { - player.addUpgrade?.(UpgradeType.AirUpgrade1); - } - if (!player.hasUpgrade?.(UpgradeType.FighterLevel2)) { - player.addUpgrade?.(UpgradeType.FighterLevel2); + if (!player.hasUpgrade?.(UpgradeType.JetEngines)) { + player.addUpgrade?.(UpgradeType.JetEngines); } }, }, }, - // Air techs - Level 2: Advanced Jets + // Air techs - Level 2: Jet Technology [RESEARCH_TECH_IDS.AIR_ADVANCED_JETS]: { meta: { - name: "Advanced Jets", - shortDescription: "Fighter L3, Bomber L2", + name: "Jet Technology", + shortDescription: "Gen 2 Fighters, Heavy Bombers", description: - "Next-generation aircraft systems. Unlocks Fighter Level 3, Bomber Level 2.", + "Next-generation aircraft systems. Unlocks 2nd Generation Fighters, Heavy Bombers.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.FighterLevel3)) { - player.addUpgrade?.(UpgradeType.FighterLevel3); + if (!player.hasUpgrade?.(UpgradeType.FighterLevel2)) { + player.addUpgrade?.(UpgradeType.FighterLevel2); } if (!player.hasUpgrade?.(UpgradeType.BomberLevel2)) { player.addUpgrade?.(UpgradeType.BomberLevel2); @@ -268,21 +276,18 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Air techs - Level 3: Naval Strike + // Air techs - Level 3: Anti-Ship Warfare [RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE]: { meta: { - name: "Naval Strike", - shortDescription: "Fighter L4, Bomber L3, Anti-ship", + name: "Anti-Ship Warfare", + shortDescription: "Gen 3 Fighters, Anti-ship", description: - "Advanced naval attack capability for aircraft. Unlocks Fighter Level 4, Bomber Level 3, Naval strike weapons.", + "Advanced naval attack capability for aircraft. Unlocks 3rd Generation Fighters, Naval strike weapons.", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.FighterLevel4)) { - player.addUpgrade?.(UpgradeType.FighterLevel4); - } - if (!player.hasUpgrade?.(UpgradeType.BomberLevel3)) { - player.addUpgrade?.(UpgradeType.BomberLevel3); + if (!player.hasUpgrade?.(UpgradeType.FighterLevel3)) { + player.addUpgrade?.(UpgradeType.FighterLevel3); } if (!player.hasUpgrade?.(UpgradeType.FighterJetNavalTargeting)) { player.addUpgrade?.(UpgradeType.FighterJetNavalTargeting); @@ -293,17 +298,27 @@ export const TECHS: Readonly> = Object.freeze({ // Air techs - Level 4: TBD [RESEARCH_TECH_IDS.AIR_TBD_LEVEL4]: { meta: { - name: "Air Level 4 - TBD", - shortDescription: "Placeholder air tech", - description: "Placeholder for future air-based technology.", + name: "Advanced Fighters", + shortDescription: "Gen 4 Fighters, Supersonic Bombers", + description: "Unlocks 4th Generation Fighters, Supersonic Bombers.", + }, + effects: { + onComplete: (player) => { + if (!player.hasUpgrade?.(UpgradeType.FighterLevel4)) { + player.addUpgrade?.(UpgradeType.FighterLevel4); + } + if (!player.hasUpgrade?.(UpgradeType.BomberLevel3)) { + player.addUpgrade?.(UpgradeType.BomberLevel3); + } + }, }, }, - // Nuclear techs - Level 1: Nuclear Fission + // Nuclear techs - Level 1: Atomic Weapons [RESEARCH_TECH_IDS.NUCLEAR_FISSION]: { meta: { - name: "Nuclear Fission", - shortDescription: "Enables Atom Bomb, Silo", - description: "Enables: Atom Bomb, Silo", + name: "Atomic Weapons", + shortDescription: "Atom Bomb, Silo", + description: "Atom Bomb, Silo", }, effects: { onComplete: (player) => { @@ -314,12 +329,12 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Nuclear techs - Level 2: Thermonuclear Staging + // Nuclear techs - Level 2: Thermonuclear Weapons [RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING]: { meta: { - name: "Thermonuclear Staging", - shortDescription: "Enables Hydrogen Bomb", - description: "Enables: Hydrogen Bomb", + name: "Thermonuclear Weapons", + shortDescription: "Hydrogen Bomb", + description: "Hydrogen Bomb", }, effects: { onComplete: (player) => { @@ -329,12 +344,12 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Nuclear techs - Level 3: MIRV Technology + // Nuclear techs - Level 3: MIRV Warheads [RESEARCH_TECH_IDS.MIRV_TECHNOLOGY]: { meta: { - name: "MIRV Technology", - shortDescription: "Enables MIRV", - description: "Enables: MIRV", + name: "MIRV Warheads", + shortDescription: "MIRV", + description: "MIRV", }, effects: { onComplete: (player) => { @@ -347,9 +362,16 @@ export const TECHS: Readonly> = Object.freeze({ // Nuclear techs - Level 4: TBD [RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4]: { meta: { - name: "Nuclear Level 4 - TBD", - shortDescription: "Placeholder nuclear tech", - description: "Placeholder for future nuclear technology.", + name: "Doomsday Device", + shortDescription: "Global deterrence", + description: "Unlocks Doomsday Device (auto-launches nukes on defeat).", + }, + effects: { + onComplete: (player) => { + if (!player.hasUpgrade?.(UpgradeType.DoomsdayDeviceResearch)) { + player.addUpgrade?.(UpgradeType.DoomsdayDeviceResearch); + } + }, }, }, }); From 2b51407a3360c57e7549e1a5730c575482da777f Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 14:02:22 +0100 Subject: [PATCH 03/16] Clarify tech descriptions and tooltips --- src/client/TechTooltips.ts | 32 ++++++++++++++--------------- src/core/tech/TechEffects.ts | 39 ++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/client/TechTooltips.ts b/src/client/TechTooltips.ts index ea99237b0..646682d4e 100644 --- a/src/client/TechTooltips.ts +++ b/src/client/TechTooltips.ts @@ -12,59 +12,59 @@ export function getDetailedTechTooltip(techId: string): string { case RESEARCH_TECH_IDS.SEA_MISSILE_NAVY: { const w2 = WARSHIP_UPGRADES[1]; const s1 = SUBMARINE_UPGRADES[0]; - return `Unlocks:\nโ€ข Cruisers (HP: ${w2.maxHealth}, Dmg: ${w2.damageMin}-${w2.damageMax})\nโ€ข Diesel-Electric Subs (HP: ${s1.maxHealth}, Dmg: ${s1.damageMin}-${s1.damageMax})`; + return `Unlocks:\nโ€ข Cruisers (+25% health to ${w2.maxHealth}, +35% min damage to ${w2.damageMin}, +21.5% max damage to ${w2.damageMax})\nโ€ข Diesel-Electric Subs (${s1.maxHealth} health, ${s1.damageMin}-${s1.damageMax} damage, stealth)`; } case RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET: { const w3 = WARSHIP_UPGRADES[2]; const s2 = SUBMARINE_UPGRADES[1]; - return `Unlocks:\nโ€ข Aegis Warships (HP: ${w3.maxHealth}, Dmg: ${w3.damageMin}-${w3.damageMax})\nโ€ข Tactical Subs (HP: ${s2.maxHealth}, Dmg: ${s2.damageMin}-${s2.damageMax})`; + return `Unlocks:\nโ€ข Aegis Warships (+20% health to ${w3.maxHealth}, +25.9% min damage to ${w3.damageMin}, +17.7% max damage to ${w3.damageMax})\nโ€ข Tactical Subs (+25% health to ${s2.maxHealth}, +35% min damage to ${s2.damageMin}, +21.5% max damage to ${s2.damageMax})`; } case RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES: { const s3 = SUBMARINE_UPGRADES[2]; - return `Unlocks:\nโ€ข Attack Subs (HP: ${s3.maxHealth}, Dmg: ${s3.damageMin}-${s3.damageMax})\nโ€ข Ship Anti-Air: Defends fleet against air attacks.`; + return `Unlocks:\nโ€ข Attack Subs (+20% health to ${s3.maxHealth}, +25.9% min damage to ${s3.damageMin}, +17.7% max damage to ${s3.damageMax})\nโ€ข Ship Anti-Air: Warships engage and destroy aircraft within range`; } case RESEARCH_TECH_IDS.SEA_TBD_LEVEL4: - return `Unlocks:\nโ€ข Nuclear Subs: Ballistic missile submarines can launch nuclear weapons while submerged.`; + return `Unlocks:\nโ€ข Nuclear Subs: Enables submarines to launch nuclear weapons while submerged and undetected (second-strike capability)`; // --- LAND --- case RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS: - return `Unlocks:\nโ€ข Roads: Increases movement speed and generates trade income.\nโ€ข Trade Routes: International trade routes boost economy.`; + return `Unlocks:\nโ€ข Roads: Increases unit movement speed, generates passive trade income per connected tile\nโ€ข Trade Routes: Trade ships establish international commerce routes for continuous gold income`; case RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY: - return `Unlocks:\nโ€ข City Anti-Air: Defends city against air attacks.\nโ€ข Improved SAM: Increased range and accuracy.`; + return `Unlocks:\nโ€ข City Anti-Air: Cities automatically engage enemy aircraft with AA batteries\nโ€ข Improved SAM: +35% range to 94.5 pixels, improved accuracy vs bombers/fighters/missiles`; case RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS: - return `Unlocks:\nโ€ข Advanced SAM: Maximum range and accuracy.\nโ€ข Hospitals: Increases population growth rate.`; + return `Unlocks:\nโ€ข Advanced SAM: +82.25% range to 127.6 pixels (exceeds H-bomb radius), max interception success\nโ€ข Hospitals: Increases city population growth rate (faster troop production & economy)`; case RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE: - return `Unlocks:\nโ€ข Military Academy: Allows training of advanced units.`; + return `Unlocks:\nโ€ข Military Academy: Unlocks Academy structure; each connected Academy increases enemy troop casualties you inflict in land battles (+10% with one, ~+15% with two, up to +20% cap; applies on attack and defense)`; // --- AIR --- case RESEARCH_TECH_IDS.AIR_PARATROOPERS: { const f1 = FIGHTER_UPGRADES[0]; - return `Unlocks:\nโ€ข Gen 1 Fighters (HP: ${f1.maxHealth}, Dmg: ${f1.damageMin}-${f1.damageMax})\nโ€ข Paratroopers: Can drop infantry behind enemy lines.`; + return `Unlocks:\nโ€ข Gen 1 Fighters (${f1.maxHealth} health, ${f1.damageMin}-${f1.damageMax} damage, engages aircraft)\nโ€ข Paratroopers: Airborne infantry deployed behind enemy lines for rapid expansion`; } case RESEARCH_TECH_IDS.AIR_ADVANCED_JETS: { const f2 = FIGHTER_UPGRADES[1]; const b2 = BOMBER_UPGRADES[1]; - return `Unlocks:\nโ€ข Gen 2 Fighters (HP: ${f2.maxHealth}, Dmg: ${f2.damageMin}-${f2.damageMax})\nโ€ข Heavy Bombers (HP: ${b2.maxHealth}, Dmg: ${b2.damageMin}-${b2.damageMax}, Range: ${b2.targetRange})`; + return `Unlocks:\nโ€ข Gen 2 Fighters (+33.3% health to ${f2.maxHealth}, +50% min damage to ${f2.damageMin}, +30.8% max damage to ${f2.damageMax})\nโ€ข Heavy Bombers (+20% health to ${b2.maxHealth}, +20% damage to ${b2.damageMin}, +40% range to ${b2.targetRange}, +50% speed to 3)`; } case RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE: { const f3 = FIGHTER_UPGRADES[2]; - return `Unlocks:\nโ€ข Gen 3 Fighters (HP: ${f3.maxHealth}, Dmg: ${f3.damageMin}-${f3.damageMax})\nโ€ข Naval Strike: Fighters can attack naval units.`; + return `Unlocks:\nโ€ข Gen 3 Fighters (+25% health to ${f3.maxHealth}, +33.3% min damage to ${f3.damageMin}, +23.5% max damage to ${f3.damageMax})\nโ€ข Naval Strike: Fighters can attack warships, transport ships, and trade ships`; } case RESEARCH_TECH_IDS.AIR_TBD_LEVEL4: { const f4 = FIGHTER_UPGRADES[3]; const b3 = BOMBER_UPGRADES[2]; - return `Unlocks:\nโ€ข Gen 4 Fighters (HP: ${f4.maxHealth}, Dmg: ${f4.damageMin}-${f4.damageMax})\nโ€ข Supersonic Bombers (HP: ${b3.maxHealth}, Dmg: ${b3.damageMin}-${b3.damageMax}, Range: ${b3.targetRange})`; + return `Unlocks:\nโ€ข Gen 4 Fighters (+20% health to ${f4.maxHealth}, +25% min damage to ${f4.damageMin}, +19% max damage to ${f4.damageMax})\nโ€ข Supersonic Bombers (+16.7% health to ${b3.maxHealth}, +16.7% damage to ${b3.damageMin}, +28.6% range to ${b3.targetRange}, +33.3% speed to 4)`; } // --- NUCLEAR --- case RESEARCH_TECH_IDS.NUCLEAR_FISSION: - return `Unlocks:\nโ€ข Atom Bomb: Basic nuclear weapon.\nโ€ข Missile Silo: Launch facility for nuclear weapons.`; + return `Unlocks:\nโ€ข Atom Bomb: Basic fission weapon with large blast radius (inner: 12px, outer: 30px)\nโ€ข Missile Silo: Required launch facility for deploying nuclear weapons`; case RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING: - return `Unlocks:\nโ€ข Hydrogen Bomb: High-yield nuclear weapon. Larger blast radius.`; + return `Unlocks:\nโ€ข Hydrogen Bomb: High-yield fusion weapon with massive blast radius (inner: 80px, outer: 100px) - devastates multi-tile areas`; case RESEARCH_TECH_IDS.MIRV_TECHNOLOGY: - return `Unlocks:\nโ€ข MIRV: Multiple Independent Reentry Vehicles. Harder to intercept.`; + return `Unlocks:\nโ€ข MIRV: Multiple Independent Reentry Vehicles - deploys multiple warheads per missile, significantly harder for SAMs to intercept (50% hit chance vs 100% for atom bombs)`; case RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4: - return `Unlocks:\nโ€ข Doomsday Device: Automatically launches all nukes if you are defeated.`; + return `Unlocks:\nโ€ข Doomsday Device: Auto-triggers when any of your tiles are hit by a nuke; consumes the device and unleashes a global fallout wave that instantly deletes bombers/fighters/warships/trade ships, damages other structures by 80% of current health, relinquishes land, and spreads fallout across the world`; default: return "No detailed information available."; diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts index 87062e894..698bf51b5 100644 --- a/src/core/tech/TechEffects.ts +++ b/src/core/tech/TechEffects.ts @@ -98,7 +98,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Maritime Warfare", shortDescription: "Cruisers, Diesel-Electric Subs", description: - "Develop naval warfare capabilities. Unlocks Cruisers, Diesel-Electric Submarines.", + "Develop naval warfare capabilities. Unlocks Cruisers (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395) and Diesel-Electric Submarines (1,000 health, 200-325 damage, stealth capabilities).", }, effects: { onComplete: (player) => { @@ -120,7 +120,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Fleet Modernization", shortDescription: "Aegis, Tactical Subs", description: - "Advanced naval systems and fleet integration. Unlocks Aegis Warships and Tactical Submarines.", + "Advanced naval systems and fleet integration. Unlocks Aegis Warships (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Tactical Submarines (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395).", }, effects: { onComplete: (player) => { @@ -139,7 +139,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Submarine Dominance", shortDescription: "Attack Subs, Ship Anti-Air", description: - "Advanced submarine technology and fleet air defense. Unlocks Attack Submarines and Ship Anti-Air Systems.", + "Advanced submarine technology and fleet air defense. Unlocks Attack Submarines (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Ship Anti-Air Systems (allows warships to engage and destroy enemy aircraft within range).", }, effects: { onComplete: (player) => { @@ -158,7 +158,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Strategic Deterrent", shortDescription: "Nuclear Sub", description: - "Ballistic missile submarine programs for strategic deterrence. Unlocks Nuclear Submarines (can launch nuclear weapons while submerged).", + "Ballistic missile submarine programs for strategic deterrence. Unlocks Nuclear Submarines (enables submarines to launch nuclear weapons while remaining submerged and undetected, providing second-strike capability).", }, effects: { onComplete: (player) => { @@ -174,7 +174,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Road Network", shortDescription: "Roads, Trade Routes", description: - "Infrastructure development. Unlocks Roads and Trade Routes.", + "Develop critical infrastructure to boost your economy and military mobility. Unlocks Roads (increases unit movement speed and generates passive trade income per connected tile) and Trade Routes (enables trade ships to establish international commerce routes, generating continuous gold income).", }, effects: { onComplete: (player, game) => { @@ -191,7 +191,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Ground Air Defense", shortDescription: "City Anti-Air, Improved SAM", description: - "Defensive infrastructure. Unlocks City Anti-Air systems and Improved SAM.", + "Establish comprehensive air defense capabilities to protect your cities and territories. Unlocks City Anti-Air (enables cities to automatically engage enemy aircraft with AA batteries) and Improved SAM (+35% range to 94.5 pixels, improved interception accuracy against bombers, fighters, and nuclear missiles).", }, effects: { onComplete: (player, game) => { @@ -212,7 +212,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Modern Air Defense", shortDescription: "Advanced SAM, Hospitals", description: - "Upgraded defensive and medical systems. Unlocks Advanced SAM and Hospitals.", + "Achieve peak defensive and medical capabilities. Unlocks Advanced SAM (+82.25% range to 127.6 pixels from base 70, maximum interception range exceeding hydrogen bomb blast radius, highest success rate against all aircraft and missiles) and Hospitals (building that increases city population growth rate, accelerating troop production and economic output).", }, effects: { onComplete: (player) => { @@ -229,9 +229,9 @@ export const TECHS: Readonly> = Object.freeze({ [RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE]: { meta: { name: "Military Academy", - shortDescription: "Academy building", + shortDescription: "Academy, casualty bonus", description: - "Establish military training infrastructure. Unlocks Military Academy building.", + "Establish elite military training infrastructure. Unlocks the Military Academy building. Each Academy (level- and health-scaled, +road bonus when connected) increases enemy troop casualties you inflict in land battles when attacking or defending: +10% with one Academy, ~+15% with two, ~+17.5% with three, asymptotically capped at +20% global casualty output.", }, effects: { onComplete: (player) => { @@ -247,7 +247,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Early Air Power", shortDescription: "Gen 1 Fighters, Paratroopers", description: - "Airborne infantry and assault doctrine. Unlocks 1st Generation Fighters, Paratroopers.", + "Develop airborne warfare capabilities. Unlocks Jet Engines enabling 1st Generation Fighters (750 health, 200-325 damage, engages enemy aircraft) and Paratroopers (airborne infantry units that can be deployed behind enemy lines for rapid territorial expansion).", }, effects: { onComplete: (player) => { @@ -263,7 +263,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Jet Technology", shortDescription: "Gen 2 Fighters, Heavy Bombers", description: - "Next-generation aircraft systems. Unlocks 2nd Generation Fighters, Heavy Bombers.", + "Advance to next-generation aircraft systems. Unlocks 2nd Generation Fighters (+33.3% health to 1,000, +50% minimum damage to 300, +30.8% maximum damage to 425) and Heavy Bombers (+20% health to 600, +20% damage to 300, +40% range to 350, +50% speed to 3).", }, effects: { onComplete: (player) => { @@ -282,7 +282,7 @@ export const TECHS: Readonly> = Object.freeze({ name: "Anti-Ship Warfare", shortDescription: "Gen 3 Fighters, Anti-ship", description: - "Advanced naval attack capability for aircraft. Unlocks 3rd Generation Fighters, Naval strike weapons.", + "Develop advanced anti-ship capabilities for air superiority. Unlocks 3rd Generation Fighters (+25% health to 1,250, +33.3% minimum damage to 400, +23.5% maximum damage to 525) and Naval Strike Weapons (enables fighters to target and attack warships, transport ships, and trade ships).", }, effects: { onComplete: (player) => { @@ -300,7 +300,8 @@ export const TECHS: Readonly> = Object.freeze({ meta: { name: "Advanced Fighters", shortDescription: "Gen 4 Fighters, Supersonic Bombers", - description: "Unlocks 4th Generation Fighters, Supersonic Bombers.", + description: + "Master cutting-edge aerospace technology. Unlocks 4th Generation Fighters (+20% health to 1,500, +25% minimum damage to 500, +19% maximum damage to 625) and Supersonic Bombers (+16.7% health to 700, +16.7% damage to 350, +28.6% range to 450, +33.3% speed to 4).", }, effects: { onComplete: (player) => { @@ -318,7 +319,8 @@ export const TECHS: Readonly> = Object.freeze({ meta: { name: "Atomic Weapons", shortDescription: "Atom Bomb, Silo", - description: "Atom Bomb, Silo", + description: + "Harness nuclear fission technology. Unlocks Atom Bomb (basic nuclear weapon with large blast radius causing massive area damage) and Missile Silo (required launch facility for deploying nuclear weapons against enemy targets).", }, effects: { onComplete: (player) => { @@ -334,7 +336,8 @@ export const TECHS: Readonly> = Object.freeze({ meta: { name: "Thermonuclear Weapons", shortDescription: "Hydrogen Bomb", - description: "Hydrogen Bomb", + description: + "Advance to fusion-based thermonuclear weapons. Unlocks Hydrogen Bomb (high-yield nuclear weapon with significantly larger blast radius than atom bombs, capable of devastating multi-tile areas and causing catastrophic damage to enemy infrastructure).", }, effects: { onComplete: (player) => { @@ -349,7 +352,8 @@ export const TECHS: Readonly> = Object.freeze({ meta: { name: "MIRV Warheads", shortDescription: "MIRV", - description: "MIRV", + description: + "Develop Multiple Independent Reentry Vehicle technology. Unlocks MIRV (advanced nuclear missiles deploying multiple independently targetable warheads from a single missile, significantly harder for enemy SAM systems to intercept, ensuring delivery of nuclear payload).", }, effects: { onComplete: (player) => { @@ -364,7 +368,8 @@ export const TECHS: Readonly> = Object.freeze({ meta: { name: "Doomsday Device", shortDescription: "Global deterrence", - description: "Unlocks Doomsday Device (auto-launches nukes on defeat).", + description: + "Construct the ultimate deterrent. Unlocks Doomsday Device. When any of your tiles are hit by a nuclear detonation, the device auto-triggers: it consumes itself, plays a global alert, and unleashes an expanding fallout wave across every land tile. The wave instantly destroys all bombers, fighters, warships, and trade ships; damages remaining structures by 80% of current health; relinquishes claimed land; and seeds widespread fallout (noise-pattern coverage) world-wide.", }, effects: { onComplete: (player) => { From 66a040a9a468a29c7d57c811936857432ac3cdad Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 18:59:20 +0100 Subject: [PATCH 04/16] feat: implement structure stacking system with functional multipliers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete structure stacking system that allows players to build multiple instances of structures in a single tile. Stacking provides: - Increased HP (bonus per stacked instance) - Multiplied functionality (e.g., ร—2 city = 2ร— population cap) STACKING MECHANICS: - Stack count is user-controlled (1-99) via +/- buttons in build menu - Each game starts fresh with stack count reset to 1 - Uses localStorage for in-game UI communication only (cleared on game start) - Stack count shown as ร—N prefix in build menu display STACKABLE STRUCTURES (9 total): - City: ร—N population cap increase - Port: ร—N trade ship supply - Airfield: spawns N bombers - Hospital: ร—N healing effect - Academy: ร—N combat bonus - Research Lab: ร—N research boost - Factory: ร—N gold production bonus - Missile Silo: can launch N nukes before cooldown - SAM Launcher: fires N missiles per interception DEFENSE POST: Stacking intentionally disabled (single instance only) IMPLEMENTATION DETAILS: Core data model: - Added _stackCount field to UnitImpl for tracking stacked instances - Added _launchesRemaining field for missile silo multi-launch tracking - Added stackCount() and launchesRemaining() to Unit interface - Added fields to GameUpdates and GameView for client sync Stackable structure definitions (Upgradeables.ts): - Added STACKABLE_STRUCTURES set (9 structure types) - Added TECH_UPGRADEABLE_STRUCTURES set (SAM, Airfield only) - Added helper functions: isStackableStructure(), maxStackCount(), isTechUpgradeableStructure(), maxStructureTechLevel() Construction execution: - ConstructionExecution now separates stackCount from techLevel - Added computeStackCount() and computeTechLevel() methods - Added applyStackingIfNeeded() to set stack count and HP bonuses Structure-specific execution updates: - AirfieldExecution: spawns stackCount bombers, tracks upgrades - SAMLauncherExecution: fires stackCount missiles per interception - MissileSiloExecution: accepts stackCount param, allows N launches before cooldown via launchesRemaining tracking - DefensePostExecution: removed stacking support Client UI: - BuildMenu: shows ร—N prefix for stacked structures - ControlPanel2: Stack ร—N UI with +/- adjustment buttons - Transport: reads stack count and sends with build intent - LocalPersistantStats: clears stack count settings on game start --- src/client/LocalPersistantStats.ts | 3 + src/client/TechTooltips.ts | 6 +- src/client/Transport.ts | 50 ++---- src/client/graphics/layers/BuildMenu.ts | 166 +++++++++++++++---- src/client/graphics/layers/ControlPanel2.ts | 95 +---------- src/core/execution/AirfieldExecution.ts | 59 +++---- src/core/execution/ConstructionExecution.ts | 175 ++++++++++++-------- src/core/execution/MissileSiloExecution.ts | 15 ++ src/core/execution/SAMLauncherExecution.ts | 33 ++-- src/core/game/Game.ts | 3 +- src/core/game/GameUpdates.ts | 4 + src/core/game/GameView.ts | 11 ++ src/core/game/PlayerImpl.ts | 120 ++++++++++++++ src/core/game/UnitImpl.ts | 37 +++++ src/core/game/Upgradeables.ts | 64 +++++-- 15 files changed, 543 insertions(+), 298 deletions(-) diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..b73b76636 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -31,6 +31,9 @@ export function startGame(id: GameID, lobby: Partial) { return; } + // Clear stack count settings so each game starts fresh + localStorage.removeItem("buildSettings.stackCount"); + _startTime = Date.now(); const stats = getStats(); stats[id] = { lobby }; diff --git a/src/client/TechTooltips.ts b/src/client/TechTooltips.ts index 646682d4e..280a1875b 100644 --- a/src/client/TechTooltips.ts +++ b/src/client/TechTooltips.ts @@ -12,16 +12,16 @@ export function getDetailedTechTooltip(techId: string): string { case RESEARCH_TECH_IDS.SEA_MISSILE_NAVY: { const w2 = WARSHIP_UPGRADES[1]; const s1 = SUBMARINE_UPGRADES[0]; - return `Unlocks:\nโ€ข Cruisers (+25% health to ${w2.maxHealth}, +35% min damage to ${w2.damageMin}, +21.5% max damage to ${w2.damageMax})\nโ€ข Diesel-Electric Subs (${s1.maxHealth} health, ${s1.damageMin}-${s1.damageMax} damage, stealth)`; + return `Unlocks:\nโ€ข Gen 2 Warships (+25% health to ${w2.maxHealth}, +35% min damage to ${w2.damageMin}, +21.5% max damage to ${w2.damageMax})\nโ€ข Gen 1 Submarines (${s1.maxHealth} health, ${s1.damageMin}-${s1.damageMax} damage, stealth)`; } case RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET: { const w3 = WARSHIP_UPGRADES[2]; const s2 = SUBMARINE_UPGRADES[1]; - return `Unlocks:\nโ€ข Aegis Warships (+20% health to ${w3.maxHealth}, +25.9% min damage to ${w3.damageMin}, +17.7% max damage to ${w3.damageMax})\nโ€ข Tactical Subs (+25% health to ${s2.maxHealth}, +35% min damage to ${s2.damageMin}, +21.5% max damage to ${s2.damageMax})`; + return `Unlocks:\nโ€ข Gen 3 Warships (+20% health to ${w3.maxHealth}, +25.9% min damage to ${w3.damageMin}, +17.7% max damage to ${w3.damageMax})\nโ€ข Gen 2 Submarines (+25% health to ${s2.maxHealth}, +35% min damage to ${s2.damageMin}, +21.5% max damage to ${s2.damageMax})`; } case RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES: { const s3 = SUBMARINE_UPGRADES[2]; - return `Unlocks:\nโ€ข Attack Subs (+20% health to ${s3.maxHealth}, +25.9% min damage to ${s3.damageMin}, +17.7% max damage to ${s3.damageMax})\nโ€ข Ship Anti-Air: Warships engage and destroy aircraft within range`; + return `Unlocks:\nโ€ข Gen 3 Submarines (+20% health to ${s3.maxHealth}, +25.9% min damage to ${s3.damageMin}, +17.7% max damage to ${s3.damageMax})\nโ€ข Ship Anti-Air: Warships engage and destroy aircraft within range`; } case RESEARCH_TECH_IDS.SEA_TBD_LEVEL4: return `Unlocks:\nโ€ข Nuclear Subs: Enables submarines to launch nuclear weapons while submerged and undetected (second-strike capability)`; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 009fcd9e3..c4bb47d9f 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -11,11 +11,6 @@ import { } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { PlayerView } from "../core/game/GameView"; -import { - isUpgradeableUnit, - maxStructureLevel, - maxUnitLevel, -} from "../core/game/Upgradeables"; import { AllPlayersStats, ClientHashMessage, @@ -767,45 +762,20 @@ export class Transport { this._lastBuildUnit = event.unit; this._lastBuildAt = now; - // Compute desired starting level for upgradeable structures or units from local settings. - let targetLevel: number | undefined; + // Read stack count from localStorage (in-game communication) + let stackCount: number | undefined; let bomberLevel: number | undefined; try { - const key = String(event.unit); - if (isUpgradeableUnit(event.unit)) { - const rawUnits = localStorage.getItem("unitUpgradeSettings.levels"); - if (rawUnits) { - const obj = JSON.parse(rawUnits) as Record; - const val = obj?.[key]; - if (typeof val === "number" && val > 1) { - // Server will clamp to player's researched max level - targetLevel = Math.min(maxUnitLevel(event.unit), val); - } - } - } else { - const rawStruct = localStorage.getItem("buildSettings.levels"); - if (rawStruct) { - const obj = JSON.parse(rawStruct) as Record; - const val = obj?.[key]; - if (typeof val === "number" && val > 1) { - targetLevel = Math.min(maxStructureLevel(event.unit), val); - } - } - } - // For airfields, also get bomber upgrade level from unit upgrade settings - if (event.unit === UnitType.Airfield) { - const rawUnits = localStorage.getItem("unitUpgradeSettings.levels"); - if (rawUnits) { - const obj = JSON.parse(rawUnits) as Record; - const val = obj?.[String(UnitType.Bomber)]; - if (typeof val === "number" && val > 1) { - bomberLevel = Math.min(maxUnitLevel(UnitType.Bomber), val); - } + const rawStack = localStorage.getItem("buildSettings.stackCount"); + if (rawStack) { + const obj = JSON.parse(rawStack) as Record; + const val = obj?.[String(event.unit)]; + if (typeof val === "number" && val > 1) { + stackCount = Math.min(99, val); } } } catch { - // Ignore malformed local storage. - targetLevel = undefined; + stackCount = undefined; bomberLevel = undefined; } @@ -814,7 +784,7 @@ export class Transport { clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, - targetLevel, + targetLevel: stackCount, // Renamed semantically but keeping wire format for now bomberLevel, }); } diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 98a78b66d..da82a97f2 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -27,10 +27,11 @@ import { import { Gold, UnitType, UpgradeType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { + isStackableStructure, + isTechUpgradeableStructure, isUnitAvailable, - isUpgradeableStructure, isUpgradeableUnit, - maxStructureLevel, + maxStackCount, maxUnitLevel, playerMaxStructureLevel, playerMaxUnitLevel, @@ -547,70 +548,79 @@ export class BuildMenu extends LitElement { .config() .unitInfo(item.unitType) .cost(this.game.myPlayer()!); - // Structures: use configured structure multiplier - if (isUpgradeableStructure(item.unitType)) { - const desired = this._desiredStructureLevel(item.unitType); + // Stackable structures: use stack count for cost calculation + if (isStackableStructure(item.unitType)) { + const stackCount = this._desiredStackCount(item.unitType); let structureCost = - desired <= 1 + stackCount <= 1 ? base : aggregateStructureBuildCost( this.game.config(), this.game.myPlayer()!, item.unitType, - desired, + stackCount, this.game.config().structureUpgradeCostMultiplier(item.unitType), ); - // Add bomber upgrade cost for airfields + // Add bomber upgrade cost for airfields (based on tech level, not stack) if (item.unitType === UnitType.Airfield) { - const bomberLevel = this._desiredUnitLevel(UnitType.Bomber); + const bomberLevel = this._structureTechLevel(UnitType.Airfield); structureCost += computeBomberUpgradeCost( this.game.config(), this.game.myPlayer()!, bomberLevel, - desired, + stackCount, ); } return structureCost; } // Units: use hardcoded costs from UnitUpgrades (aggregateStructureBuildCost handles this) if (isUpgradeableUnit(item.unitType)) { - const desired = this._desiredUnitLevel(item.unitType); - if (desired <= 1) return base; + const techLevel = playerMaxUnitLevel( + this.game.myPlayer()!, + item.unitType, + ); + if (techLevel <= 1) return base; // aggregateStructureBuildCost detects upgradeable units and uses hardcoded costs return aggregateStructureBuildCost( this.game.config(), this.game.myPlayer()!, item.unitType, - desired, + techLevel, 0, // multiplier ignored for upgradeable units ); } return base; } - private _desiredStructureLevel(type: UnitType): number { - // If a specific level is requested via the UI prop, use that (clamped by max level) + // Get the desired stack count for stackable structures + private _desiredStackCount(type: UnitType): number { + // If a specific level is requested via the UI prop, use that (clamped by max) const level = this.structureLevels[type]; if (level && level > 1) { - return Math.min(maxStructureLevel(type), level); + return Math.min(maxStackCount(type), level); } + // Read from localStorage (used for in-game communication, not persistence) try { - const raw = localStorage.getItem("buildSettings.levels"); + const raw = localStorage.getItem("buildSettings.stackCount"); if (!raw) return 1; const obj = JSON.parse(raw); const key = String(type); const val = obj?.[key]; if (typeof val !== "number" || val < 1) return 1; - // Use player-specific max level based on researched techs - const player = this.game?.myPlayer(); - const maxLevel = player ? playerMaxStructureLevel(player, type) : 1; - return Math.min(maxLevel, val); + return Math.min(maxStackCount(type), val); } catch (_) { return 1; } } + // Get the tech level for tech-upgradeable structures (SAM, Airfield) + private _structureTechLevel(type: UnitType): number { + const player = this.game?.myPlayer(); + if (!player) return 1; + return playerMaxStructureLevel(player, type); + } + private _desiredUnitLevel(type: UnitType): number { try { const raw = localStorage.getItem("unitUpgradeSettings.levels"); @@ -638,6 +648,97 @@ export class BuildMenu extends LitElement { return player.units(item.unitType).length.toString(); } + private getUnitDisplayName(unitType: UnitType, baseName: string): string { + const player = this.game?.myPlayer(); + if (!player) return baseName; + + // Handle combat units with tech upgrades + if (isUpgradeableUnit(unitType)) { + const level = playerMaxUnitLevel(player, unitType); + + // Only Fighters use "Gen X" naming + if (unitType === UnitType.FighterJet && level > 1) { + return `Gen ${level} ${baseName}`; + } + + // Warships have specific names per level + if (unitType === UnitType.Warship) { + switch (level) { + case 1: + return baseName; // "Warship" + case 2: + return "Cruiser"; + case 3: + return "Aegis Warship"; + default: + return baseName; + } + } + + // Submarines have specific names per level + if (unitType === UnitType.Submarine) { + switch (level) { + case 1: + return "Diesel-Electric Sub"; + case 2: + return "Tactical Sub"; + case 3: + return "Attack Sub"; + default: + return baseName; + } + } + + // Bombers have specific names per level + if (unitType === UnitType.Bomber) { + switch (level) { + case 1: + return baseName; // "Bomber" + case 2: + return "Heavy Bomber"; + case 3: + return "Supersonic Bomber"; + default: + return baseName; + } + } + + return baseName; + } + + // Handle tech-upgradeable structures (SAM, Airfield) + if (isTechUpgradeableStructure(unitType)) { + const techLevel = playerMaxStructureLevel(player, unitType); + const stackCount = this._desiredStackCount(unitType); + + let name = baseName; + if (unitType === UnitType.SAMLauncher && techLevel > 1) { + name = + techLevel === 2 + ? "Radar SAM" + : techLevel === 3 + ? "Strategic SAM" + : baseName; + } + + // Show stack count if > 1 + if (stackCount > 1) { + return `ร—${stackCount} ${name}`; + } + return name; + } + + // Handle other stackable structures + if (isStackableStructure(unitType)) { + const stackCount = this._desiredStackCount(unitType); + if (stackCount > 1) { + return `ร—${stackCount} ${baseName}`; + } + } + + return baseName; + } + public onBuildSelected = (item: BuildItemDisplay) => { // Selecting a build item should exit upgrade mode and unhighlight the button if (this.uiState?.upgradeMode) { @@ -674,16 +775,16 @@ export class BuildMenu extends LitElement { (row) => html`
${row.map((item) => { - const name = item.key + const baseName = item.key ? translateText(item.key) : String(item.unitType); const price = this.game && this.game.myPlayer() ? this.cost(item) : 0; - const desiredLevel = isUpgradeableStructure(item.unitType) - ? this._desiredStructureLevel(item.unitType) - : isUpgradeableUnit(item.unitType) - ? this._desiredUnitLevel(item.unitType) - : 1; + + const displayName = this.getUnitDisplayName( + item.unitType, + baseName, + ); return html` -
-
- - ${this.uiState.pendingBuildUnitType - ? `Set ${this.uiState.pendingBuildUnitType} Level` - : "Select a unit..."} - - - -
-
-
1) { + (this.airfield as any).setStackCount(this.stackCount); + // Apply HP bonuses for stacking (one upgrade per extra stack) + for (let i = 1; i < this.stackCount; i++) { + (this.airfield as any).upgradeStructure(); + } + } + this.lastStackCount = this.stackCount; // Set initial bomber upgrade level if specified (clamped to max) const bomberLvl = Math.min( @@ -60,8 +62,8 @@ export class AirfieldExecution implements Execution { this.airfield.setBomberLevel?.(bomberLvl); } - // Spawn initial bombers when airfield is built - this.spawnBombersForLevel(mg); + // Spawn initial bombers based on stack count + this.spawnBombersForStackCount(mg); } if (!this.airfield.isActive()) { @@ -73,14 +75,14 @@ export class AirfieldExecution implements Execution { this.player = this.airfield.owner(); } - // Check if airfield was upgraded - spawn additional bombers - const currentLevel = this.airfield.level?.() ?? 1; - if (currentLevel > this.lastLevel) { - const bombersToAdd = currentLevel - this.lastLevel; + // Check if airfield was upgraded (stack count increased) - spawn additional bombers + const currentStackCount = this.airfield.stackCount?.() ?? 1; + if (currentStackCount > this.lastStackCount) { + const bombersToAdd = currentStackCount - this.lastStackCount; for (let i = 0; i < bombersToAdd; i++) { mg.addExecution(new BomberExecution(this.player, this.airfield)); } - this.lastLevel = currentLevel; + this.lastStackCount = currentStackCount; } if ((mg.ticks() + this.checkOffset) % 10 !== 0) { @@ -110,9 +112,9 @@ export class AirfieldExecution implements Execution { } } - private spawnBombersForLevel(mg: Game): void { - const level = this.airfield?.level?.() ?? 1; - for (let i = 0; i < level; i++) { + private spawnBombersForStackCount(mg: Game): void { + const count = this.airfield?.stackCount?.() ?? 1; + for (let i = 0; i < count; i++) { mg.addExecution(new BomberExecution(this.player, this.airfield!)); } } @@ -124,19 +126,4 @@ export class AirfieldExecution implements Execution { activeDuringSpawnPhase(): boolean { return false; } - - private computeDesiredLevel(type: UnitType, target?: number): number { - if (target === undefined || target < 1) return 1; - const cap = playerMaxStructureLevel(this.player, type); - return Math.max(1, Math.min(cap, target)); - } - - private applyUpgrades(unit: Unit, desiredLevel: number): void { - const steps = Math.max(0, desiredLevel - 1); - if (steps <= 0) return; - const impl = unit as any; - if (typeof impl.upgradeStructure === "function") { - for (let i = 0; i < steps; i++) impl.upgradeStructure(); - } - } } diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 67cf40bc3..a00b3520e 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -15,6 +15,8 @@ import { } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { + isStackableStructure, + isTechUpgradeableStructure, isUpgradeableUnit, playerMaxStructureLevel, playerMaxUnitLevel, @@ -39,13 +41,14 @@ export class ConstructionExecution implements Execution { private reservedTotalCost: Gold = 0n; private baseCost: Gold = 0n; - private desiredLevel: number = 1; + private desiredStackCount: number = 1; // How many stacked instances + private desiredTechLevel: number = 1; // Tech upgrade level (for SAM, Airfield) constructor( private player: Player, private constructionType: UnitType, private tile: TileRef, - private targetLevel?: number, + private stackCount?: number, // User-selected stack count (renamed from targetLevel) private bomberLevel?: number, // Bomber upgrade level for airfields ) {} @@ -70,16 +73,17 @@ export class ConstructionExecution implements Execution { tick(ticks: number): void { if (this.construction === null) { const info = this.mg.unitInfo(this.constructionType); + + // Compute stack count and tech level + this.desiredStackCount = this.computeStackCount(this.constructionType); + this.desiredTechLevel = this.computeTechLevel(this.constructionType); + if (info.constructionDuration === undefined) { // No construction phase; treat as instant build path // Compute and reserve total aggregated cost first this.baseCost = this.mg .unitInfo(this.constructionType) .cost(this.player); - this.desiredLevel = this.computeDesiredLevel( - this.constructionType, - this.targetLevel, - ); // Validate build feasibility BEFORE charging any gold const canSpawnInstant = this.player.canBuild( this.constructionType, @@ -90,28 +94,10 @@ export class ConstructionExecution implements Execution { this.active = false; return; } - const total = - aggregateStructureBuildCost( - this.mg, - this.player, - this.constructionType, - this.desiredLevel, - // For upgradeable units, aggregateStructureBuildCost ignores multiplier - this.mg - .config() - .structureUpgradeCostMultiplier(this.constructionType), - ) + - (this.constructionType === UnitType.Airfield - ? computeBomberUpgradeCost( - this.mg, - this.player, - this.bomberLevel ?? 1, - this.desiredLevel, - ) - : 0n); + const total = this.computeTotalCost(); if (this.player.gold() < total) { console.warn( - `cannot afford construction ${this.constructionType} at level ${this.desiredLevel}`, + `cannot afford construction ${this.constructionType} stack=${this.desiredStackCount} techLevel=${this.desiredTechLevel}`, ); this.active = false; return; @@ -128,32 +114,10 @@ export class ConstructionExecution implements Execution { } // Timed construction path: compute and reserve aggregate cost upfront this.baseCost = this.mg.unitInfo(this.constructionType).cost(this.player); - this.desiredLevel = this.computeDesiredLevel( - this.constructionType, - this.targetLevel, - ); - const totalCost = - aggregateStructureBuildCost( - this.mg, - this.player, - this.constructionType, - this.desiredLevel, - // For upgradeable units, aggregateStructureBuildCost ignores multiplier - this.mg - .config() - .structureUpgradeCostMultiplier(this.constructionType), - ) + - (this.constructionType === UnitType.Airfield - ? computeBomberUpgradeCost( - this.mg, - this.player, - this.bomberLevel ?? 1, - this.desiredLevel, - ) - : 0n); + const totalCost = this.computeTotalCost(); if (this.player.gold() < totalCost) { console.warn( - `cannot afford construction ${this.constructionType} at level ${this.desiredLevel}`, + `cannot afford construction ${this.constructionType} stack=${this.desiredStackCount} techLevel=${this.desiredTechLevel}`, ); this.active = false; return; @@ -173,7 +137,7 @@ export class ConstructionExecution implements Execution { // Reserve total aggregated cost upfront so funds are locked during construction this.player.removeGold(this.reservedTotalCost); this.construction.setConstructionType(this.constructionType); - this.construction.setConstructionTargetLevel(this.desiredLevel); + this.construction.setConstructionTargetLevel(this.desiredStackCount); // Apply construction speed modifier from tech effects const speedMods = constructionSpeedModifiers(this.player); this.ticksUntilComplete = Math.ceil( @@ -224,7 +188,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new WarshipExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -232,7 +196,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new SubmarineExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -240,7 +204,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new FighterJetExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -259,15 +223,21 @@ export class ConstructionExecution implements Execution { canSpawn, {}, ); - this.applyUpgradesIfNeeded(built, this.desiredLevel); + this.applyStackingIfNeeded(built, this.desiredStackCount); } break; case UnitType.MissileSilo: this.mg.addExecution( - new MissileSiloExecution(player, this.tile, this.desiredLevel), + new MissileSiloExecution( + player, + this.tile, + this.desiredTechLevel, + this.desiredStackCount, + ), ); break; case UnitType.DefensePost: + // DefensePost does not support stacking this.mg.addExecution(new DefensePostExecution(player, this.tile)); break; case UnitType.SAMLauncher: @@ -277,8 +247,15 @@ export class ConstructionExecution implements Execution { ) { player.addUpgrade(UpgradeType.CityAntiAir); } + // SAM uses tech level for capability AND stack count for multiple missiles this.mg.addExecution( - new SAMLauncherExecution(player, this.tile, null, this.desiredLevel), + new SAMLauncherExecution( + player, + this.tile, + null, + this.desiredTechLevel, + this.desiredStackCount, + ), ); break; case UnitType.City: @@ -300,16 +277,17 @@ export class ConstructionExecution implements Execution { canSpawn, {}, ); - this.applyUpgradesIfNeeded(built, this.desiredLevel); + this.applyStackingIfNeeded(built, this.desiredStackCount); } break; case UnitType.Airfield: + // Airfield uses bomber level for capability AND stack count for multiple bombers this.mg.addExecution( new AirfieldExecution( player, this.tile, - this.bomberLevel, - this.desiredLevel, + this.bomberLevel ?? this.desiredTechLevel, + this.desiredStackCount, ), ); break; @@ -328,7 +306,7 @@ export class ConstructionExecution implements Execution { canSpawn, {}, ); - this.applyUpgradesIfNeeded(built, this.desiredLevel); + this.applyStackingIfNeeded(built, this.desiredStackCount); } break; } @@ -342,22 +320,75 @@ export class ConstructionExecution implements Execution { return false; } - private computeDesiredLevel(type: UnitType, target?: number): number { - if (target === undefined || target < 1) return 1; - // For units, use player-specific max level based on researched techs - // For structures, use player-specific max (e.g., SAM launchers depend on SAM tech level) - const cap = isUpgradeableUnit(type) - ? playerMaxUnitLevel(this.player, type) - : playerMaxStructureLevel(this.player, type); - return Math.max(1, Math.min(cap, target)); + // Compute the stack count (how many instances in one tile) + private computeStackCount(type: UnitType): number { + // Use client-provided stack count, clamped to valid range + if (isStackableStructure(type) && this.stackCount && this.stackCount > 1) { + return Math.min(99, this.stackCount); + } + return 1; } - // step cost is centralized in ../game/Costs + // Compute the tech level for upgradeable units/structures + private computeTechLevel(type: UnitType): number { + if (isUpgradeableUnit(type)) { + return playerMaxUnitLevel(this.player, type); + } + if (isTechUpgradeableStructure(type)) { + return playerMaxStructureLevel(this.player, type); + } + return 1; + } + + // Compute total cost including stacking and tech upgrades + private computeTotalCost(): Gold { + // For combat units, use hardcoded tech-based costs + if (isUpgradeableUnit(this.constructionType)) { + return aggregateStructureBuildCost( + this.mg, + this.player, + this.constructionType, + this.desiredTechLevel, + 0, // multiplier ignored for upgradeable units + ); + } + + // For structures, compute stacking cost + const stackCost = aggregateStructureBuildCost( + this.mg, + this.player, + this.constructionType, + this.desiredStackCount, + this.mg.config().structureUpgradeCostMultiplier(this.constructionType), + ); - private applyUpgradesIfNeeded(unit: Unit, desiredLevel: number) { - const steps = Math.max(0, desiredLevel - 1); + // Add bomber upgrade cost for airfields + if (this.constructionType === UnitType.Airfield) { + const bomberLvl = this.bomberLevel ?? this.desiredTechLevel; + return ( + stackCost + + computeBomberUpgradeCost( + this.mg, + this.player, + bomberLvl, + this.desiredStackCount, + ) + ); + } + + return stackCost; + } + + // Apply stacking upgrades (HP bonus) for non-tech structures + private applyStackingIfNeeded(unit: Unit, stackCount: number) { + const steps = Math.max(0, stackCount - 1); if (steps <= 0) return; const impl = unit as any; // UnitImpl + // Set the stack count on the unit + if (typeof impl.setStackCount === "function") { + impl.setStackCount(stackCount); + } + // Apply HP bonuses via upgradeStructure if (typeof impl.upgradeStructure === "function") { for (let i = 0; i < steps; i++) { impl.upgradeStructure(); diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index 34d98fe72..5bf9e72fc 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -10,6 +10,7 @@ export class MissileSiloExecution implements Execution { private player: Player, private tile: TileRef, private desiredLevel?: number, + private stackCount: number = 1, ) {} init(mg: Game, ticks: number): void { @@ -28,6 +29,20 @@ export class MissileSiloExecution implements Execution { } this.silo = this.player.buildUnit(UnitType.MissileSilo, spawn, {}); + // Apply stack count (multiple silos in one tile) + if (this.stackCount > 1) { + const impl = this.silo as any; + if (typeof impl.setStackCount === "function") { + impl.setStackCount(this.stackCount); + } + // Apply HP bonuses for stacking via upgradeStructure + if (typeof impl.upgradeStructure === "function") { + for (let i = 0; i < this.stackCount - 1; i++) { + impl.upgradeStructure(); + } + } + } + // Apply upgrades up to cap 3 if requested const level = this.computeDesiredLevel( UnitType.MissileSilo, diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 379561bf0..685be49d3 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -153,6 +153,7 @@ export class SAMLauncherExecution implements Execution { private tile: TileRef | null, private sam: Unit | null = null, private desiredLevel?: number, + private stackCount: number = 1, // Number of stacked SAMs (fires multiple missiles) ) { if (sam !== null) { this.tile = sam.tile(); @@ -203,12 +204,20 @@ export class SAMLauncherExecution implements Execution { return; } this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {}); - // Apply upgrades up to cap 3 if requested + // Apply tech level upgrades const level = this.computeDesiredLevel( UnitType.SAMLauncher, this.desiredLevel, ); this.applyUpgrades(this.sam, level); + // Set stack count for multiple missiles + if (this.stackCount > 1) { + (this.sam as any).setStackCount(this.stackCount); + // Apply HP bonuses for stacking (one upgrade per extra stack) + for (let i = 1; i < this.stackCount; i++) { + (this.sam as any).upgradeStructure(); + } + } } this.targetingSystem ??= new SAMTargetingSystem( this.mg, @@ -303,15 +312,19 @@ export class SAMLauncherExecution implements Execution { ); } else if (target !== null) { target.unit.setTargetedBySAM(true); - this.mg.addExecution( - new SAMMissileExecution( - this.sam.tile(), - this.sam.owner(), - this.sam, - target.unit, - target.tile, - ), - ); + // Fire stackCount missiles (one for each stacked SAM) + const missileCount = this.sam.stackCount?.() ?? 1; + for (let i = 0; i < missileCount; i++) { + this.mg.addExecution( + new SAMMissileExecution( + this.sam.tile(), + this.sam.owner(), + this.sam, + target.unit, + target.tile, + ), + ); + } } else { // No valid target to engage (should not happen when firing) } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 262214d2b..ec39a74a9 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -572,7 +572,8 @@ export interface Unit { isPeriodicallyVisible(): boolean; // Upgrades - level(): number; // Current upgrade level (>=1) + level(): number; // Current upgrade/tech level (>=1) - for SAM/Airfield this is the tech tier + stackCount(): number; // Number of stacked instances (>=1) - for all structures upgradeStructure(): void; // Applies structure-specific upgrade effects (currently City only) effectiveMaxHealth(): number; // Base max health + bonuses from upgrades } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 019163c79..b03af37d9 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -151,6 +151,10 @@ export interface UnitUpdate { ghostExpiresAt?: Tick; // Structure upgrade level (>=1). Cities increase level by 1 per upgrade. level?: number; + // Stack count (>=1). Number of stacked instances for stackable structures. + stackCount?: number; + // Missile silo specific: remaining launches before cooldown (for stacked silos) + launchesRemaining?: number; // Trade-ship specific, for precise UI without heuristics tradeRouteStartOwnerID?: number; // smallID of start port owner tradeRouteEndOwnerID?: number; // smallID of end port owner diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d2d65c382..2fec2ff7c 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -193,6 +193,17 @@ export class UnitView { return (this.data as any).level ?? 1; } + // Stack count (>=1). Number of stacked instances for stackable structures. + stackCount(): number { + return (this.data as any).stackCount ?? 1; + } + + // Missile silo specific: remaining launches before cooldown (for stacked silos) + launchesRemaining(): number | null { + const v = (this.data as any).launchesRemaining as number | undefined; + return v ?? null; + } + // Airfield-specific: bomber upgrade level. Defaults to 1. bomberLevel(): number { return (this.data as any).bomberLevel ?? 1; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index f13485330..263d4a6f8 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -55,6 +55,7 @@ import { canBuildTransportShip, } from "./TransportShipUtils"; import { UnitImpl } from "./UnitImpl"; +import { playerMaxUnitLevel } from "./Upgradeables"; interface Target { tick: Tick; @@ -390,12 +391,131 @@ export class PlayerImpl implements Player { addUpgrade(upgrade: UpgradeType): void { this._upgrades.add(upgrade); + this.applyAutoUnitUpgrades(upgrade); } removeUpgrade(upgrade: UpgradeType): void { this._upgrades.delete(upgrade); } + private applyAutoUnitUpgrades(upgrade: UpgradeType): void { + const unitTypes: UnitType[] = []; + switch (upgrade) { + case UpgradeType.FighterLevel2: + case UpgradeType.FighterLevel3: + case UpgradeType.FighterLevel4: + unitTypes.push(UnitType.FighterJet); + break; + case UpgradeType.BomberLevel2: + case UpgradeType.BomberLevel3: + unitTypes.push(UnitType.Bomber); + break; + case UpgradeType.WarshipLevel2: + case UpgradeType.WarshipLevel3: + unitTypes.push(UnitType.Warship); + break; + case UpgradeType.SubmarineLevel2: + case UpgradeType.SubmarineLevel3: + unitTypes.push(UnitType.Submarine); + break; + default: + return; + } + + for (const type of unitTypes) { + if (type === UnitType.Bomber) { + this.upgradeBombersAndAirfields(); + } else { + this.upgradeCombatUnits(type); + } + } + } + + private upgradeCombatUnits(type: UnitType): void { + const targetLevel = playerMaxUnitLevel(this, type); + if (targetLevel <= 1) return; + + const desiredMaxHealth = (() => { + switch (type) { + case UnitType.FighterJet: + return this.mg.config().fighterJetLevelMaxHealth(targetLevel); + case UnitType.Warship: + return this.mg.config().warshipLevelMaxHealth(targetLevel); + case UnitType.Submarine: + return this.mg.config().submarineLevelMaxHealth(targetLevel); + default: + return this.mg.unitInfo(type).maxHealth ?? 0; + } + })(); + const baseMax = this.mg.unitInfo(type).maxHealth ?? desiredMaxHealth; + + for (const unit of this.units(type)) { + const impl = unit as any; + const currentLevel = typeof impl.level === "function" ? impl.level() : 1; + if (currentLevel >= targetLevel) continue; + + const oldMax = + typeof impl.effectiveMaxHealth === "function" + ? impl.effectiveMaxHealth() + : baseMax; + const healthRatio = oldMax > 0 ? Math.min(1, unit.health() / oldMax) : 1; + + impl._level = targetLevel; + impl._bonusMaxHealth = Math.max(0, desiredMaxHealth - baseMax); + const newHealth = Math.max(0, Math.round(desiredMaxHealth * healthRatio)); + impl._health = BigInt( + Math.min(desiredMaxHealth, newHealth || desiredMaxHealth), + ); + this.mg.addUpdate(unit.toUpdate()); + } + + this.invalidateEffectiveUnitsCache(type); + } + + private upgradeBombersAndAirfields(): void { + const targetLevel = playerMaxUnitLevel(this, UnitType.Bomber); + if (targetLevel <= 1) return; + + // Sync airfields so new and existing bombers inherit the latest level + for (const airfield of this.units(UnitType.Airfield)) { + if (airfield.bomberLevel?.() !== undefined) { + const current = airfield.bomberLevel(); + if (current < targetLevel) { + airfield.setBomberLevel?.(targetLevel); + } + } else { + airfield.setBomberLevel?.(targetLevel); + } + } + + const desiredMaxHealth = this.mg.config().bomberMaxHealth(targetLevel); + const baseMax = + this.mg.unitInfo(UnitType.Bomber).maxHealth ?? desiredMaxHealth; + + for (const bomber of this.units(UnitType.Bomber)) { + const impl = bomber as any; + const currentLevel = typeof impl.level === "function" ? impl.level() : 1; + if (currentLevel < targetLevel) { + impl._level = targetLevel; + } + const oldMax = + typeof impl.effectiveMaxHealth === "function" + ? impl.effectiveMaxHealth() + : baseMax; + const healthRatio = + oldMax > 0 ? Math.min(1, bomber.health() / oldMax) : 1; + impl._bonusMaxHealth = Math.max(0, desiredMaxHealth - baseMax); + const newHealth = Math.max(0, Math.round(desiredMaxHealth * healthRatio)); + impl._health = BigInt( + Math.min(desiredMaxHealth, newHealth || desiredMaxHealth), + ); + this.mg.addUpdate(bomber.toUpdate()); + } + + this.invalidateEffectiveUnitsCache(UnitType.Airfield); + this.invalidateEffectiveUnitsCache(UnitType.Bomber); + } + // Research tree (standalone) API addResearchedTech(techId: string): void { // Add tech to researched set diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 344ee13ef..443ecd85e 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -36,6 +36,8 @@ export class UnitImpl implements Unit { private _returning: boolean = false; private _patrolTile: TileRef | undefined; private _level: number = 1; + private _stackCount: number = 1; // Number of stacked instances (for stackable structures) + private _launchesRemaining: number | null = null; // For stacked silos: remaining launches before cooldown private _bonusMaxHealth: number = 0; // Extra max health from upgrades (e.g. city upgrades) private _targetable: boolean = true; private _accumulatedRegen: number = 0; @@ -176,6 +178,11 @@ export class UnitImpl implements Unit { health: this.hasHealth() ? Number(this._health) : undefined, maxHealth: this.hasHealth() ? this.effectiveMaxHealth() : undefined, level: this._level > 1 ? this._level : undefined, + stackCount: this._stackCount > 1 ? this._stackCount : undefined, + launchesRemaining: + this._type === UnitType.MissileSilo && this._launchesRemaining !== null + ? this._launchesRemaining + : undefined, constructionType: this._constructionType, constructionTargetLevel: this._type === UnitType.Construction && @@ -292,6 +299,15 @@ export class UnitImpl implements Unit { return this._level; } + stackCount(): number { + return this._stackCount; + } + + setStackCount(count: number): void { + this._stackCount = Math.max(1, count); + this.mg.addUpdate(this.toUpdate()); + } + // Port-specific accessor/mutator for scheduled trade ship construction (single legacy) setPendingTradeShipDueTick(due: Tick | null): void { if (this._pendingTradeShipDueTick !== due) { @@ -648,6 +664,27 @@ export class UnitImpl implements Unit { } launch(duration?: Tick): void { + // For stacked missile silos: allow multiple launches before cooldown + if (this.type() === UnitType.MissileSilo && this._stackCount > 1) { + // Initialize launches remaining on first launch + if (this._launchesRemaining === null) { + this._launchesRemaining = this._stackCount - 1; // First launch uses one + this.mg.addUpdate(this.toUpdate()); + return; // Don't start cooldown yet + } + // If we have remaining launches, use one + if (this._launchesRemaining > 0) { + this._launchesRemaining--; + this.mg.addUpdate(this.toUpdate()); + if (this._launchesRemaining > 0) { + return; // Still have more launches, don't start cooldown + } + // Fall through to start cooldown when all launches used + } + // Reset launches for next cycle + this._launchesRemaining = null; + } + this._cooldownStartTick = this.mg.ticks(); if (duration !== undefined) { this._cooldownDuration = duration; diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index 9d4891a08..c6f4737da 100644 --- a/src/core/game/Upgradeables.ts +++ b/src/core/game/Upgradeables.ts @@ -5,7 +5,9 @@ interface HasUpgrade { hasUpgrade(type: UpgradeType): boolean; } -export const UPGRADEABLE_STRUCTURES: ReadonlySet = new Set([ +// STACKABLE structures: can have multiple "instances" in one tile (user-controlled stack count) +// Stacking adds HP and counts as multiple buildings. +export const STACKABLE_STRUCTURES: ReadonlySet = new Set([ UnitType.City, UnitType.Port, UnitType.Airfield, @@ -17,7 +19,16 @@ export const UPGRADEABLE_STRUCTURES: ReadonlySet = new Set([ UnitType.SAMLauncher, ]); -// Units that can be upgraded +// TECH-UPGRADEABLE structures: level is determined by researched techs (auto-applied) +// SAM and Airfield have tech-based upgrade levels that affect their capabilities. +export const TECH_UPGRADEABLE_STRUCTURES: ReadonlySet = + new Set([UnitType.SAMLauncher, UnitType.Airfield]); + +// Legacy alias for backwards compatibility +export const UPGRADEABLE_STRUCTURES: ReadonlySet = + STACKABLE_STRUCTURES; + +// Units that can be upgraded via tech export const UPGRADEABLE_UNITS: ReadonlySet = new Set([ UnitType.Warship, UnitType.FighterJet, @@ -25,19 +36,41 @@ export const UPGRADEABLE_UNITS: ReadonlySet = new Set([ UnitType.Bomber, // Bomber level affects airfield construction cost ]); +export function isStackableStructure(type: UnitType): boolean { + return STACKABLE_STRUCTURES.has(type); +} + +export function isTechUpgradeableStructure(type: UnitType): boolean { + return TECH_UPGRADEABLE_STRUCTURES.has(type); +} + export function isUpgradeableStructure(type: UnitType): boolean { - return UPGRADEABLE_STRUCTURES.has(type); + return STACKABLE_STRUCTURES.has(type); } export function isUpgradeableUnit(type: UnitType): boolean { return UPGRADEABLE_UNITS.has(type); } +// Maximum TECH upgrade level for structures (SAM, Airfield) +// This is NOT the stack count - it's the quality tier from research. +export function maxStructureTechLevel(type: UnitType): number { + if (type === UnitType.SAMLauncher) return 3; + if (type === UnitType.Airfield) return 3; // Based on bomber level + return 1; +} + +// Maximum stack count for stackable structures +export function maxStackCount(type: UnitType): number { + return isStackableStructure(type) ? 99 : 1; +} + +// Legacy function - returns max stack count for most, max tech level for SAM export function maxStructureLevel(type: UnitType): number { if (type === UnitType.MissileSilo || type === UnitType.SAMLauncher) { return 3; } - return isUpgradeableStructure(type) ? 99 : 1; + return isStackableStructure(type) ? 99 : 1; } // Return maximum upgrade level for upgradeable combat units. @@ -109,24 +142,31 @@ export function playerMaxUnitLevel(player: HasUpgrade, type: UnitType): number { return globalMax; } -// Return maximum upgrade level for a structure based on player's researched techs. +// Return maximum TECH upgrade level for a structure based on player's researched techs. // For SAMLauncher: Surface-to-Air Missiles = level 1, Radar-Guided SAMs = level 2, // Strategic SAM Systems = level 3. +// For Airfield: Returns the bomber level the player has researched. +// This is NOT the stack count - it's the quality tier. export function playerMaxStructureLevel( player: HasUpgrade, type: UnitType, ): number { - const globalMax = maxStructureLevel(type); - if (type === UnitType.SAMLauncher) { - if (player.hasUpgrade(UpgradeType.SAMLevel3)) return Math.min(3, globalMax); - if (player.hasUpgrade(UpgradeType.SAMLevel2)) return Math.min(2, globalMax); + if (player.hasUpgrade(UpgradeType.SAMLevel3)) return 3; + if (player.hasUpgrade(UpgradeType.SAMLevel2)) return 2; // SAM Level 1 is available by default at game start - return Math.min(1, globalMax); + return 1; } - // For other structures, return global max - return globalMax; + if (type === UnitType.Airfield) { + // Airfield tech level is based on bomber upgrades + if (player.hasUpgrade(UpgradeType.BomberLevel3)) return 3; + if (player.hasUpgrade(UpgradeType.BomberLevel2)) return 2; + return 1; + } + + // Non-tech-upgradeable structures always have tech level 1 + return 1; } // Resolve a UnitType value from a stored string value (String(UnitType.X)) From cff23ef0d9a2b8559c4c81403d90fc3c5872fdcf Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 21:49:13 +0100 Subject: [PATCH 05/16] feat: Complete structure stacking system with proper counts and unlimited SAM/Silo stacking - Fix structure stacking system to properly increment stackCount via setStackCount() - Update playerMaxStructureLevel() to return 99 for all stackable structures (SAM, Silo, Airfield) - Remove legacy cap of 3 from maxStructureLevel() for SAM and Silo - Implement launchesRemaining system for SAM (fire stackCount times before cooldown) - Fix SAM/Silo cooldown calculation to use effectiveMaxHealth() instead of base health - SAM range now uses playerMaxStructureTechLevel() (tech tier 1-3), not stack count - Display stack count instead of level for structure badges - Add SAM tech level as secondary indicator (similar to Airfield bomber level) - PlayerInfoOverlay and BuildMenu now show correct counts including stacked structures - Update unitsOwned() to use stackCount() for all stackable structures - Rename 'Upgrade Structures' button to 'Stack Structures' --- src/client/graphics/layers/BuildMenu.ts | 9 +-- src/client/graphics/layers/ControlPanel2.ts | 12 ++-- .../graphics/layers/PlayerInfoOverlay.ts | 9 ++- .../graphics/layers/RangeOverlayLayer.ts | 4 +- src/client/graphics/layers/StructureLayer.ts | 44 +++++++++---- src/core/execution/BomberExecution.ts | 4 +- src/core/execution/ConstructionExecution.ts | 4 +- src/core/execution/SAMLauncherExecution.ts | 63 ++++++++++++------- .../execution/UpgradeStructureExecution.ts | 3 + src/core/game/PlayerImpl.ts | 38 +++++------ src/core/game/UnitImpl.ts | 30 +++++---- src/core/game/Upgradeables.ts | 33 ++++++---- 12 files changed, 154 insertions(+), 99 deletions(-) diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index da82a97f2..c76734cf7 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -33,7 +33,7 @@ import { isUpgradeableUnit, maxStackCount, maxUnitLevel, - playerMaxStructureLevel, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; @@ -618,7 +618,7 @@ export class BuildMenu extends LitElement { private _structureTechLevel(type: UnitType): number { const player = this.game?.myPlayer(); if (!player) return 1; - return playerMaxStructureLevel(player, type); + return playerMaxStructureTechLevel(player, type); } private _desiredUnitLevel(type: UnitType): number { @@ -645,7 +645,8 @@ export class BuildMenu extends LitElement { if (!player) { return "?"; } - return player.units(item.unitType).length.toString(); + // Use unitsOwned() to get the correct count including stacked structures + return player.unitsOwned(item.unitType).toString(); } private getUnitDisplayName(unitType: UnitType, baseName: string): string { @@ -708,7 +709,7 @@ export class BuildMenu extends LitElement { // Handle tech-upgradeable structures (SAM, Airfield) if (isTechUpgradeableStructure(unitType)) { - const techLevel = playerMaxStructureLevel(player, unitType); + const techLevel = playerMaxStructureTechLevel(player, unitType); const stackCount = this._desiredStackCount(unitType); let name = baseName; diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index ff170000d..d9e55113a 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -1760,24 +1760,24 @@ export class ControlPanel2 extends LitElement implements Layer { class="upgrade-structures-button ${this.uiState.upgradeMode ? "selected" : ""}" - title="Click structures to upgrade them" + title="Click structures to add stacks (+1 per click)" @click=${() => { const enabled = !this.uiState.upgradeMode; this.uiState.upgradeMode = enabled; this.eventBus.emit(new ToggleUpgradeModeEvent(enabled)); - // Disable mass production if upgrade is enabled + // Disable mass production if stacking is enabled if (enabled && this._multibuildEnabled) { this._multibuildEnabled = false; this.uiState.multibuildEnabled = false; } - // Disable bomber upgrade mode if structure upgrade is enabled + // Disable bomber upgrade mode if stacking is enabled if (enabled && this.uiState.bomberUpgradeMode) { this.uiState.bomberUpgradeMode = false; this.eventBus.emit( new ToggleBomberUpgradeModeEvent(false), ); } - // Clear pending build selection when upgrade is enabled + // Clear pending build selection when stacking is enabled if (enabled) { this.uiState.pendingBuildUnitType = null; } @@ -1787,9 +1787,9 @@ export class ControlPanel2 extends LitElement implements Layer { Upgrade - Upgrade Structures + Stack Structures
r.unit.id() === unit.id()); if (target) { @@ -1043,18 +1044,37 @@ export class StructureLayer implements Layer { !this.structureLevels.has(id) && unit.type() !== UnitType.Construction ) { - // Initialize with server level (typically 1 unless upgraded before client joined) + // Initialize with stack count (for display) instead of level // For airfields, set secondary to bomber upgrade level - const secondary = - unit.type() === UnitType.Airfield ? unit.bomberLevel() : 0; - this.structureLevels.set(id, { primary: unit.level(), secondary }); + // For SAMs, set secondary to SAM tech level + let secondary = 0; + if (unit.type() === UnitType.Airfield) { + secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + secondary = playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + } + } + this.structureLevels.set(id, { + primary: unit.stackCount?.() ?? 1, + secondary, + }); } else if (this.structureLevels.has(id)) { - // Keep in sync with authoritative server level each tick/render cycle + // Keep in sync with authoritative server stack count each tick/render cycle const rec = this.structureLevels.get(id)!; - rec.primary = unit.level(); + rec.primary = unit.stackCount?.() ?? 1; // For airfields, update secondary to bomber upgrade level if (unit.type() === UnitType.Airfield) { rec.secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + rec.secondary = playerMaxStructureTechLevel( + player, + UnitType.SAMLauncher, + ); + } } } } diff --git a/src/core/execution/BomberExecution.ts b/src/core/execution/BomberExecution.ts index f88760eea..f2f198cc7 100644 --- a/src/core/execution/BomberExecution.ts +++ b/src/core/execution/BomberExecution.ts @@ -1,6 +1,7 @@ import type { Execution, Game, Player, Unit } from "../game/Game"; import { UnitType } from "../game/Game"; import type { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; import { StraightPathFinder } from "../pathfinding/PathFinding"; import { roadEffectModifiers } from "../tech/TechEffects"; @@ -546,7 +547,8 @@ export class BomberExecution implements Execution { private getEffectiveSAMRange(sam: Unit): number { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = sam.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel(sam.owner(), UnitType.SAMLauncher); if (lvl <= 1) return base; // Apply per-upgrade multiplicative increase const factor = Math.pow(1 + bonus, lvl - 1); diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index a00b3520e..b8a190bdb 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -18,7 +18,7 @@ import { isStackableStructure, isTechUpgradeableStructure, isUpgradeableUnit, - playerMaxStructureLevel, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../game/Upgradeables"; import { constructionSpeedModifiers } from "../tech/TechEffects"; @@ -335,7 +335,7 @@ export class ConstructionExecution implements Execution { return playerMaxUnitLevel(this.player, type); } if (isTechUpgradeableStructure(type)) { - return playerMaxStructureLevel(this.player, type); + return playerMaxStructureTechLevel(this.player, type); } return 1; } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 685be49d3..e21c79cb5 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -8,6 +8,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; @@ -45,7 +46,11 @@ class SAMTargetingSystem { private effectiveSamRange(): number { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = this.sam.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel( + this.sam.owner(), + UnitType.SAMLauncher, + ); if (lvl <= 1) return base; // Apply per-upgrade multiplicative increase const factor = Math.pow(1 + bonus, lvl - 1); @@ -83,6 +88,12 @@ class SAMTargetingSystem { } public getSingleTarget(): Target | null { + const targets = this.getMultipleTargets(1); + return targets.length > 0 ? targets[0] : null; + } + + // Get multiple targets for stacked SAMs - each SAM can target a different nuke + public getMultipleTargets(maxCount: number): Target[] { // Look beyond the SAM range so it can preshot nukes const detectionRange = this.effectiveSamRange() * 1.5; const nukes = this.mg.nearbyUnits( @@ -104,6 +115,10 @@ class SAMTargetingSystem { if (this.nukesToIgnore.has(nuke.unit.id())) { continue; } + // Skip nukes already being targeted by a SAM missile + if (nuke.unit.targetedBySAM()) { + continue; + } const interceptionTile = this.computeInterceptionTile(nuke.unit); if (interceptionTile !== undefined) { targets.push({ unit: nuke.unit, tile: interceptionTile }); @@ -113,8 +128,9 @@ class SAMTargetingSystem { } } - return ( - targets.sort((a: Target, b: Target) => { + // Sort by priority (H-bombs first) and return up to maxCount + return targets + .sort((a: Target, b: Target) => { // Prioritize Hydrogen Bombs if ( a.unit.type() === UnitType.HydrogenBomb && @@ -128,8 +144,8 @@ class SAMTargetingSystem { return 1; return 0; - })[0] ?? null - ); + }) + .slice(0, maxCount); } } @@ -258,6 +274,7 @@ export class SAMLauncherExecution implements Execution { }, ); + // Get a single target - stacked SAMs use launchesRemaining to fire multiple times before cooldown let target: Target | null = null; if (mirvWarheadTargets.length === 0) { target = this.targetingSystem.getSingleTarget(); @@ -268,11 +285,8 @@ export class SAMLauncherExecution implements Execution { this.sam.touch(); } - const isSingleTarget = !!(target && !target.unit.targetedBySAM()); - if ( - (isSingleTarget || mirvWarheadTargets.length > 0) && - !isPeaceTimerActive - ) { + const hasTarget = target !== null; + if ((hasTarget || mirvWarheadTargets.length > 0) && !isPeaceTimerActive) { this.sam.launch(); const type = mirvWarheadTargets.length > 0 @@ -311,20 +325,17 @@ export class SAMLauncherExecution implements Execution { mirvWarheadTargets.length, ); } else if (target !== null) { + // Fire one missile at the target target.unit.setTargetedBySAM(true); - // Fire stackCount missiles (one for each stacked SAM) - const missileCount = this.sam.stackCount?.() ?? 1; - for (let i = 0; i < missileCount; i++) { - this.mg.addExecution( - new SAMMissileExecution( - this.sam.tile(), - this.sam.owner(), - this.sam, - target.unit, - target.tile, - ), - ); - } + this.mg.addExecution( + new SAMMissileExecution( + this.sam.tile(), + this.sam.owner(), + this.sam, + target.unit, + target.tile, + ), + ); } else { // No valid target to engage (should not happen when firing) } @@ -342,7 +353,11 @@ export class SAMLauncherExecution implements Execution { const effectiveRange = (() => { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = this.sam!.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel( + this.sam!.owner(), + UnitType.SAMLauncher, + ); if (lvl <= 1) return base; const factor = Math.pow(1 + bonus, lvl - 1); return Math.round(base * factor); diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index 5492f4d17..31dc28a4a 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -59,6 +59,9 @@ export class UpgradeStructureExecution implements Execution { return; } this.player.removeGold(upgradeCost); + // Increment stack count first, then apply HP bonus + const currentStack = this.unit.stackCount?.() ?? 1; + (this.unit as UnitImpl).setStackCount(currentStack + 1); (this.unit as UnitImpl).upgradeStructure(); this._isActive = false; return; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 263d4a6f8..5ff94353f 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -348,19 +348,26 @@ export class PlayerImpl implements Player { // Count of units owned by the player, including construction unitsOwned(type: UnitType): number { let total = 0; + // All stackable structure types + const stackableTypes = new Set([ + UnitType.City, + UnitType.Port, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.SAMLauncher, + UnitType.Airfield, + UnitType.MissileSilo, + ]); + const isStackable = stackableTypes.has(type); + for (const unit of this._units) { if (unit.type() === type) { - if ( - type === UnitType.City || - type === UnitType.Port || - type === UnitType.Hospital || - type === UnitType.Academy || - type === UnitType.ResearchLab || - type === UnitType.Factory - ) { - // Upgraded cities, ports, hospitals, and academies count toward totals + if (isStackable) { + // Stacked structures count their stackCount toward totals // (affects scaling like new build cost and display counts) - total += (unit as any).level?.() ?? 1; + total += unit.stackCount?.() ?? 1; } else { total++; } @@ -368,15 +375,8 @@ export class PlayerImpl implements Player { } if (unit.type() !== UnitType.Construction) continue; if (unit.constructionType() !== type) continue; - // For upgradeable structures, count the target level instead of just 1 - if ( - type === UnitType.City || - type === UnitType.Port || - type === UnitType.Hospital || - type === UnitType.Academy || - type === UnitType.ResearchLab || - type === UnitType.Factory - ) { + // For stackable structures, count the target level instead of just 1 + if (isStackable) { total += unit.constructionTargetLevel(); } else { total++; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 443ecd85e..04b86d278 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -353,11 +353,12 @@ export class UnitImpl implements Unit { return; } case UnitType.MissileSilo: { - // Cap silo upgrades at level 3 - if (this._level >= 3) { - return; - } + // No cap for silo stacking this._level += 1; + // Reset launches remaining to allow more launches + if (this._launchesRemaining !== null) { + this._launchesRemaining += 1; + } this._bonusMaxHealth += 250; const healed = Number(this._health) + 250; const capped = Math.min(healed, this.effectiveMaxHealth()); @@ -368,10 +369,6 @@ export class UnitImpl implements Unit { return; } case UnitType.SAMLauncher: { - // Cap SAM upgrades at level 3 - if (this._level >= 3) { - return; - } this._level += 1; // Small durability boost per upgrade, aligned with MissileSilo behavior this._bonusMaxHealth += 250; @@ -664,8 +661,12 @@ export class UnitImpl implements Unit { } launch(duration?: Tick): void { - // For stacked missile silos: allow multiple launches before cooldown - if (this.type() === UnitType.MissileSilo && this._stackCount > 1) { + // For stacked missile silos and SAMs: allow multiple launches before cooldown + if ( + (this.type() === UnitType.MissileSilo || + this.type() === UnitType.SAMLauncher) && + this._stackCount > 1 + ) { // Initialize launches remaining on first launch if (this._launchesRemaining === null) { this._launchesRemaining = this._stackCount - 1; // First launch uses one @@ -691,11 +692,8 @@ export class UnitImpl implements Unit { } else { // Choose default by unit type if (this.type() === UnitType.MissileSilo) { - // Reduce cooldown by 20% per upgrade level beyond 1: L1=100%, L2=80%, L3=60% - const base = this.mg.config().SiloCooldown(); - const levelsAboveOne = Math.max(0, this._level - 1); - const multiplier = Math.max(0, 1 - 0.2 * levelsAboveOne); - this._cooldownDuration = Math.floor(base * multiplier); + // Use base cooldown - stacking doesn't affect cooldown duration + this._cooldownDuration = this.mg.config().SiloCooldown(); } else if (this.type() === UnitType.SAMLauncher) { this._cooldownDuration = this.mg.config().SAMNukeCooldown(); } else if (this.type() === UnitType.City) { @@ -721,7 +719,7 @@ export class UnitImpl implements Unit { ) { if (this.hasHealth()) { const healthPercentage = - Number(this.health()) / (this.info().maxHealth ?? 1); + Number(this.health()) / this.effectiveMaxHealth(); if (healthPercentage > 0) { cooldownDuration /= healthPercentage; } diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index c6f4737da..94167ec64 100644 --- a/src/core/game/Upgradeables.ts +++ b/src/core/game/Upgradeables.ts @@ -65,11 +65,8 @@ export function maxStackCount(type: UnitType): number { return isStackableStructure(type) ? 99 : 1; } -// Legacy function - returns max stack count for most, max tech level for SAM +// Legacy function - returns max stack count (99 for all stackable structures) export function maxStructureLevel(type: UnitType): number { - if (type === UnitType.MissileSilo || type === UnitType.SAMLauncher) { - return 3; - } return isStackableStructure(type) ? 99 : 1; } @@ -142,24 +139,38 @@ export function playerMaxUnitLevel(player: HasUpgrade, type: UnitType): number { return globalMax; } -// Return maximum TECH upgrade level for a structure based on player's researched techs. -// For SAMLauncher: Surface-to-Air Missiles = level 1, Radar-Guided SAMs = level 2, -// Strategic SAM Systems = level 3. -// For Airfield: Returns the bomber level the player has researched. -// This is NOT the stack count - it's the quality tier. +// Return maximum level for a structure based on stacking capability. +// All stackable structures (including SAM, Airfield, MissileSilo) can stack up to 99. +// Note: SAM and Airfield have separate tech upgrades (SAMLevel1-3, BomberLevel1-3) +// that affect the quality/stats, but stacking is independent. export function playerMaxStructureLevel( + _player: HasUpgrade, + type: UnitType, +): number { + // All stackable structures can go up to 99 stacks + if (isUpgradeableStructure(type)) { + return 99; + } + + // Non-stackable structures have max level 1 + return 1; +} + +// Return the maximum TECH level for a structure based on player's researched techs. +// For SAMLauncher: 1-3 based on SAM upgrades. +// For Airfield: 1-3 based on bomber upgrades. +// This is for quality/stats upgrades, NOT stacking. +export function playerMaxStructureTechLevel( player: HasUpgrade, type: UnitType, ): number { if (type === UnitType.SAMLauncher) { if (player.hasUpgrade(UpgradeType.SAMLevel3)) return 3; if (player.hasUpgrade(UpgradeType.SAMLevel2)) return 2; - // SAM Level 1 is available by default at game start return 1; } if (type === UnitType.Airfield) { - // Airfield tech level is based on bomber upgrades if (player.hasUpgrade(UpgradeType.BomberLevel3)) return 3; if (player.hasUpgrade(UpgradeType.BomberLevel2)) return 2; return 1; From 604033efa6c3c2f2b82b60d78e884fd34753dd6d Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 22:10:58 +0100 Subject: [PATCH 06/16] refactor: Shorten submarine level 1 name in build menu to 'Diesel Sub' --- src/client/graphics/layers/BuildMenu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index c76734cf7..38d8742f3 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -680,7 +680,7 @@ export class BuildMenu extends LitElement { if (unitType === UnitType.Submarine) { switch (level) { case 1: - return "Diesel-Electric Sub"; + return "Diesel Sub"; case 2: return "Tactical Sub"; case 3: From c06403241cbcdca21e695409a47a2dbb699654f2 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 22:17:08 +0100 Subject: [PATCH 07/16] fix: gate diesel subs behind Sea-1 and allow research labs by default --- src/core/game/Upgradeables.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index 94167ec64..551f592e8 100644 --- a/src/core/game/Upgradeables.ts +++ b/src/core/game/Upgradeables.ts @@ -193,9 +193,14 @@ export function tryParseUnitType(value: string): UnitType | null { export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean { switch (type) { case UnitType.Warship: - case UnitType.Submarine: - // Warship and Submarine Level 1 are available by default at game start + // Warship Level 1 is available by default at game start return true; + case UnitType.Submarine: + // Diesel Sub unlocks with Sea Level 1 (Submarine research) + return ( + player.hasUpgrade(UpgradeType.SubmarineResearch) || + player.hasUpgrade(UpgradeType.SubmarineLevel1) + ); case UnitType.Airfield: case UnitType.FighterJet: case UnitType.Bomber: @@ -218,7 +223,8 @@ export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean { case UnitType.Hospital: return player.hasUpgrade(UpgradeType.HospitalResearch); case UnitType.ResearchLab: - return player.hasUpgrade(UpgradeType.ResearchLabResearch); + // Research Lab is available without a tech gate + return true; default: return true; } From 755b029c4344466595ec621721e9eb1b2ca17af8 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 22:22:08 +0100 Subject: [PATCH 08/16] chore: show tech short description in unlock toast --- src/client/graphics/layers/TechUnlockNotification.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts index 95aaf5dcc..0c1ed838f 100644 --- a/src/client/graphics/layers/TechUnlockNotification.ts +++ b/src/client/graphics/layers/TechUnlockNotification.ts @@ -87,10 +87,11 @@ export class TechUnlockNotification extends LitElement implements Layer { const meta = getTechMeta(techId, { strict: false }); if (!meta) continue; this.seenTechs.add(techId); + const body = meta.shortDescription ?? meta.description ?? ""; this.enqueue({ id: techId, name: meta.name ?? techId, - description: meta.description ?? "", + description: body, }); } for (const techId of filtered) this.seenTechs.add(techId); From f0cf9d6e44a27fac46c2a37560738f54aefa5e03 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sat, 13 Dec 2025 22:28:45 +0100 Subject: [PATCH 09/16] ui: move stack chip and swap stack controls --- src/client/graphics/layers/BuildMenu.ts | 17 ++--- src/client/graphics/layers/ControlPanel2.ts | 70 ++++++++++----------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 38d8742f3..0e11105a6 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -722,19 +722,14 @@ export class BuildMenu extends LitElement { : baseName; } - // Show stack count if > 1 - if (stackCount > 1) { - return `ร—${stackCount} ${name}`; - } + // Do not prefix stack count in the label; chip handles it return name; } // Handle other stackable structures if (isStackableStructure(unitType)) { - const stackCount = this._desiredStackCount(unitType); - if (stackCount > 1) { - return `ร—${stackCount} ${baseName}`; - } + // Do not prefix stack count in the label; chip handles it + return baseName; } return baseName; @@ -786,6 +781,7 @@ export class BuildMenu extends LitElement { item.unitType, baseName, ); + const desiredStack = this._desiredStackCount(item.unitType); return html`
+ ${desiredStack > 1 + ? html`
+ ร—${desiredStack} +
` + : ""} ${item.countable ? html`
${this.count(item)} diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index d9e55113a..97d3de8db 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -1694,6 +1694,41 @@ export class ControlPanel2 extends LitElement implements Layer { /> Multi-Build Structures +
-
Date: Sat, 13 Dec 2025 23:25:16 +0100 Subject: [PATCH 10/16] feat: cap structure stacking at 25 across client+server\n\n- Add MAX_STACK_COUNT=25 and clamp in helpers (maxStackCount, maxStructureLevel, playerMaxStructureLevel)\n- Clamp ConstructionExecution desired stack, UnitImpl.setStackCount\n- Limit client intent payload and schema to 25 (Transport, Schemas)\n- Keeps UI controls honoring cap via shared helpers --- src/client/Transport.ts | 2 +- src/core/Schemas.ts | 2 +- src/core/execution/ConstructionExecution.ts | 3 ++- src/core/game/UnitImpl.ts | 4 +++- src/core/game/Upgradeables.ts | 14 ++++++++------ 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index c4bb47d9f..b6dfe58c1 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -771,7 +771,7 @@ export class Transport { const obj = JSON.parse(rawStack) as Record; const val = obj?.[String(event.unit)]; if (typeof val === "number" && val > 1) { - stackCount = Math.min(99, val); + stackCount = Math.min(25, val); } } } catch { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index dcf8a0d3b..687f07fa6 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -425,7 +425,7 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ tile: z.number(), // Optional desired starting level for upgradeable structures. // Server will clamp based on type and game rules. - targetLevel: z.number().int().min(1).max(99).optional(), + targetLevel: z.number().int().min(1).max(25).optional(), // Optional desired bomber upgrade level for airfields. // Server will clamp based on maxUnitLevel(UnitType.Bomber). bomberLevel: z.number().int().min(1).max(99).optional(), diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index b8a190bdb..dbd58e342 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -18,6 +18,7 @@ import { isStackableStructure, isTechUpgradeableStructure, isUpgradeableUnit, + maxStackCount, playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../game/Upgradeables"; @@ -324,7 +325,7 @@ export class ConstructionExecution implements Execution { private computeStackCount(type: UnitType): number { // Use client-provided stack count, clamped to valid range if (isStackableStructure(type) && this.stackCount && this.stackCount > 1) { - return Math.min(99, this.stackCount); + return Math.min(maxStackCount(type), this.stackCount); } return 1; } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 04b86d278..d2980cf2b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -16,6 +16,7 @@ import { GameImpl } from "./GameImpl"; import { TileRef } from "./GameMap"; import { GameUpdateType, UnitUpdate } from "./GameUpdates"; import { PlayerImpl } from "./PlayerImpl"; +import { maxStackCount } from "./Upgradeables"; export class UnitImpl implements Unit { private _active = true; @@ -304,7 +305,8 @@ export class UnitImpl implements Unit { } setStackCount(count: number): void { - this._stackCount = Math.max(1, count); + const cap = maxStackCount(this._type); + this._stackCount = Math.max(1, Math.min(cap, count)); this.mg.addUpdate(this.toUpdate()); } diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index 551f592e8..3cd5994ce 100644 --- a/src/core/game/Upgradeables.ts +++ b/src/core/game/Upgradeables.ts @@ -52,6 +52,8 @@ export function isUpgradeableUnit(type: UnitType): boolean { return UPGRADEABLE_UNITS.has(type); } +const MAX_STACK_COUNT = 25; + // Maximum TECH upgrade level for structures (SAM, Airfield) // This is NOT the stack count - it's the quality tier from research. export function maxStructureTechLevel(type: UnitType): number { @@ -62,12 +64,12 @@ export function maxStructureTechLevel(type: UnitType): number { // Maximum stack count for stackable structures export function maxStackCount(type: UnitType): number { - return isStackableStructure(type) ? 99 : 1; + return isStackableStructure(type) ? MAX_STACK_COUNT : 1; } -// Legacy function - returns max stack count (99 for all stackable structures) +// Legacy function - returns max stack count (25 for all stackable structures) export function maxStructureLevel(type: UnitType): number { - return isStackableStructure(type) ? 99 : 1; + return isStackableStructure(type) ? MAX_STACK_COUNT : 1; } // Return maximum upgrade level for upgradeable combat units. @@ -140,16 +142,16 @@ export function playerMaxUnitLevel(player: HasUpgrade, type: UnitType): number { } // Return maximum level for a structure based on stacking capability. -// All stackable structures (including SAM, Airfield, MissileSilo) can stack up to 99. +// All stackable structures (including SAM, Airfield, MissileSilo) can stack up to 25. // Note: SAM and Airfield have separate tech upgrades (SAMLevel1-3, BomberLevel1-3) // that affect the quality/stats, but stacking is independent. export function playerMaxStructureLevel( _player: HasUpgrade, type: UnitType, ): number { - // All stackable structures can go up to 99 stacks + // All stackable structures can go up to 25 stacks if (isUpgradeableStructure(type)) { - return 99; + return MAX_STACK_COUNT; } // Non-stackable structures have max level 1 From 20d802ae1bf7fab911841473025c8b81ef34233f Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 00:23:34 +0100 Subject: [PATCH 11/16] feat: add multi-priority research system with category-level controls - Support multiple simultaneous research priorities (Set-based) - Add category 'Prioritize' button to set all techs in category - Cross-category prioritization: selecting individual tech clears same-level techs from other categories - Category prioritization clears other categories but preserves existing priorities in selected category - Locked prioritized techs show gold background with lock icon - Research allocation: 60% split among available priorities, 40% to others - Priority prerequisites get 60% when priorities locked - Update PlayerView to expose full priorities Set --- src/client/ResearchTreeModal.ts | 135 ++++++++++++++++-- src/core/execution/PlayerExecution.ts | 39 +++-- .../execution/ResearchTreeSelectExecution.ts | 13 +- src/core/game/GameUpdates.ts | 4 +- src/core/game/GameView.ts | 3 + src/core/game/PlayerImpl.ts | 31 ++-- 6 files changed, 188 insertions(+), 37 deletions(-) diff --git a/src/client/ResearchTreeModal.ts b/src/client/ResearchTreeModal.ts index d40e75bd8..898aabd2b 100644 --- a/src/client/ResearchTreeModal.ts +++ b/src/client/ResearchTreeModal.ts @@ -119,10 +119,62 @@ export class ResearchTreeModal extends LitElement { const researched = this.researchedIDsFromGame(); if (me.hasResearchedTech?.(id)) return; + // Find the clicked tech to get its level and category + const clickedTech = this.techs.find((t) => t.id === id); + if (!clickedTech) return; + + const priorities = me.researchPriorities?.() ?? new Set(); + + // If this tech is being prioritized (not toggled off) + const willBePrioritized = !priorities.has(id); + + if (willBePrioritized) { + // Remove priorities from same-level techs in other categories + for (const tech of this.techs) { + if ( + tech.level === clickedTech.level && + tech.category !== clickedTech.category && + priorities.has(tech.id) + ) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); + } + } + } + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(id)); this.requestUpdate(); } + private prioritizeCategory(category: Category) { + if (!this.game || !this.eventBus) return; + const me = this.game.myPlayer(); + if (!me) return; + + // First, clear all priorities from other categories + const allTechs = this.techs; + const researched = this.researchedIDsFromGame(); + const priorities = me.researchPriorities?.() ?? new Set(); + + // Remove priorities from techs in other categories + for (const tech of allTechs) { + if (tech.category !== category && priorities.has(tech.id)) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); + } + } + + // Now prioritize all techs in this category that aren't already prioritized + const categoryTechs = this.techs.filter((t) => t.category === category); + + for (const tech of categoryTechs) { + if (!researched.has(tech.id) && !priorities.has(tech.id)) { + this.eventBus.emit(new SendResearchTreeSelectIntentEvent(tech.id)); + } + } + + // Force UI update after a short delay to show changes + setTimeout(() => this.requestUpdate(), 50); + } + private handleInvestmentSync = (event: Event) => { const { detail } = event as CustomEvent; if (!detail) return; @@ -201,7 +253,7 @@ export class ResearchTreeModal extends LitElement { Nuclear: "#e74c3c", }; const me = this.game?.myPlayer?.(); - const priority = me?.researchPriorityTech?.() ?? null; + const priorities = me?.researchPriorities?.() ?? new Set(); const percentByTechId = (() => { const map = new Map(); @@ -482,6 +534,40 @@ export class ResearchTreeModal extends LitElement { opacity: 0.8; } + .priority-btn.locked-prioritized { + background: #f39c12; + color: #fff; + border-color: #e67e22; + opacity: 0.7; + } + + .category-prioritize-btn { + background: none; + border: 1px solid var(--ui-border-muted); + color: var(--ui-text-muted); + cursor: pointer; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.8em; + font-weight: 600; + margin-left: auto; + transition: all 0.2s; + white-space: nowrap; + } + + .category-prioritize-btn:hover { + background: var(--ui-secondary); + color: var(--ui-text-default); + border-color: var(--ui-text-light); + } + + .category-prioritize-btn.active { + background: #f39c12; + color: #fff; + border-color: #e67e22; + box-shadow: 0 0 8px rgba(243, 156, 18, 0.4); + } + .bar-sea { background-color: #3498db; } @@ -532,6 +618,14 @@ export class ResearchTreeModal extends LitElement { }; const iconSrc = icons[cat]; + // Check if all non-researched techs in category are prioritized + const nonResearchedTechs = techs.filter( + (t) => !researched.has(t.id), + ); + const allPrioritized = + nonResearchedTechs.length > 0 && + nonResearchedTechs.every((t) => priorities.has(t.id)); + return html`
@@ -542,12 +636,21 @@ export class ResearchTreeModal extends LitElement { >
` : ""} ${cat.toUpperCase()} +
${techs.map((tech) => { const available = this.isAvailable(tech.id, researched); const isResearched = researched.has(tech.id); const pct = percentByTechId.get(tech.id) ?? 0; - const isPriority = priority === tech.id; + const isPriority = priorities.has(tech.id); const display = getTechDisplay(tech); const tooltip = getDetailedTechTooltip(tech.id); @@ -561,6 +664,18 @@ export class ResearchTreeModal extends LitElement { const barClass = `bar-${cat.toLowerCase()}`; + // Locked techs show as prioritized (yellow) if they're set as priority + const btnClass = [ + "priority-btn", + isPriority && available + ? "active" + : isPriority && !available + ? "locked-prioritized" + : "", + ] + .filter(Boolean) + .join(" "); + return html`
`; diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 8680f64ba..0ae68705e 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -182,11 +182,12 @@ export class PlayerExecution implements Execution { ); if (available.length === 0) return; - // Allocation: 50% to priority, 50% split among remaining; if no valid priority, split evenly - const priorityId: string | null = - (this.player as any).researchPriority?.() ?? null; - const priorityInSet = - priorityId !== null && available.some((n) => n.id === priorityId); + // Get all priorities and check which are available + const allPriorities: Set = + (this.player as any).researchPriorities?.() ?? new Set(); + const availablePriorities = available.filter((n) => + allPriorities.has(n.id), + ); const k = this.config.researchK(); const bMin = this.config.researchBeakerMin(); @@ -240,16 +241,20 @@ export class PlayerExecution implements Execution { }; const alloc: Record = {}; - if (priorityId && !priorityInSet) { - // Priority target not available: allocate 60% to the frontier of its missing prereqs - const pathSet = buildMissingPrereqPath(priorityId); - const frontier = available.filter((n) => pathSet.has(n.id)); + if (allPriorities.size > 0 && availablePriorities.length === 0) { + // Has priorities but none available: allocate 60% to the frontier of their missing prereqs + const allPathSets = new Set(); + for (const priorityId of allPriorities) { + const pathSet = buildMissingPrereqPath(priorityId); + pathSet.forEach((id) => allPathSets.add(id)); + } + const frontier = available.filter((n) => allPathSets.has(n.id)); if (frontier.length > 0) { const priorityShare = 0.6 * xTotal; const shareFrontier = priorityShare / frontier.length; for (const n of frontier) alloc[n.id] = (alloc[n.id] ?? 0) + shareFrontier; - const others = available.filter((n) => !pathSet.has(n.id)); + const others = available.filter((n) => !allPathSets.has(n.id)); const remaining = xTotal - priorityShare; const shareOthers = others.length > 0 ? remaining / others.length : 0; for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + shareOthers; @@ -258,14 +263,22 @@ export class PlayerExecution implements Execution { const share = xTotal / available.length; for (const n of available) alloc[n.id] = share; } - } else if (priorityInSet && available.length > 1) { + } else if ( + availablePriorities.length > 0 && + available.length > availablePriorities.length + ) { + // Some priorities available: 60% split among priorities, 40% among others const priorityShare = 0.6 * xTotal; - alloc[priorityId!] = (alloc[priorityId!] ?? 0) + priorityShare; - const others = available.filter((n) => n.id !== priorityId); + const sharePriority = priorityShare / availablePriorities.length; + for (const n of availablePriorities) { + alloc[n.id] = sharePriority; + } + const others = available.filter((n) => !allPriorities.has(n.id)); const share = others.length > 0 ? (xTotal - priorityShare) / others.length : 0; for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + share; } else { + // No priorities or all available are prioritized: even split const share = xTotal / available.length; for (const n of available) alloc[n.id] = share; } diff --git a/src/core/execution/ResearchTreeSelectExecution.ts b/src/core/execution/ResearchTreeSelectExecution.ts index 4b15407d1..ce893ff7e 100644 --- a/src/core/execution/ResearchTreeSelectExecution.ts +++ b/src/core/execution/ResearchTreeSelectExecution.ts @@ -39,18 +39,19 @@ export class ResearchTreeSelectExecution implements Execution { // Complete the research immediately; side-effects are handled by addResearchedTech() (this.player as any).addResearchedTech?.(this.techId); - // Clear any existing priority since research is completed - (this.player as any).setResearchPriority?.(null); + // Remove from priorities since research is completed + const priorities = (this.player as any).researchPriorities?.(); + if (priorities?.has(this.techId)) { + (this.player as any).setResearchPriority?.(this.techId); // Toggle off + } this._active = false; return; } // Fall through to priority toggle if not available or already researched } - // Default behavior: toggle research priority on click - const current = (this.player as any).researchPriority?.() ?? null; - const next = current === this.techId ? null : this.techId; - (this.player as any).setResearchPriority?.(next); + // Default behavior: toggle research priority (add/remove from set) + (this.player as any).setResearchPriority?.(this.techId); this._active = false; } } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index b03af37d9..4c446a403 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -236,8 +236,10 @@ export interface PlayerUpdate { researchTreeTechs: string[]; // Research progress (beakers) per tech id (optional; omitted if none) researchTreeBeakers?: Record; - // Currently selected research priority tech id (optional) + // Currently selected research priority tech id (optional, legacy single priority) researchPriorityTech?: string | null; + // All selected research priority tech ids (optional; omitted if none) + researchPriorities?: string[]; // Policy directive choices: directiveId -> optionId (optional; omitted if none) policyChoices?: Record; // Whether the player has unseen policy directives to review diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 2fec2ff7c..6788c23a7 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -344,6 +344,9 @@ export class PlayerView { researchPriorityTech(): string | null { return this.data.researchPriorityTech ?? null; } + researchPriorities(): Set { + return new Set(this.data.researchPriorities ?? []); + } // Policy Directive access getPolicyChoice(directiveId: string): string | null { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 5ff94353f..c0ed15e0b 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -101,8 +101,8 @@ export class PlayerImpl implements Player { private _researchTreeTechs: Set = new Set(); // Per-match research progress (beakers) per tech private _researchBeakers: Map = new Map(); - // Currently selected research priority tech id - private _researchPriority: string | null = null; + // Currently selected research priority tech ids (can have multiple) + private _researchPriorities: Set = new Set(); // Policy directive choices: directiveId -> optionId private _policyChoices: Map = new Map(); // Track unseen policy directives (based on newly unlocked techs) @@ -254,7 +254,12 @@ export class PlayerImpl implements Player { this._researchBeakers.size > 0 ? Object.fromEntries(this._researchBeakers) : undefined, - researchPriorityTech: this._researchPriority, + researchPriorityTech: + this._researchPriorities.values().next().value ?? null, + researchPriorities: + this._researchPriorities.size > 0 + ? Array.from(this._researchPriorities) + : undefined, policyChoices: this._policyChoices.size > 0 ? Object.fromEntries(this._policyChoices) @@ -555,7 +560,6 @@ export class PlayerImpl implements Player { } if (toRemove.length === 0 && progressToClear.length === 0) return; - const priority = this._researchPriority; const cleared = new Set([...toRemove, ...progressToClear]); for (const techId of toRemove) { @@ -565,8 +569,9 @@ export class PlayerImpl implements Player { this._researchBeakers.delete(techId); } - if (priority && cleared.has(priority)) { - this._researchPriority = null; + // Remove cleared techs from priorities + for (const techId of cleared) { + this._researchPriorities.delete(techId); } } hasResearchedTech(techId: string): boolean { @@ -595,10 +600,20 @@ export class PlayerImpl implements Player { return { completed, newBeakers: total }; } setResearchPriority(techId: string | null): void { - this._researchPriority = techId; + if (techId === null) { + this._researchPriorities.clear(); + } else if (this._researchPriorities.has(techId)) { + this._researchPriorities.delete(techId); + } else { + this._researchPriorities.add(techId); + } } researchPriority(): string | null { - return this._researchPriority; + // Return first priority for backward compatibility + return this._researchPriorities.values().next().value ?? null; + } + researchPriorities(): Set { + return this._researchPriorities; } // Policy Directive methods From b2a7f92750fad4b6fd40e899910b04a216de2f18 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 00:37:52 +0100 Subject: [PATCH 12/16] Update tests for new tech tree structure (Land/Sea/Air/Nuclear) --- .../ResearchPriorityAllocation.test.ts | 71 ++++++++-------- .../ResearchTreeSelectExecution.test.ts | 8 +- .../UpgradeStructureExecution.test.ts | 25 ++++-- tests/core/game/CargoManager.test.ts | 2 +- tests/core/tech/EconomyTechEffects.test.ts | 4 +- tests/core/tech/TechEffects.test.ts | 10 +-- tests/integrations/ScorchedEarth.test.ts | 81 ------------------- 7 files changed, 65 insertions(+), 136 deletions(-) delete mode 100644 tests/integrations/ScorchedEarth.test.ts diff --git a/tests/core/execution/ResearchPriorityAllocation.test.ts b/tests/core/execution/ResearchPriorityAllocation.test.ts index 7d8152868..8da5ba4d8 100644 --- a/tests/core/execution/ResearchPriorityAllocation.test.ts +++ b/tests/core/execution/ResearchPriorityAllocation.test.ts @@ -44,7 +44,7 @@ describe("Research Priority Allocation", () => { }); describe("buildMissingPrereqPath logic validation", () => { - it("should identify all prerequisites for Economy-4 when nothing is researched", () => { + it("should identify all prerequisites for Land-4 when nothing is researched", () => { // Simulate the buildMissingPrereqPath logic const nodes = getTechNodes(); const researched = new Set(); @@ -90,19 +90,19 @@ describe("Research Priority Allocation", () => { return path; }; - // Test: When setting priority to Economy-4, it should identify Economy-1, Economy-2, Economy-3 as prerequisites - const pathSet = buildMissingPrereqPath("Economy-4"); + // Test: When setting priority to Land-4, it should identify Land-1, Land-2, Land-3 as prerequisites + const pathSet = buildMissingPrereqPath("Land-4"); - expect(pathSet.has("Economy-1")).toBe(true); - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); - expect(pathSet.has("Economy-4")).toBe(false); // Target itself should not be in path + expect(pathSet.has("Land-1")).toBe(true); + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); + expect(pathSet.has("Land-4")).toBe(false); // Target itself should not be in path expect(pathSet.size).toBe(3); }); - it("should identify correct frontier when Economy-1 is already researched", () => { + it("should identify correct frontier when Land-1 is already researched", () => { const nodes = getTechNodes(); - const researched = new Set(["Economy-1"]); // Already researched Economy-1 + const researched = new Set(["Land-1"]); // Already researched Land-1 const byId = new Map(nodes.map((n) => [n.id, n] as const)); const sameCat = (a: string, b: string) => (byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? ""); @@ -145,12 +145,12 @@ describe("Research Priority Allocation", () => { return path; }; - // Test: When Economy-1 is researched and priority is Economy-4, path should only include Economy-2, Economy-3 - const pathSet = buildMissingPrereqPath("Economy-4"); + // Test: When Land-1 is researched and priority is Land-4, path should only include Land-2, Land-3 + const pathSet = buildMissingPrereqPath("Land-4"); - expect(pathSet.has("Economy-1")).toBe(false); // Already researched - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); + expect(pathSet.has("Land-1")).toBe(false); // Already researched + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); expect(pathSet.size).toBe(2); }); @@ -206,21 +206,20 @@ describe("Research Priority Allocation", () => { // Only level-1 techs should be available when nothing is researched const availableIds = available.map((n) => n.id); - expect(availableIds).toContain("Economy-1"); expect(availableIds).toContain("Land-1"); expect(availableIds).toContain("Sea-1"); expect(availableIds).toContain("Air-1"); expect(availableIds).toContain("Nuclear-1"); - // Set priority to Economy-4 - const pathSet = buildMissingPrereqPath("Economy-4"); + // Set priority to Land-4 + const pathSet = buildMissingPrereqPath("Land-4"); // Frontier = intersection of pathSet and available const frontier = available.filter((n) => pathSet.has(n.id)); - // Only Economy-1 should be in the frontier (the only available prereq) + // Only Land-1 should be in the frontier (the only available prereq) expect(frontier.length).toBe(1); - expect(frontier[0].id).toBe("Economy-1"); + expect(frontier[0].id).toBe("Land-1"); }); it("should prioritize frontier techs when priority target is not directly available", () => { @@ -268,13 +267,13 @@ describe("Research Priority Allocation", () => { return path; }; - const priorityId = "Economy-4"; + const priorityId = "Land-4"; const available = nodes.filter( (n) => !researched.has(n.id) && isTechAvailable(n.id, researched), ); const priorityInSet = available.some((n) => n.id === priorityId); - expect(priorityInSet).toBe(false); // Economy-4 should NOT be directly available + expect(priorityInSet).toBe(false); // Land-4 should NOT be directly available // Simulate allocation logic const xTotal = 1000; // arbitrary total research points @@ -300,12 +299,12 @@ describe("Research Priority Allocation", () => { } } - // Economy-1 should receive 50% of the total (500 points) - expect(alloc["Economy-1"]).toBe(500); + // Land-1 should receive 50% of the total (500 points) + expect(alloc["Land-1"]).toBe(500); // Other level-1 techs should share the remaining 50% - const otherTechs = ["Land-1", "Sea-1", "Air-1", "Nuclear-1"]; - const expectedShareOthers = 500 / otherTechs.length; // 125 each + const otherTechs = ["Sea-1", "Air-1", "Nuclear-1"]; + const expectedShareOthers = 500 / otherTechs.length; // ~166.67 each for (const techId of otherTechs) { expect(alloc[techId]).toBeCloseTo(expectedShareOthers, 5); } @@ -316,8 +315,8 @@ describe("Research Priority Allocation", () => { it("should eventually progress frontier as prerequisite techs complete", () => { const nodes = getTechNodes(); - // Simulate: Economy-1 is now researched - const researched = new Set(["Economy-1"]); + // Simulate: Land-1 is now researched + const researched = new Set(["Land-1"]); const byId = new Map(nodes.map((n) => [n.id, n] as const)); const sameCat = (a: string, b: string) => (byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? ""); @@ -360,24 +359,24 @@ describe("Research Priority Allocation", () => { return path; }; - const priorityId = "Economy-4"; + const priorityId = "Land-4"; const available = nodes.filter( (n) => !researched.has(n.id) && isTechAvailable(n.id, researched), ); - // Now Economy-2 should be available (since Economy-1 is researched) - expect(available.some((n) => n.id === "Economy-2")).toBe(true); + // Now Land-2 should be available (since Land-1 is researched) + expect(available.some((n) => n.id === "Land-2")).toBe(true); - // Path should now only include Economy-2 and Economy-3 + // Path should now only include Land-2 and Land-3 const pathSet = buildMissingPrereqPath(priorityId); - expect(pathSet.has("Economy-1")).toBe(false); // Already researched - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); + expect(pathSet.has("Land-1")).toBe(false); // Already researched + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); - // Frontier should be Economy-2 (the only currently available prereq) + // Frontier should be Land-2 (the only currently available prereq) const frontier = available.filter((n) => pathSet.has(n.id)); expect(frontier.length).toBe(1); - expect(frontier[0].id).toBe("Economy-2"); + expect(frontier[0].id).toBe("Land-2"); }); }); }); diff --git a/tests/core/execution/ResearchTreeSelectExecution.test.ts b/tests/core/execution/ResearchTreeSelectExecution.test.ts index bd2fd698c..c518ed6ef 100644 --- a/tests/core/execution/ResearchTreeSelectExecution.test.ts +++ b/tests/core/execution/ResearchTreeSelectExecution.test.ts @@ -34,7 +34,7 @@ describe("ResearchTreeSelectExecution", () => { // Simulate PlayerImpl side-effects when research completes (mockPlayer.addResearchedTech as jest.Mock).mockImplementation( (id: string) => { - if (id === RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM) { + if (id === RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS) { const alreadyHas = (mockPlayer.hasUpgrade as jest.Mock)( UpgradeType.Roads, ); @@ -60,10 +60,10 @@ describe("ResearchTreeSelectExecution", () => { ); }); - it("grants Roads and reconnects when Economy-1 is selected", () => { + it("grants Roads and reconnects when Land-1 is selected", () => { const exec = new ResearchTreeSelectExecution( mockPlayer as any, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); exec.init(mockGame as any, 0); exec.tick(0); @@ -78,7 +78,7 @@ describe("ResearchTreeSelectExecution", () => { (mockPlayer.hasUpgrade as jest.Mock).mockReturnValue(true); const exec = new ResearchTreeSelectExecution( mockPlayer as any, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); exec.init(mockGame as any, 0); exec.tick(0); diff --git a/tests/core/execution/UpgradeStructureExecution.test.ts b/tests/core/execution/UpgradeStructureExecution.test.ts index f06f3ac15..9fa25ec47 100644 --- a/tests/core/execution/UpgradeStructureExecution.test.ts +++ b/tests/core/execution/UpgradeStructureExecution.test.ts @@ -22,8 +22,16 @@ describe("UpgradeStructureExecution", () => { isUnit: jest.fn().mockReturnValue(true), type: jest.fn().mockReturnValue(unitType), owner: jest.fn().mockReturnValue(mockPlayer), + level: jest.fn().mockReturnValue(1), + stackCount: jest.fn().mockReturnValue(1), + setStackCount: jest.fn(), upgradeStructure: jest.fn(), - } as unknown as jest.Mocked void }>; + } as unknown as jest.Mocked< + Unit & { + upgradeStructure: () => void; + setStackCount: (count: number) => void; + } + >; return { mockPlayer, mockGame, mockUnit }; }; @@ -76,16 +84,23 @@ describe("UpgradeStructureExecution", () => { expect(mockUnit.upgradeStructure).toHaveBeenCalled(); }); - it("does not charge or upgrade a Missile Silo at max level (3)", () => { + it("does not charge or upgrade a Missile Silo at max stack level", () => { const { mockPlayer, mockGame } = makeMocks(UnitType.MissileSilo); - // Create a unit mock that reports level 3 + // Create a unit mock that reports level 25 (max stack count) const mockUnit = { isUnit: jest.fn().mockReturnValue(true), type: jest.fn().mockReturnValue(UnitType.MissileSilo), owner: jest.fn().mockReturnValue(mockPlayer), - level: jest.fn().mockReturnValue(3), + level: jest.fn().mockReturnValue(25), // MAX_STACK_COUNT + stackCount: jest.fn().mockReturnValue(25), + setStackCount: jest.fn(), upgradeStructure: jest.fn(), - } as unknown as jest.Mocked void }>; + } as unknown as jest.Mocked< + Unit & { + upgradeStructure: () => void; + setStackCount: (count: number) => void; + } + >; const exec = new UpgradeStructureExecution(mockPlayer, mockUnit); exec.init(mockGame, 0); diff --git a/tests/core/game/CargoManager.test.ts b/tests/core/game/CargoManager.test.ts index 30341b870..0609b1d8f 100644 --- a/tests/core/game/CargoManager.test.ts +++ b/tests/core/game/CargoManager.test.ts @@ -46,7 +46,7 @@ describe("CargoManager", () => { } // Grant Roads via research tech so RoadManager reconnects nodes immediately - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); // Let roads form for (let i = 0; i < 200; i++) { diff --git a/tests/core/tech/EconomyTechEffects.test.ts b/tests/core/tech/EconomyTechEffects.test.ts index 433d223fc..3fbd2138c 100644 --- a/tests/core/tech/EconomyTechEffects.test.ts +++ b/tests/core/tech/EconomyTechEffects.test.ts @@ -15,13 +15,13 @@ describe("Land infrastructure tech integrations", () => { expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); }); - it("enables HospitalResearch after researching Roads & Hospitals", async () => { + it("enables HospitalResearch after researching Modern Air Defense (Land-3)", async () => { const info = playerInfo("health", PlayerType.Human); const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); }); diff --git a/tests/core/tech/TechEffects.test.ts b/tests/core/tech/TechEffects.test.ts index 325b05b4c..920ca8876 100644 --- a/tests/core/tech/TechEffects.test.ts +++ b/tests/core/tech/TechEffects.test.ts @@ -5,8 +5,8 @@ import { } from "../../../src/core/tech/TechEffects"; describe("TechEffects", () => { - it("removes Scorched Earth upgrade when National Reconstruction Program completes", () => { - const owned = new Set([UpgradeType.ScorchedEarth]); + it("grants Roads when Land-1 (Road Network) completes", () => { + const owned = new Set(); const player = { hasUpgrade: jest.fn((upgrade: UpgradeType) => owned.has(upgrade)), addUpgrade: jest.fn((upgrade: UpgradeType) => owned.add(upgrade)), @@ -19,14 +19,10 @@ describe("TechEffects", () => { applyTechCompletionEffects( player, game, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); expect(player.addUpgrade).toHaveBeenCalledWith(UpgradeType.Roads); expect(game.markPlayerNodesForReconnection).toHaveBeenCalledWith(player); - expect(player.removeUpgrade).toHaveBeenCalledWith( - UpgradeType.ScorchedEarth, - ); - expect(owned.has(UpgradeType.ScorchedEarth)).toBe(false); }); }); diff --git a/tests/integrations/ScorchedEarth.test.ts b/tests/integrations/ScorchedEarth.test.ts deleted file mode 100644 index 2665f5f35..000000000 --- a/tests/integrations/ScorchedEarth.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ScorchedEarthExecution } from "../../src/core/execution/ScorchedEarthExecution"; -import { PlayerType, UnitType, UpgradeType } from "../../src/core/game/Game"; -import { GameImpl } from "../../src/core/game/GameImpl"; -import { PlayerImpl } from "../../src/core/game/PlayerImpl"; -import { RESEARCH_TECH_IDS } from "../../src/core/tech/TechEffects"; -import { playerInfo, setup } from "../util/Setup"; - -describe("Scorched Earth Full Cycle Integration Test", () => { - it("should allow a player to build, destroy, and rebuild their road network", async () => { - // Given: A game with a player having several cities and enough gold - const game = (await setup("ocean_and_land", { - instantBuild: true, - })) as GameImpl; - const pInfo = playerInfo("Player A", PlayerType.Human); - game.addPlayer(pInfo); - const player = game.player(pInfo.id); - player.addGold(10_000_000n); - // Allocate income to road building so construction progresses in tests - player.setRoadInvestmentRate(1); - (player as any).addWorkers(10000000); - const city1 = player.buildUnit(UnitType.City, game.ref(0, 10), {}); - const city2 = player.buildUnit(UnitType.City, game.ref(0, 12), {}); - - // Conquer a path between the cities - for (let i = 10; i <= 12; i++) { - const tile = game.ref(0, i); - if (game.owner(tile) !== player) { - game.conquer(player as PlayerImpl, tile); - } - } - - // Research core economy techs to unlock and test revocation behavior - player.addResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - - // Allow the automatic road upgrade to build out the network - for (let i = 0; i < 200; i++) { - game.executeNextTick(); - } - expect(game.roads().length).toBeGreaterThan(0); - expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - - // Step 2: Research and activate Scorched Earth, verify network destruction - player.addResearchedTech(RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE); - game.addExecution(new ScorchedEarthExecution(player)); - game.executeNextTick(); - expect(game.roads().length).toBe(0); - // Scorched Earth only destroys roads, keeps upgrades and techs - expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(true); - expect(player.roadInvestmentRate()).toBe(0); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), - ).toBe(true); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ), - ).toBe(true); - - // Step 3: Re-unlock roads and verify Scorched Earth deactivates - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false); - player.setRoadInvestmentRate(1); - for (let i = 0; i < 200; i++) { - game.executeNextTick(); - } - expect(game.roads().length).toBeGreaterThan(0); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false); - }); -}); From 41efa5605e13f4e7d05dc3592d767c8057dd76f1 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 01:02:37 +0100 Subject: [PATCH 13/16] Remove dead code: policy directives, insurance, and scorched earth - Remove ScorchedEarth upgrade and all related code - Delete ScorchedEarthExecution and integration tests - Remove SCORCHED_EARTH from UpgradeType enum - Remove scorchedEarthActivationCost from Config - Remove Insurance system - Remove INSURANCE_REFUND message type - Remove insurance refund logic from UnitImpl - Remove structureInsuranceRefundNum/Den from Config - Remove ECONOMY_INSURANCE tech ID - Remove policy directive system (referenced non-existent Economy-3 tech) - Delete PolicyDirectiveSelectExecution, MarkPolicyDirectivesSeenExecution - Delete PolicyDirectives.ts definition file - Remove policy schemas, intents, handlers from Transport/ExecutionManager - Remove policy methods from Player interface and PlayerImpl - Remove policy logic from all TechEffects modifier functions - Remove policy notification from ResearchToggleButton - Move InternationalTrade upgrade to Land-1 tech (Road Network) - Remove unused tech IDs: ECONOMY_INTERNATIONAL_TRADE, ECONOMY_TBD_LEVEL4, TRADE_POLICY_FRAMEWORK - Remove unused upgrade types: WaterUpgrade1/2/3, AirUpgrade3, EconomyUpgrade1/2/3 All tests passing (46 suites, 276 tests) --- src/client/Transport.ts | 47 ------ src/client/Utils.ts | 1 - .../graphics/layers/ResearchToggleButton.ts | 12 -- src/core/Schemas.ts | 29 +--- src/core/configuration/Config.ts | 3 - src/core/configuration/DefaultConfig.ts | 12 -- src/core/execution/ExecutionManager.ts | 13 -- .../MarkPolicyDirectivesSeenExecution.ts | 25 --- .../PolicyDirectiveSelectExecution.ts | 88 ---------- src/core/execution/ScorchedEarthExecution.ts | 74 -------- src/core/game/Game.ts | 22 --- src/core/game/GameView.ts | 8 - src/core/game/PlayerImpl.ts | 42 ----- src/core/game/UnitImpl.ts | 42 ----- src/core/tech/PolicyDirectives.ts | 159 ------------------ src/core/tech/TechEffects.ts | 130 +------------- src/core/tech/TechIds.ts | 9 +- .../execution/ScorchedEarthExecution.test.ts | 83 --------- 18 files changed, 5 insertions(+), 794 deletions(-) delete mode 100644 src/core/execution/MarkPolicyDirectivesSeenExecution.ts delete mode 100644 src/core/execution/PolicyDirectiveSelectExecution.ts delete mode 100644 src/core/execution/ScorchedEarthExecution.ts delete mode 100644 src/core/tech/PolicyDirectives.ts delete mode 100644 tests/core/execution/ScorchedEarthExecution.test.ts diff --git a/src/client/Transport.ts b/src/client/Transport.ts index b6dfe58c1..c1c434104 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -119,21 +119,10 @@ export class BuildUnitIntentEvent implements GameEvent { ) {} } -export class SendScorchedEarthIntentEvent implements GameEvent {} - export class SendResearchTreeSelectIntentEvent implements GameEvent { constructor(public readonly techId: string) {} } -export class SendPolicyDirectiveSelectIntentEvent implements GameEvent { - constructor( - public readonly directiveId: string, - public readonly optionId: string, - ) {} -} - -export class SendMarkPolicyDirectivesSeenIntentEvent implements GameEvent {} - export class SendTargetPlayerIntentEvent implements GameEvent { constructor(public readonly targetID: PlayerID) {} } @@ -346,9 +335,6 @@ export class Transport { this.onSendSetAutoBombingEvent(e), ); - this.eventBus.on(SendScorchedEarthIntentEvent, () => - this.onSendScorchedEarthIntent(), - ); this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.onSendUpgradeStructureIntent(e), ); @@ -363,14 +349,6 @@ export class Transport { this.onSendResearchTreeSelectIntent(e), ); - this.eventBus.on(SendPolicyDirectiveSelectIntentEvent, (e) => - this.onSendPolicyDirectiveSelectIntent(e), - ); - - this.eventBus.on(SendMarkPolicyDirectivesSeenIntentEvent, () => - this.onSendMarkPolicyDirectivesSeenIntent(), - ); - this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); @@ -789,13 +767,6 @@ export class Transport { }); } - private onSendScorchedEarthIntent() { - this.sendIntent({ - type: "activate_scorched_earth", - clientID: this.lobbyConfig.clientID, - }); - } - private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { // Prefer new generic intent this.sendIntent({ @@ -824,24 +795,6 @@ export class Transport { }); } - private onSendPolicyDirectiveSelectIntent( - event: SendPolicyDirectiveSelectIntentEvent, - ) { - this.sendIntent({ - type: "policy_directive_select", - clientID: this.lobbyConfig.clientID, - directiveId: event.directiveId, - optionId: event.optionId, - }); - } - - private onSendMarkPolicyDirectivesSeenIntent() { - this.sendIntent({ - type: "mark_policy_directives_seen", - clientID: this.lobbyConfig.clientID, - }); - } - private onPauseGameEvent(event: PauseGameEvent) { if (!this.isLocal) { console.log(`cannot pause multiplayer games`); diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 890d1927f..8f5a96114 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -168,7 +168,6 @@ export function getMessageTypeClasses(type: MessageType): string { case MessageType.TRADE_SHIP_CAPTURED_ENEMY: case MessageType.RECEIVED_GOLD_FROM_TRADE: case MessageType.CONQUERED_PLAYER: - case MessageType.INSURANCE_REFUND: return severityColors["success"]; case MessageType.ATTACK_FAILED: case MessageType.ALLIANCE_REJECTED: diff --git a/src/client/graphics/layers/ResearchToggleButton.ts b/src/client/graphics/layers/ResearchToggleButton.ts index 5d751e34e..b1ae56d3b 100644 --- a/src/client/graphics/layers/ResearchToggleButton.ts +++ b/src/client/graphics/layers/ResearchToggleButton.ts @@ -19,9 +19,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @state() private _isModalOpen = false; - @state() - private _hasUnseenPolicyDirectives = false; - private modalRef: ResearchTreeModal | null = null; createRenderRoot() { @@ -42,12 +39,6 @@ export class ResearchToggleButton extends LitElement implements Layer { this._isVisible = shouldShow; this.requestUpdate(); } - // Check for unseen policy directives - const hasUnseen = player?.hasUnseenPolicyDirectives?.() ?? false; - if (hasUnseen !== this._hasUnseenPolicyDirectives) { - this._hasUnseenPolicyDirectives = hasUnseen; - this.requestUpdate(); - } this.updateModalState(); } @@ -188,9 +179,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @click=${this.toggleModal} style="position: relative;" > - ${this._hasUnseenPolicyDirectives - ? html`!` - : ""} ${["R", "E", "S", "E", "A", "R", "C", "H"].map( (letter) => html`${letter}`, )} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 687f07fa6..e3b2d27c6 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -47,7 +47,6 @@ export type Intent = | RoadInvestmentIntent | ResearchInvestmentIntent | BuildUnitIntent - | ScorchedEarthIntent | ResearchTreeSelectIntent | EmbargoIntent | QuickChatIntent @@ -61,9 +60,7 @@ export type Intent = | SetAutoBombingIntent | KickPlayerIntent | UpgradeStructureIntent - | UpgradeBomberIntent - | PolicyDirectiveSelectIntent - | MarkPolicyDirectivesSeenIntent; + | UpgradeBomberIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -91,7 +88,6 @@ export type ResearchInvestmentIntent = z.infer< typeof ResearchInvestmentIntentSchema >; export type BuildUnitIntent = z.infer; -export type ScorchedEarthIntent = z.infer; export type ResearchTreeSelectIntent = z.infer< typeof ResearchTreeSelectIntentSchema >; @@ -117,12 +113,6 @@ export type UpgradeStructureIntent = z.infer< typeof UpgradeStructureIntentSchema >; export type UpgradeBomberIntent = z.infer; -export type PolicyDirectiveSelectIntent = z.infer< - typeof PolicyDirectiveSelectIntentSchema ->; -export type MarkPolicyDirectivesSeenIntent = z.infer< - typeof MarkPolicyDirectivesSeenIntentSchema ->; export type Turn = z.infer; export enum PeaceTimerDuration { @@ -431,10 +421,6 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ bomberLevel: z.number().int().min(1).max(99).optional(), }); -export const ScorchedEarthIntentSchema = BaseIntentSchema.extend({ - type: z.literal("activate_scorched_earth"), -}); - export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ type: z.literal("upgrade_structure"), unitId: z.number(), @@ -451,16 +437,6 @@ export const ResearchTreeSelectIntentSchema = BaseIntentSchema.extend({ techId: z.string().max(128), }); -export const PolicyDirectiveSelectIntentSchema = BaseIntentSchema.extend({ - type: z.literal("policy_directive_select"), - directiveId: z.string().max(128), - optionId: z.string().max(128), -}); - -export const MarkPolicyDirectivesSeenIntentSchema = BaseIntentSchema.extend({ - type: z.literal("mark_policy_directives_seen"), -}); - export const CancelAttackIntentSchema = BaseIntentSchema.extend({ type: z.literal("cancel_attack"), attackID: z.string(), @@ -551,12 +527,9 @@ const IntentSchema = z.discriminatedUnion("type", [ RoadInvestmentIntentSchema, ResearchInvestmentIntentSchema, BuildUnitIntentSchema, - ScorchedEarthIntentSchema, UpgradeStructureIntentSchema, UpgradeBomberIntentSchema, ResearchTreeSelectIntentSchema, - PolicyDirectiveSelectIntentSchema, - MarkPolicyDirectivesSeenIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, MoveSubmarineIntentSchema, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 745082997..c0fe1c242 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -144,7 +144,6 @@ export interface Config { donateCooldown(): Tick; defaultDonationAmount(sender: Player): number; unitInfo(type: UnitType): UnitInfo; - scorchedEarthActivationCost(player: Player | PlayerView): Gold; tradeShipGold(dist: number): Gold; tradeShipSpawnRate(numberOfPorts: number): number; // Trade rework: gravity-based demand and port-supplied ships @@ -173,8 +172,6 @@ export interface Config { internationalCargoTruckGoldSplitRatio(): number; urbanPlanningPopulationBonusNum(): number; urbanPlanningPopulationBonusDen(): number; - structureInsuranceRefundNum(): number; - structureInsuranceRefundDen(): number; // Structure upgrade cost multiplier per structure type (e.g., 0.8 for 80%) structureUpgradeCostMultiplier(type: UnitType): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 30d627bc2..20815a3a0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -831,12 +831,6 @@ export class DefaultConfig implements Config { assertNever(type); } } - scorchedEarthActivationCost(player: Player | PlayerView): Gold { - if (player.type() === PlayerType.Human && this.infiniteGold()) { - return 0n; - } - return 3_000_000n; - } defaultDonationAmount(sender: Player): number { return Math.floor(sender.troops() / 3); } @@ -1343,12 +1337,6 @@ export class DefaultConfig implements Config { urbanPlanningPopulationBonusDen(): number { return 4; } - structureInsuranceRefundNum(): number { - return 1; - } - structureInsuranceRefundDen(): number { - return 3; - } // --- Structure upgrade cost multipliers --- structureUpgradeCostMultiplier(type: UnitType): number { switch (type) { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index cf9c4bc35..cb7d7f538 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -19,7 +19,6 @@ import { EmbargoExecution } from "./EmbargoExecution"; import { EmojiExecution } from "./EmojiExecution"; import { FakeHumanExecution } from "./FakeHumanExecution"; import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; -import { MarkPolicyDirectivesSeenExecution } from "./MarkPolicyDirectivesSeenExecution"; import { MoveFighterJetExecution } from "./MoveFighterJetExecution"; import { MoveSubmarineExecution } from "./MoveSubmarineExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; @@ -27,11 +26,9 @@ import { NoOpExecution } from "./NoOpExecution"; import { ParatrooperAttackExecution } from "./ParatrooperAttackExecution"; import { ParatrooperRetreatExecution } from "./ParatrooperRetreatExecution"; import { PeaceRequestExecution } from "./PeaceRequestExecution"; -import { PolicyDirectiveSelectExecution } from "./PolicyDirectiveSelectExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { ResearchTreeSelectExecution } from "./ResearchTreeSelectExecution"; import { RetreatExecution } from "./RetreatExecution"; -import { ScorchedEarthExecution } from "./ScorchedEarthExecution"; import { SetAutoBombingExecution } from "./SetAutoBombingExecution"; import { SetInvestmentRateExecution } from "./SetInvestmentRateExecution"; import { SetResearchInvestmentExecution } from "./SetResearchInvestmentExecution"; @@ -159,8 +156,6 @@ export class Executor { intent.targetLevel, intent.bomberLevel, ); - case "activate_scorched_earth": - return new ScorchedEarthExecution(player); case "upgrade_structure": { const unit = player.units().find((u) => u.id() === intent.unitId); if (!unit || unit.owner() !== player) return new NoOpExecution(); @@ -175,14 +170,6 @@ export class Executor { } case "research_tree_select": return new ResearchTreeSelectExecution(player, intent.techId); - case "policy_directive_select": - return new PolicyDirectiveSelectExecution( - player, - intent.directiveId, - intent.optionId, - ); - case "mark_policy_directives_seen": - return new MarkPolicyDirectivesSeenExecution(player); case "quick_chat": return new QuickChatExecution( diff --git a/src/core/execution/MarkPolicyDirectivesSeenExecution.ts b/src/core/execution/MarkPolicyDirectivesSeenExecution.ts deleted file mode 100644 index e42868c13..000000000 --- a/src/core/execution/MarkPolicyDirectivesSeenExecution.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Execution, Game, Player } from "../game/Game"; - -/** - * Execution to mark all policy directives as seen for the player. - */ -export class MarkPolicyDirectivesSeenExecution implements Execution { - private _active = true; - - constructor(private readonly player: Player) {} - - isActive(): boolean { - return this._active; - } - - activeDuringSpawnPhase(): boolean { - return true; - } - - init(_mg: Game, _ticks: number): void {} - - tick(_ticks: number): void { - this.player.markPolicyDirectivesSeen(); - this._active = false; - } -} diff --git a/src/core/execution/PolicyDirectiveSelectExecution.ts b/src/core/execution/PolicyDirectiveSelectExecution.ts deleted file mode 100644 index bf14c7cfc..000000000 --- a/src/core/execution/PolicyDirectiveSelectExecution.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Execution, Game, Player, UpgradeType } from "../game/Game"; -import { - getPolicyDirective, - isDirectiveUnlocked, - type PolicyDirectiveId, -} from "../tech/PolicyDirectives"; - -/** - * Execution to set the player's policy directive choice. - */ -export class PolicyDirectiveSelectExecution implements Execution { - private _active = true; - private mg: Game | null = null; - - constructor( - private readonly player: Player, - private readonly directiveId: string, - private readonly optionId: string, - ) {} - - isActive(): boolean { - return this._active; - } - - activeDuringSpawnPhase(): boolean { - return true; - } - - init(_mg: Game, _ticks: number): void { - this.mg = _mg; - } - - tick(_ticks: number): void { - // Validate the directive exists - const directive = getPolicyDirective(this.directiveId as PolicyDirectiveId); - if (!directive) { - console.warn( - `[PolicyDirectiveSelectExecution] Unknown directive: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Validate the directive is unlocked for this player - if ( - !isDirectiveUnlocked( - this.directiveId as PolicyDirectiveId, - (techId) => this.player.hasResearchedTech?.(techId) ?? false, - ) - ) { - console.warn( - `[PolicyDirectiveSelectExecution] Directive not unlocked: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Validate the option exists - const option = directive.options.find((o) => o.id === this.optionId); - if (!option) { - console.warn( - `[PolicyDirectiveSelectExecution] Unknown option: ${this.optionId} for directive ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Check if a choice has already been made (policy directives are one-time choices) - const existingChoice = this.player.getPolicyChoice(this.directiveId); - if (existingChoice !== null) { - console.warn( - `[PolicyDirectiveSelectExecution] Choice already made for directive: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Set the policy choice - this.player.setPolicyChoice(this.directiveId, this.optionId); - - // Apply upgrade effects from the chosen option - if (option.effects.grantsInternationalTrade) { - this.player.addUpgrade(UpgradeType.InternationalTrade); - } - - this._active = false; - } -} diff --git a/src/core/execution/ScorchedEarthExecution.ts b/src/core/execution/ScorchedEarthExecution.ts deleted file mode 100644 index 3c2215c33..000000000 --- a/src/core/execution/ScorchedEarthExecution.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Execution, Player, UpgradeType } from "../game/Game"; -import { GameImpl } from "../game/GameImpl"; -import { RESEARCH_TECH_IDS } from "../tech/TechEffects"; - -export class ScorchedEarthExecution implements Execution { - private mg: GameImpl; - private _isActive = true; - - constructor(private player: Player) {} - - public static fromIntent( - game: GameImpl, - intent: { - type: "activate_scorched_earth"; - clientID: string; - }, - ): ScorchedEarthExecution { - const player = game.playerByClientID(intent.clientID); - if (!player) { - throw new Error(`Player with clientID ${intent.clientID} not found`); - } - return new ScorchedEarthExecution(player); - } - - public isActive(): boolean { - return this._isActive; - } - - public activeDuringSpawnPhase(): boolean { - return false; - } - - init(mg: GameImpl, ticks: number): void { - this.mg = mg; - - // Already activated - if (this.player.hasUpgrade(UpgradeType.ScorchedEarth)) { - this._isActive = false; - return; - } - - // Must have researched Mechanized Warfare Doctrine to unlock Scorched Earth - if ( - !this.player.hasResearchedTech( - RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE, - ) - ) { - this._isActive = false; - return; - } - - // Check gold cost - const cost = this.mg.config().scorchedEarthActivationCost(this.player); - if (this.player.gold() < cost) { - this._isActive = false; - return; - } - - // Deduct gold and activate - this.player.removeGold(cost); - this.player.addUpgrade(UpgradeType.ScorchedEarth); - - // Destroy roads only (keep techs and upgrades) - this.mg.destroyPlayerRoads(this.player); - this.player.setRoadInvestmentRate(0); - this.mg.markPlayerNodesForReconnection(this.player); - - this._isActive = false; - } - - public tick(ticks: number): void { - // Logic is in init() - } -} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ec39a74a9..25611b6f4 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -181,22 +181,15 @@ export enum UnitType { export enum UpgradeType { Roads = "Roads", - // Land Upgrades - ScorchedEarth = "ScorchedEarth", - // Economy Upgrades InternationalTrade = "InternationalTrade", - // TEMPORARILY DISABLED: StructureInsurance = "StructureInsurance", HospitalResearch = "HospitalResearch", ResearchLabResearch = "ResearchLabResearch", // Water Upgrades SubmarineResearch = "SubmarineResearch", NuclearSubmarineResearch = "NuclearSubmarineResearch", - WaterUpgrade1 = "WaterUpgrade1", - WaterUpgrade2 = "WaterUpgrade2", WarshipAntiAir = "WarshipAntiAir", - WaterUpgrade3 = "WaterUpgrade3", // Warship level upgrades (Early Cold War Cruisers gives level 1) WarshipLevel1 = "WarshipLevel1", WarshipLevel2 = "WarshipLevel2", @@ -210,7 +203,6 @@ export enum UpgradeType { JetEngines = "JetEngines", AirUpgrade1 = "AirUpgrade1", CityAntiAir = "CityAntiAir", - AirUpgrade3 = "AirUpgrade3", FighterJetNavalTargeting = "FighterJetNavalTargeting", // Fighter level upgrades (Jet Engines gives level 1 by default) FighterLevel2 = "FighterLevel2", @@ -232,11 +224,6 @@ export enum UpgradeType { ThermonuclearStaging = "ThermonuclearStaging", MIRVTechnology = "MIRVTechnology", DoomsdayDeviceResearch = "DoomsdayDeviceResearch", - - // Dummy Economy Upgrades - EconomyUpgrade1 = "EconomyUpgrade1", - EconomyUpgrade2 = "EconomyUpgrade2", - EconomyUpgrade3 = "EconomyUpgrade3", } const _structureTypes: ReadonlySet = new Set([ @@ -695,13 +682,6 @@ export interface Player { addResearchedTech(techId: string): void; removeResearchedTechsByCategory(category: Category): void; - // Policy Directives (player choices linked to research) - getPolicyChoice(directiveId: string): string | null; - setPolicyChoice(directiveId: string, optionId: string): void; - getAllPolicyChoices(): ReadonlyMap; - hasUnseenPolicyDirectives(): boolean; - markPolicyDirectivesSeen(): void; - captureUnit(unit: Unit): void; // Relations & Diplomacy @@ -981,7 +961,6 @@ export enum MessageType { SENT_TROOPS_TO_PLAYER, RECEIVED_TROOPS_FROM_PLAYER, CHAT, - INSURANCE_REFUND, WARN, PEACE_TIMER_BLOCKED, DOOMSDAY_DEVICE_ACTIVATED, @@ -1031,7 +1010,6 @@ export const MESSAGE_TYPE_CATEGORIES: Record = { [MessageType.SENT_TROOPS_TO_PLAYER]: MessageCategory.TRADE, [MessageType.RECEIVED_TROOPS_FROM_PLAYER]: MessageCategory.TRADE, [MessageType.CHAT]: MessageCategory.CHAT, - [MessageType.INSURANCE_REFUND]: MessageCategory.FINANCIAL, [MessageType.DOOMSDAY_DEVICE_ACTIVATED]: MessageCategory.ATTACK, } as const; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 6788c23a7..eb5547b2f 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -348,14 +348,6 @@ export class PlayerView { return new Set(this.data.researchPriorities ?? []); } - // Policy Directive access - getPolicyChoice(directiveId: string): string | null { - return this.data.policyChoices?.[directiveId] ?? null; - } - hasUnseenPolicyDirectives(): boolean { - return this.data.hasUnseenPolicyDirectives ?? false; - } - // Aggregate research progress across levels in [0, L] (L = max level in tree) researchTechLevel(): number { const tick = this.game.ticks(); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index c0ed15e0b..46f1d50e8 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1,7 +1,6 @@ import { renderNumber, renderTroops } from "../../client/Utils"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID } from "../Schemas"; -import { getDirectivesUnlockedByTech } from "../tech/PolicyDirectives"; import { Category, findTech } from "../tech/ResearchTree"; import { applyTechCompletionEffects, @@ -103,10 +102,6 @@ export class PlayerImpl implements Player { private _researchBeakers: Map = new Map(); // Currently selected research priority tech ids (can have multiple) private _researchPriorities: Set = new Set(); - // Policy directive choices: directiveId -> optionId - private _policyChoices: Map = new Map(); - // Track unseen policy directives (based on newly unlocked techs) - private _unseenPolicyDirectives: Set = new Set(); private _flag: string | undefined; private _name: string; @@ -260,11 +255,6 @@ export class PlayerImpl implements Player { this._researchPriorities.size > 0 ? Array.from(this._researchPriorities) : undefined, - policyChoices: - this._policyChoices.size > 0 - ? Object.fromEntries(this._policyChoices) - : undefined, - hasUnseenPolicyDirectives: this._unseenPolicyDirectives.size > 0, }; } @@ -528,12 +518,6 @@ export class PlayerImpl implements Player { // Apply centralized side-effects upon research completion applyTechCompletionEffects(this, this.mg, techId); - - // Check if this tech unlocks any policy directives - const unlockedDirectives = getDirectivesUnlockedByTech(techId); - for (const directive of unlockedDirectives) { - this._markPolicyDirectiveUnseen(directive.id); - } } removeResearchedTechsByCategory(category: Category): void { const toRemove: string[] = []; @@ -616,32 +600,6 @@ export class PlayerImpl implements Player { return this._researchPriorities; } - // Policy Directive methods - getPolicyChoice(directiveId: string): string | null { - return this._policyChoices.get(directiveId) ?? null; - } - setPolicyChoice(directiveId: string, optionId: string): void { - this._policyChoices.set(directiveId, optionId); - // Mark as seen once a choice is made - this._unseenPolicyDirectives.delete(directiveId); - } - getAllPolicyChoices(): ReadonlyMap { - return this._policyChoices; - } - hasUnseenPolicyDirectives(): boolean { - return this._unseenPolicyDirectives.size > 0; - } - markPolicyDirectivesSeen(): void { - this._unseenPolicyDirectives.clear(); - } - // Internal: mark a directive as unseen (called when tech unlocks it) - _markPolicyDirectiveUnseen(directiveId: string): void { - // Only mark as unseen if no choice has been made yet - if (!this._policyChoices.has(directiveId)) { - this._unseenPolicyDirectives.add(directiveId); - } - } - invalidateEffectiveUnitsCache(type: UnitType): void { this._effectiveUnitsCache.delete(type); } diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index d2980cf2b..d217ec16d 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -1,4 +1,3 @@ -import { renderNumber } from "../../client/Utils"; import { simpleHash, toInt, withinInt } from "../Util"; import { AllUnitParams, @@ -103,13 +102,6 @@ export class UnitImpl implements Unit { "sourceAirfield" in params ? (params.sourceAirfield ?? undefined) : undefined; - // TEMPORARILY DISABLED: Structure insurance - // if ( - // isStructureType(this._type) && - // this._owner.hasUpgrade(UpgradeType.StructureInsurance) - // ) { - // this._insuredBy = this._owner; - // } switch (this._type) { case UnitType.Warship: @@ -454,23 +446,6 @@ export class UnitImpl implements Unit { } setOwner(newOwner: PlayerImpl): void { - if (this._insuredBy) { - const baseCost = this.info().cost(this._insuredBy); - if (baseCost > 0n) { - const num = BigInt(this.mg.config().structureInsuranceRefundNum()); - const den = BigInt(this.mg.config().structureInsuranceRefundDen()); - const refundAmount = (baseCost * num) / den; - this._insuredBy.addGold(refundAmount); - this.mg.displayMessage( - "messages.insurance_refund_conquest", - MessageType.INSURANCE_REFUND, - this._insuredBy.id(), - refundAmount, - { amount: renderNumber(refundAmount) }, - ); - } - } - this._insuredBy = null; switch (this._type) { case UnitType.Warship: case UnitType.FighterJet: @@ -540,23 +515,6 @@ export class UnitImpl implements Unit { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); } - if (this._insuredBy) { - const baseCost = this.info().cost(this._insuredBy); - if (baseCost > 0n) { - const num = BigInt(this.mg.config().structureInsuranceRefundNum()); - const den = BigInt(this.mg.config().structureInsuranceRefundDen()); - const refundAmount = (baseCost * num) / den; - this._insuredBy.addGold(refundAmount); - this.mg.displayMessage( - "messages.insurance_refund", - MessageType.INSURANCE_REFUND, - this._insuredBy.id(), - refundAmount, - { amount: renderNumber(refundAmount) }, - ); - } - } - this._insuredBy = null; this._owner._units = this._owner._units.filter((b) => b !== this); this._active = false; this.mg.addUpdate(this.toUpdate()); diff --git a/src/core/tech/PolicyDirectives.ts b/src/core/tech/PolicyDirectives.ts deleted file mode 100644 index 3e0546db5..000000000 --- a/src/core/tech/PolicyDirectives.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Policy Directives are optional player choices that unlock when certain techs are researched. - * Each directive offers a choice between two or more policy options, each with distinct effects. - */ - -import { RESEARCH_TECH_IDS } from "./TechIds"; - -// Policy directive identifiers -export const POLICY_DIRECTIVE_IDS = { - // Keep only Trade Policy framework - others were removed/simplified - TRADE_POLICY_FRAMEWORK: "policy_trade_policy", -} as const; - -export type PolicyDirectiveId = - (typeof POLICY_DIRECTIVE_IDS)[keyof typeof POLICY_DIRECTIVE_IDS]; - -// Option identifiers within a directive -export type PolicyOptionId = string; - -export interface PolicyOption { - id: PolicyOptionId; - name: string; - description: string; - effects: PolicyEffects; -} - -export interface PolicyEffects { - // Multiplier for construction speed (e.g., 1.03 = +3% faster) - constructionSpeedMul?: number; - // Multiplier for trade income from roads and trade ships (e.g., 1.05 = +5%) - tradeIncomeMul?: number; - // Multiplier for trade ship income specifically (stacks with tradeIncomeMul) - tradeShipIncomeMul?: number; - // Multiplier for domestic income (non-trade income from population/industry) - domesticIncomeMul?: number; - // If true, grants the InternationalTrade upgrade (enables international road/sea trade) - grantsInternationalTrade?: boolean; - // Multiplier for road effects (e.g., 1.2 = +20% stronger road bonuses) - roadEffectMul?: number; - // Multiplier for infrastructure spending effectiveness (e.g., 1.2 = +20% more roads per gold) - infrastructureSpendingEffectivenessMul?: number; - // Multiplier for research spending effectiveness (e.g., 1.3 = +30% research effectiveness) - researchEffectivenessMul?: number; - // Multiplier for attack speed (e.g., 1.1 = +10% faster offensive speed) - attackSpeedMul?: number; - // Multiplier for attacker losses when attacking (e.g., 0.9 = -10% losses) - attackerLossMul?: number; - // Multiplier for defender losses when defending (e.g., 0.9 = -10% losses) - defenderLossMul?: number; - // Multiplier for enemy (defender) losses when you attack (e.g., 1.1 = +10% enemy losses) - enemyLossMulOnAttack?: number; - // Multiplier for attacker (enemy) losses when you defend (e.g., 1.1 = +10% enemy losses when they attack you) - attackerLossMulOnDefense?: number; - // Multiplier for maintenance cost reduction (e.g., 0.90 = -10% maintenance) - // TODO: Commented out until maintenance is implemented - // maintenanceCostMul?: number; -} - -export interface PolicyDirective { - id: PolicyDirectiveId; - name: string; - description: string; - // Tech that must be researched to unlock this directive - unlockedByTech: string; - // Available options to choose from - options: PolicyOption[]; -} - -// Central registry of all policy directives -export const POLICY_DIRECTIVES: Readonly< - Record -> = Object.freeze({ - [POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK]: { - id: POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - name: "Trade Policy Framework", - description: - "Choose your nation's approach to international commerce and trade relations.", - unlockedByTech: RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK, - options: [ - { - id: "open_trade", - name: "Open Trade Policy", - description: "+5% trade income, +5% income from owned trade ships", - effects: { - grantsInternationalTrade: true, - tradeIncomeMul: 1.05, - tradeShipIncomeMul: 1.05, - }, - }, - { - id: "autarky", - name: "Autarky Doctrine", - description: "Disables international trade, +20% domestic income", - effects: { - domesticIncomeMul: 1.2, - }, - }, - ], - }, -}); - -/** - * Get all policy directives. - */ -export function getAllPolicyDirectives(): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES); -} - -/** - * Get a policy directive by ID. - */ -export function getPolicyDirective( - id: PolicyDirectiveId, -): PolicyDirective | undefined { - return POLICY_DIRECTIVES[id]; -} - -/** - * Get policy directives unlocked by a specific tech. - */ -export function getDirectivesUnlockedByTech(techId: string): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES).filter( - (d) => d.unlockedByTech === techId, - ); -} - -/** - * Get a specific option from a directive. - */ -export function getPolicyOption( - directiveId: PolicyDirectiveId, - optionId: PolicyOptionId, -): PolicyOption | undefined { - const directive = POLICY_DIRECTIVES[directiveId]; - return directive?.options.find((o) => o.id === optionId); -} - -/** - * Check if a player has unlocked a policy directive based on researched techs. - */ -export function isDirectiveUnlocked( - directiveId: PolicyDirectiveId, - hasResearchedTech: (techId: string) => boolean, -): boolean { - const directive = POLICY_DIRECTIVES[directiveId]; - if (!directive) return false; - return hasResearchedTech(directive.unlockedByTech); -} - -/** - * Get all directives that are unlocked based on researched techs. - */ -export function getUnlockedDirectives( - hasResearchedTech: (techId: string) => boolean, -): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES).filter((d) => - hasResearchedTech(d.unlockedByTech), - ); -} diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts index 698bf51b5..966316f7b 100644 --- a/src/core/tech/TechEffects.ts +++ b/src/core/tech/TechEffects.ts @@ -1,10 +1,5 @@ import { CityAAExecution } from "../execution/CityAAExecution"; import { Game, Player, UpgradeType } from "../game/Game"; -import { - getAllPolicyDirectives, - getPolicyOption, - type PolicyDirectiveId, -} from "./PolicyDirectives"; import { RESEARCH_TECH_IDS } from "./TechIds"; // Re-export for backward compatibility with existing imports export { RESEARCH_TECH_IDS } from "./TechIds"; @@ -182,6 +177,9 @@ export const TECHS: Readonly> = Object.freeze({ player.addUpgrade?.(UpgradeType.Roads); game.markPlayerNodesForReconnection?.(player); } + if (!player.hasUpgrade?.(UpgradeType.InternationalTrade)) { + player.addUpgrade?.(UpgradeType.InternationalTrade); + } }, }, }, @@ -446,7 +444,6 @@ export function applyTechCompletionEffects( */ export function defenseCasualtyModifiers(defender: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): DefenseCasualtyModifiers { const mods: DefenseCasualtyModifiers = { attackerLossMul: 1.0, @@ -457,22 +454,6 @@ export function defenseCasualtyModifiers(defender: { def.effects?.defense?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = defender.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.defenderLossMul) { - mods.defenderLossMul *= option.effects.defenderLossMul; - } - if (option?.effects.attackerLossMulOnDefense) { - mods.attackerLossMul *= option.effects.attackerLossMulOnDefense; - } - } - } return mods; } @@ -484,7 +465,6 @@ export function defenseCasualtyModifiers(defender: { */ export function attackCasualtyModifiers(attacker: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): DefenseCasualtyModifiers { const mods: DefenseCasualtyModifiers = { attackerLossMul: 1.0, @@ -495,22 +475,6 @@ export function attackCasualtyModifiers(attacker: { def.effects?.attack?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = attacker.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.attackerLossMul) { - mods.attackerLossMul *= option.effects.attackerLossMul; - } - if (option?.effects.enemyLossMulOnAttack) { - mods.defenderLossMul *= option.effects.enemyLossMulOnAttack; - } - } - } return mods; } @@ -520,7 +484,6 @@ export function attackCasualtyModifiers(attacker: { */ export function attackSpeedModifiers(attacker: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): AttackSpeedModifiers { const mods: AttackSpeedModifiers = { speedMul: 1.0, @@ -530,19 +493,6 @@ export function attackSpeedModifiers(attacker: { def.effects?.attackSpeed?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = attacker.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.attackSpeedMul) { - mods.speedMul *= option.effects.attackSpeedMul; - } - } - } return mods; } @@ -552,7 +502,6 @@ export function attackSpeedModifiers(attacker: { */ export function constructionSpeedModifiers(player: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): ConstructionSpeedModifiers { const mods: ConstructionSpeedModifiers = { speedMul: 1.0, @@ -563,19 +512,6 @@ export function constructionSpeedModifiers(player: { def.effects?.constructionSpeed?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = player.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.constructionSpeedMul) { - mods.speedMul *= option.effects.constructionSpeedMul; - } - } - } return mods; } @@ -604,7 +540,6 @@ export function researchEffectivenessModifiers( */ export function incomeModifiers(player: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): IncomeModifiers { const mods: IncomeModifiers = { domesticIncomeMul: 1.0, @@ -615,19 +550,6 @@ export function incomeModifiers(player: { def.effects?.income?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = player.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.domesticIncomeMul) { - mods.domesticIncomeMul *= option.effects.domesticIncomeMul; - } - } - } return mods; } @@ -637,7 +559,6 @@ export function incomeModifiers(player: { */ export function infrastructureEffectivenessModifiers(player: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): InfrastructureEffectivenessModifiers { const mods: InfrastructureEffectivenessModifiers = { effectivenessMul: 1.0, @@ -647,20 +568,6 @@ export function infrastructureEffectivenessModifiers(player: { def.effects?.infrastructureEffectiveness?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = player.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.infrastructureSpendingEffectivenessMul) { - mods.effectivenessMul *= - option.effects.infrastructureSpendingEffectivenessMul; - } - } - } return mods; } @@ -671,7 +578,6 @@ export function infrastructureEffectivenessModifiers(player: { */ export function tradeIncomeModifiers(player: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): TradeIncomeModifiers { const mods: TradeIncomeModifiers = { incomeMul: 1.0, @@ -682,22 +588,6 @@ export function tradeIncomeModifiers(player: { def.effects?.tradeIncome?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = player.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.tradeIncomeMul) { - mods.incomeMul *= option.effects.tradeIncomeMul; - } - if (option?.effects.tradeShipIncomeMul) { - mods.tradeShipIncomeMul *= option.effects.tradeShipIncomeMul; - } - } - } return mods; } @@ -707,7 +597,6 @@ export function tradeIncomeModifiers(player: { */ export function roadEffectModifiers(player: { hasResearchedTech?(techId: string): boolean; - getPolicyChoice?(directiveId: string): string | null; }): RoadEffectModifiers { const mods: RoadEffectModifiers = { effectMul: 1.0, @@ -718,18 +607,5 @@ export function roadEffectModifiers(player: { def.effects?.roadEffect?.(mods); } } - // Apply policy directive effects - for (const directive of getAllPolicyDirectives()) { - const chosenOptionId = player.getPolicyChoice?.(directive.id); - if (chosenOptionId) { - const option = getPolicyOption( - directive.id as PolicyDirectiveId, - chosenOptionId, - ); - if (option?.effects.roadEffectMul) { - mods.effectMul *= option.effects.roadEffectMul; - } - } - } return mods; } diff --git a/src/core/tech/TechIds.ts b/src/core/tech/TechIds.ts index 93815bea8..f9f1be8aa 100644 --- a/src/core/tech/TechIds.ts +++ b/src/core/tech/TechIds.ts @@ -19,11 +19,8 @@ export const RESEARCH_TECH_IDS = { LAND_MILITARY_ACADEMY: "Land-2", LAND_SAM_SYSTEMS: "Land-3", LAND_DOOMSDAY_DEVICE: "Land-4", - // Economy techs (legacy; category removed, map roads to Land-1 for back-compat) + // Economy techs (legacy; category removed, kept for backwards compatibility) ECONOMY_ROADS_HOSPITALS: "Land-1", - ECONOMY_INTERNATIONAL_TRADE: "Economy-2", - ECONOMY_INSURANCE: "Economy-3", - ECONOMY_TBD_LEVEL4: "Economy-4", // Nuclear techs NUCLEAR_FISSION: "Nuclear-1", THERMONUCLEAR_STAGING: "Nuclear-2", @@ -45,10 +42,6 @@ export const RESEARCH_TECH_IDS = { INTEGRATED_SAM_BATTLEFIELD_COMMAND: "Land-4", NIGHT_VISION_THERMAL_C3I: "Land-5", NATIONAL_RECONSTRUCTION_PROGRAM: "Economy-1", - NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "Economy-2", - TRADE_POLICY_FRAMEWORK: "Economy-3", - NATIONAL_INFRASTRUCTURE_MODERNIZATION: "Economy-4", - DIGITAL_ADMINISTRATION_SYSTEMS: "Economy-5", DOOMSDAY_DEVICE: "Nuclear-4", } as const; diff --git a/tests/core/execution/ScorchedEarthExecution.test.ts b/tests/core/execution/ScorchedEarthExecution.test.ts deleted file mode 100644 index e0c6f1944..000000000 --- a/tests/core/execution/ScorchedEarthExecution.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ScorchedEarthExecution } from "../../../src/core/execution/ScorchedEarthExecution"; -import { Gold, Player, UpgradeType } from "../../../src/core/game/Game"; -import { GameImpl } from "../../../src/core/game/GameImpl"; - -describe("ScorchedEarthExecution", () => { - let mockPlayer: jest.Mocked; - let mockGame: jest.Mocked; - - beforeEach(() => { - mockPlayer = { - gold: jest.fn(), - hasUpgrade: jest.fn(), - addUpgrade: jest.fn(), - removeUpgrade: jest.fn(), - removeGold: jest.fn(), - hasResearchedTech: jest.fn(), - removeResearchedTechsByCategory: jest.fn(), - setRoadInvestmentRate: jest.fn(), - } as unknown as jest.Mocked; - (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(true); - - mockGame = { - config: jest.fn().mockReturnValue({ - scorchedEarthActivationCost: jest.fn().mockReturnValue(3_000_000n), - }), - destroyPlayerRoads: jest.fn(), - markPlayerNodesForReconnection: jest.fn(), - } as unknown as jest.Mocked; - }); - - it("should do nothing if player already has ScorchedEarth", () => { - mockPlayer.hasUpgrade.mockReturnValue(true); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("requires the Scorched Earth tech to be researched before activation", () => { - (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(false); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("should fail if player does not have enough gold", () => { - mockPlayer.gold.mockReturnValue(2_999_999n as Gold); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("should activate Scorched Earth with sufficient gold and tech", () => { - mockPlayer.gold.mockReturnValue(3_000_000n as Gold); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).toHaveBeenCalledWith(3_000_000n); - expect(mockPlayer.addUpgrade).toHaveBeenCalledWith( - UpgradeType.ScorchedEarth, - ); - expect(mockGame.destroyPlayerRoads).toHaveBeenCalledWith(mockPlayer); - expect(mockPlayer.setRoadInvestmentRate).toHaveBeenCalledWith(0); - expect(mockGame.markPlayerNodesForReconnection).toHaveBeenCalledWith( - mockPlayer, - ); - }); -}); From 44ddd80af566b0b915e1ebaef09c116290731c62 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 01:19:16 +0100 Subject: [PATCH 14/16] chore: remove economy category from HelpModal tech tree --- src/client/HelpModal.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index 33c77b7da..38733cc31 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -1153,7 +1153,6 @@ export class HelpModal extends LitElement { Sea: [], Air: [], Nuclear: [], - Economy: [], }; for (const node of nodes) { @@ -1188,7 +1187,6 @@ export class HelpModal extends LitElement { Sea: this.t("tech_tree.categories.sea"), Air: this.t("tech_tree.categories.air"), Nuclear: this.t("tech_tree.categories.nuclear"), - Economy: this.t("tech_tree.categories.economy"), }; const categoryDescriptions: Record = { @@ -1196,7 +1194,6 @@ export class HelpModal extends LitElement { Sea: this.t("tech_tree.categories.sea_desc"), Air: this.t("tech_tree.categories.air_desc"), Nuclear: this.t("tech_tree.categories.nuclear_desc"), - Economy: this.t("tech_tree.categories.economy_desc"), }; return html` From 4d7d16a018944083032caa272d9f2ba1c0b8a788 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 03:44:55 +0100 Subject: [PATCH 15/16] fix: paratroopers not showing in radial menu after researching Air-1 The paratrooper option was not appearing in the radial menu even when Air-1 tech was researched and airfield was in range. Root cause: Both RadialMenu.shouldShowAirAttack() and ParatrooperAttackExecution.init() were checking for obsolete UpgradeType.AirUpgrade1 instead of UpgradeType.JetEngines. The Air-1 Early Air Power tech grants UpgradeType.JetEngines, not AirUpgrade1, so the check was always failing. Changed both files to check for JetEngines upgrade, which is the correct requirement for paratroopers. All tests pass (46 suites, 276 tests). --- src/client/graphics/layers/RadialMenu.ts | 2 +- src/core/execution/ParatrooperAttackExecution.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 5c21d0952..1a8661fc2 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -482,7 +482,7 @@ export class RadialMenu implements Layer { } private shouldShowAirAttack(player: PlayerView, tile: TileRef): boolean { - if (!player.hasUpgrade(UpgradeType.AirUpgrade1)) { + if (!player.hasUpgrade(UpgradeType.JetEngines)) { return false; } if (player.units(UnitType.Airfield).length === 0) { diff --git a/src/core/execution/ParatrooperAttackExecution.ts b/src/core/execution/ParatrooperAttackExecution.ts index a8ccc0a80..4eb2ca607 100644 --- a/src/core/execution/ParatrooperAttackExecution.ts +++ b/src/core/execution/ParatrooperAttackExecution.ts @@ -45,7 +45,7 @@ export class ParatrooperAttackExecution implements Execution { init(game: Game, ticks: number): void { this.mg = game; - if (!this.attacker.hasUpgrade(UpgradeType.AirUpgrade1)) { + if (!this.attacker.hasUpgrade(UpgradeType.JetEngines)) { return; } From 9074d0169393e5c1f6b231a5d5b867f6288e1043 Mon Sep 17 00:00:00 2001 From: El-Magico777 Date: Sun, 14 Dec 2025 03:35:45 +0100 Subject: [PATCH 16/16] feat: replace Bombers tab with radial menu control and enable auto-bombing by default BREAKING CHANGE: Auto-bombing is now enabled by default for all players Changes: - PlayerImpl: Changed _autoBombingEnabled default from false to true * All players now have auto-bombing active by default * Can still be overridden via radial menu manual targeting - RadialMenu: Added bomber targeting action (Slot.Bomber) * Shows airfield icon when right-clicking enemy land tiles * Gating: requires airfield, land tile, enemy ownership, war status * Range validation: checks bomber level and config range from all airfields * Targets all structure types (City, DefensePost, SAMLauncher, etc.) * Uses preferClosest=true for optimal targeting priority * Icon: airfield icon (matches build menu consistency) - BomberExecution: Fixed manual to auto fallback logic * When manual targets exhausted, now clears bomberIntent via setBomberIntent(null) * Ensures seamless switch back to auto-bombing mode * Previously would get stuck when findTargetFromQueue returned null - ControlPanel2: Completely removed Bombers tab * Deleted tab UI rendering (button and content panel ~200 lines) * Removed all bomber-specific state variables * Removed bomber methods: _startAutoBombing, _stopAutoBombing, _handleBomberTargetChange, populateBomberForm, _refreshBomberPlayerLists, _getPlayersInAirfieldRange * Removed bomber-related updated() hook logic * Removed Bombers from activeTab type union and _changeTab signature * Cleaned up residual references (comments, dead code) * Kept sendBomberIntent() for radial integration - UI/UX Improvements: * Unified bomber control via radial menu (right-click context) * Removed toggle complexity from control panel * Airfield icon provides visual consistency with build menu * Auto-bombing ensures bombers always active when at war All tests pass (46 suites, 276 tests). TypeScript and ESLint clean. --- src/client/graphics/layers/ControlPanel2.ts | 527 +------------------- src/client/graphics/layers/RadialMenu.ts | 72 +++ src/core/execution/BomberExecution.ts | 8 +- src/core/game/PlayerImpl.ts | 2 +- 4 files changed, 84 insertions(+), 525 deletions(-) diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index 97d3de8db..af737c0ae 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -39,7 +39,6 @@ import { SendDeclareWarIntentEvent, SendEmbargoIntentEvent, SendPeaceRequestIntentEvent, - SendSetAutoBombingEvent, SendSetInvestmentRateEvent, SendSetResearchInvestmentEvent, SendSetRoadInvestmentEvent, @@ -105,29 +104,12 @@ export class ControlPanel2 extends LitElement implements Layer { private init_: boolean = false; @state() - private activeTab: - | "Build" - | "Attack" - | "Economy" - | "Bombers" - | "Trade" - | "Diplomacy" = "Build"; - - @state() - private _lastAirfieldCount: number = 0; - - @state() - private _lastPlayersHash: string = ""; - - @state() - private _reachablePlayersHash: string = ""; + private activeTab: "Build" | "Attack" | "Economy" | "Trade" | "Diplomacy" = + "Build"; @state() private _hasAirfields: boolean = false; - @state() - private _highlightBombersTab: boolean = false; - @state() private _currentTargetPlayerId: PlayerID | null = null; @@ -137,15 +119,6 @@ export class ControlPanel2 extends LitElement implements Layer { @state() private _currentTargetPlayerName: string | null = null; - @state() - private _bomberPreferClosest: boolean = true; - - @state() - private _isAutoBombingEnabled: boolean = false; - - @state() - private _lastSelectedBomberTarget: PlayerID | null = null; - @state() private _multibuildEnabled: boolean = false; @@ -369,21 +342,7 @@ export class ControlPanel2 extends LitElement implements Layer { } } - private _updatePlayerHashAndRefresh() { - const currentPlayersHash = this.game - .players() - .map((p) => p.id()) - .sort() - .join(","); - - if (this._lastPlayersHash !== currentPlayersHash) { - this._lastPlayersHash = currentPlayersHash; - // Only refresh the list if the relevant tab is active - if (this.activeTab === "Bombers") { - this._refreshBomberPlayerLists(); - } - } - } + private _updatePlayerHashAndRefresh() {} tick() { if (this.init_) { @@ -535,27 +494,6 @@ export class ControlPanel2 extends LitElement implements Layer { // Track relevant state for dynamic updates const currentAirfieldCount = player.units(UnitType.Airfield).length; - this._hasAirfields = currentAirfieldCount > 0; - - if (this.activeTab === "Bombers" && this.game.ticks() % 10 === 0) { - const currentReachablePlayersHash = this._getPlayersInAirfieldRange() - .map((p) => p.id()) - .sort() - .join(","); - - if ( - this._lastAirfieldCount !== currentAirfieldCount || - this._reachablePlayersHash !== currentReachablePlayersHash - ) { - this._refreshBomberPlayerLists(); - this._lastAirfieldCount = currentAirfieldCount; - this._reachablePlayersHash = currentReachablePlayersHash; - } - } - - if (this.activeTab === "Bombers" && !this._hasAirfields) { - this.activeTab = "Build"; // Changed from "Controls" - } this.requestUpdate(); @@ -773,62 +711,6 @@ export class ControlPanel2 extends LitElement implements Layer { return player?.hasUpgrade?.(UpgradeType.Roads) ?? false; } - private _getPlayersInAirfieldRange(): PlayerView[] { - const myPlayer = this.game.myPlayer(); - if (!myPlayer?.isAlive()) { - return []; - } - - const myAirfields = myPlayer - .units(UnitType.Airfield) - .filter((u) => u.isActive()); - if (myAirfields.length === 0) { - return []; - } - - const reachablePlayers = new Map(); - - const structureIndex = this.game.getStructureIndex(); - - for (const airfield of myAirfields) { - const bomberRange = this.game - .config() - .bomberTargetRange(airfield.bomberLevel()); - const airfieldPos = { - x: this.game.x(airfield.tile()), - y: this.game.y(airfield.tile()), - }; - const nearbyStructures = structureIndex.getInRange( - airfieldPos.x, - airfieldPos.y, - bomberRange, - ); - - for (const structure of nearbyStructures) { - const owner = structure.owner(); - if ( - owner && - owner.isPlayer() && - owner.id() !== myPlayer.id() && // Prevent self-targeting - !myPlayer.isFriendly(owner) && - owner.type() !== PlayerType.Bot - ) { - if (!reachablePlayers.has(owner.id())) { - reachablePlayers.set(owner.id(), owner); - } - } - } - } - - return Array.from(reachablePlayers.values()).sort((a, b) => - a.name().localeCompare(b.name()), - ); - } - - private _refreshBomberPlayerLists() { - this.populateBomberForm(); // Populates the main player select list - } - updated(changedProperties: Map) { if (changedProperties.has("isOpen")) { if (this.isOpen) { @@ -838,26 +720,6 @@ export class ControlPanel2 extends LitElement implements Layer { } } - if (this.activeTab === "Bombers") { - if ( - changedProperties.has("activeTab") || - changedProperties.has("_hasAirfields") - ) { - this._refreshBomberPlayerLists(); - } - } - - if (changedProperties.has("_hasAirfields")) { - const oldHasAirfields = changedProperties.get("_hasAirfields"); - if (this._hasAirfields && !oldHasAirfields) { - // Airfields just became available, highlight the tab - this._highlightBombersTab = true; - setTimeout(() => { - this._highlightBombersTab = false; - }, 3000); // Highlight for 3 seconds - } - } - // Apply translations to tooltips after rendering this.querySelectorAll("[data-i18n-title]").forEach((el) => { const key = el.getAttribute("data-i18n-title"); @@ -867,55 +729,6 @@ export class ControlPanel2 extends LitElement implements Layer { }); } - populateBomberForm() { - const playerSelect = this.querySelector( - "#bomber-player-select", - ) as HTMLSelectElement | null; - if (!this.game || !playerSelect) return; - - const me = this.game.myPlayer(); - if (!me) return; - - const playersToDisplay: PlayerView[] = this._getPlayersInAirfieldRange(); - - if (playersToDisplay.length === 0) { - playerSelect.innerHTML = ``; - playerSelect.disabled = true; - this._lastSelectedBomberTarget = null; // Clear selection if no targets are available - } else { - const optsPlayers = playersToDisplay - .map((p) => ``) - .join(""); - playerSelect.innerHTML = optsPlayers; - playerSelect.disabled = false; - - const stillAValidTarget = playersToDisplay.some( - (p) => p.id() === this._lastSelectedBomberTarget, - ); - - if (stillAValidTarget) { - playerSelect.value = this._lastSelectedBomberTarget as string; - } else { - // If the last target is no longer valid, default to the first in the list and update the state - this._lastSelectedBomberTarget = playerSelect.value; - } - } - } - - handleBomberIntent() { - const playerSelect = this.querySelector( - "#bomber-player-select", - ) as HTMLSelectElement; - - if (!playerSelect || this._uiSelectedStructures.length === 0) return; - - const targetID = String(playerSelect.value); - // Use the state variable instead of querying the DOM - const structures = [...this._uiSelectedStructures]; - - this.sendBomberIntent(targetID, structures, this._bomberPreferClosest); - } - sendBomberIntent( targetID: string | null, structures: UnitType[] | null, @@ -924,7 +737,6 @@ export class ControlPanel2 extends LitElement implements Layer { if (!this.eventBus) return; this._currentTargetPlayerId = targetID; this._currentTargetStructureTypes = structures ?? []; - this._bomberPreferClosest = preferClosest; if (targetID) { const targetPlayer = this.game.players().find((p) => p.id() === targetID); this._currentTargetPlayerName = targetPlayer ? targetPlayer.name() : null; @@ -936,45 +748,6 @@ export class ControlPanel2 extends LitElement implements Layer { ); } - _startAutoBombing() { - this._isAutoBombingEnabled = true; - this.eventBus.emit(new SendSetAutoBombingEvent(true)); - // Clear any manual target when auto-bombing is enabled - this.sendBomberIntent(null, null, true); - } - - async _stopAutoBombing() { - this._isAutoBombingEnabled = false; - this.eventBus.emit(new SendSetAutoBombingEvent(false)); - // Clear any manual target when auto-bombing is disabled - this.sendBomberIntent(null, null, true); - - await this.updateComplete; // Wait for the UI to update - - this._refreshBomberPlayerLists(); // NOW refresh the list - } - - handleStructureChange(e: Event) { - const checkbox = e.target as HTMLInputElement; - const value = checkbox.value as UnitType; - - if (checkbox.checked) { - if (!this._uiSelectedStructures.includes(value)) { - this._uiSelectedStructures = [...this._uiSelectedStructures, value]; - } - } else { - this._uiSelectedStructures = this._uiSelectedStructures.filter( - (s) => s !== value, - ); - } - this.requestUpdate(); - } - - private _handleBomberTargetChange(e: Event) { - const select = e.target as HTMLSelectElement; - this._lastSelectedBomberTarget = select.value; - } - private _handleMultibuildToggle() { this._multibuildEnabled = !this._multibuildEnabled; this.uiState.multibuildEnabled = this._multibuildEnabled; @@ -1090,7 +863,7 @@ export class ControlPanel2 extends LitElement implements Layer { } private _changeTab( - tab: "Build" | "Attack" | "Economy" | "Bombers" | "Trade" | "Diplomacy", + tab: "Build" | "Attack" | "Economy" | "Trade" | "Diplomacy", ) { this.activeTab = tab; if (this.uiState.pendingBuildUnitType) { @@ -1365,20 +1138,6 @@ export class ControlPanel2 extends LitElement implements Layer { > Diplomacy - ${this._hasAirfields - ? html` - - ` - : ""}
- -
- ${this._currentTargetPlayerId && - this._currentTargetStructureTypes.length > 0 - ? html` -
- ${this - ._currentTargetPlayerName} - ${this._bomberPreferClosest - ? "Closest" - : "Furthest"} -
- ${this._currentTargetStructureTypes.map( - (structType) => html` - - `, - )} -
-
- ` - : html`No target set`} -
-
- ` - } -
- - -
- -
-
- Upgrade Bombers -
-
- -
- - -
-
- Auto-Bomb -
-
- ${ - this._isAutoBombingEnabled - ? html` - - ` - : html` - - ` - } -
-
-
-
- ` - : ""} ${this.activeTab === "Build" ? html`
` : ""} diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 1a8661fc2..f4208413a 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -2,6 +2,7 @@ import * as d3 from "d3"; import doveIcon from "../../../../proprietary/images/dove.png"; import warIcon from "../../../../proprietary/images/waricon.png"; import airAttackIcon from "../../../../resources/images/AirAttackIconWhite.svg"; +import airfieldIcon from "../../../../resources/images/AirfieldIcon.svg"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; @@ -28,6 +29,7 @@ import { SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, + SendBomberIntentEvent, SendBreakAllianceIntentEvent, SendDeclareWarIntentEvent, SendParatrooperAttackIntentEvent, @@ -47,6 +49,7 @@ enum Slot { Ally, Peace, AirAttack, + Bomber, } export class RadialMenu implements Layer { @@ -87,6 +90,16 @@ export class RadialMenu implements Layer { icon: null, }, ], + [ + Slot.Bomber, + { + name: "bomber", + disabled: true, + action: () => {}, + color: null, + icon: null, + }, + ], [ Slot.Info, { @@ -476,11 +489,70 @@ export class RadialMenu implements Layer { }); } + if (this.shouldShowBomber(myPlayer, tile)) { + this.activateMenuElement(Slot.Bomber, "#FF6B35", airfieldIcon, () => { + if (this.clickedCell === null) return; + const targetPlayer = this.g.owner(tile) as PlayerView; + // Target all structure types with closest-first priority + const allStructures = [ + UnitType.City, + UnitType.DefensePost, + UnitType.SAMLauncher, + UnitType.MissileSilo, + UnitType.Port, + UnitType.Airfield, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.DoomsdayDevice, + ]; + this.eventBus.emit( + new SendBomberIntentEvent(targetPlayer.id(), allStructures, true), + ); + }); + } + if (!this.g.hasOwner(tile)) { return; } } + private shouldShowBomber(player: PlayerView, tile: TileRef): boolean { + // Check if player has at least one active airfield + if (player.units(UnitType.Airfield).length === 0) { + return false; + } + // Check if tile is land + if (!this.g.isLand(tile)) { + return false; + } + // Check if tile is owned by an enemy + const owner = this.g.owner(tile); + if (owner === player || !owner.isPlayer()) { + return false; + } + // Check if player is at war with owner + if (!player.isAtWarWith?.(owner as PlayerView)) { + return false; + } + // Check if any airfield can reach this tile + const airfields = player.units(UnitType.Airfield); + for (const airfield of airfields) { + if (!airfield.isActive()) continue; + const range = this.g + .config() + .bomberTargetRange(airfield.bomberLevel?.() ?? 1); + const dist = Math.sqrt( + this.g.euclideanDistSquared(airfield.tile(), tile), + ); + if (dist <= range) { + return true; + } + } + return false; + } + private shouldShowAirAttack(player: PlayerView, tile: TileRef): boolean { if (!player.hasUpgrade(UpgradeType.JetEngines)) { return false; diff --git a/src/core/execution/BomberExecution.ts b/src/core/execution/BomberExecution.ts index f2f198cc7..377a17904 100644 --- a/src/core/execution/BomberExecution.ts +++ b/src/core/execution/BomberExecution.ts @@ -372,11 +372,17 @@ export class BomberExecution implements Execution { ) { const targetPlayer = this.mg.player(intent.targetPlayerID); if (targetPlayer && this.origOwner.isAtWarWith(targetPlayer)) { - return this.findTargetFromQueue( + const target = this.findTargetFromQueue( targetPlayer, intent.structures, intent.preferClosest, ); + // If we found a target in manual mode, use it + if (target) { + return target; + } + // If no targets remain, clear the manual intent and fall through to auto-bombing + this.origOwner.setBomberIntent(null); } } // Auto-bombing mode if (!this.origOwner.isAutoBombingEnabled()) { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 46f1d50e8..3f8e12e43 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -135,7 +135,7 @@ export class PlayerImpl implements Player { structures: UnitType[]; preferClosest: boolean; } | null = null; - private _autoBombingEnabled: boolean = false; + private _autoBombingEnabled: boolean = true; public bombersOnTarget = new Map(); // Cached capital (geographic center) of player's territory