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/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` 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/ResearchTreeModal.ts b/src/client/ResearchTreeModal.ts index 06262e2c3..898aabd2b 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,98 @@ 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; + + // 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)); + } + } + } - // 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(); + private prioritizeCategory(category: Category) { 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 -
- `; - } + if (!me) return; - 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]; - } + // First, clear all priorities from other categories + const allTechs = this.techs; + const researched = this.researchedIDsFromGame(); + const priorities = me.researchPriorities?.() ?? new Set(); - 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); - } + // 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)); + } + } - private onTabClick(cat: ResearchTab) { - if (cat === this.activeTab) return; - this.activeTab = cat; + // Now prioritize all techs in this category that aren't already prioritized + const categoryTechs = this.techs.filter((t) => t.category === category); - // 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()); + 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; - this.roadInvestmentRate = detail.road; this.researchInvestmentRate = detail.research; - this.lockRoad = detail.lockRoad; this.lockResearch = detail.lockResearch; - this.roadInvestmentEnabled = detail.roadEnabled; }; private requestInvestmentSync() { @@ -278,582 +194,67 @@ 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: ${percent}% +
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 priorities = me?.researchPriorities?.() ?? new Set(); + const percentByTechId = (() => { const map = new Map(); for (const tech of this.techs) { @@ -867,1212 +268,464 @@ 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]; + + // 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` +
+
+ ${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 = priorities.has(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()}`; + + // 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` +
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..280a1875b --- /dev/null +++ b/src/client/TechTooltips.ts @@ -0,0 +1,72 @@ +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 s1 = SUBMARINE_UPGRADES[0]; + 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โ€ข 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โ€ข 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)`; + + // --- LAND --- + case RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS: + 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: 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: +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: 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 (${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 (+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 (+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 (+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 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 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 - 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: 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/client/Transport.ts b/src/client/Transport.ts index 009fcd9e3..c1c434104 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, @@ -124,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) {} } @@ -351,9 +335,6 @@ export class Transport { this.onSendSetAutoBombingEvent(e), ); - this.eventBus.on(SendScorchedEarthIntentEvent, () => - this.onSendScorchedEarthIntent(), - ); this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.onSendUpgradeStructureIntent(e), ); @@ -368,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)); @@ -767,45 +740,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(25, val); } } } catch { - // Ignore malformed local storage. - targetLevel = undefined; + stackCount = undefined; bomberLevel = undefined; } @@ -814,18 +762,11 @@ 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, }); } - private onSendScorchedEarthIntent() { - this.sendIntent({ - type: "activate_scorched_earth", - clientID: this.lobbyConfig.clientID, - }); - } - private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { // Prefer new generic intent this.sendIntent({ @@ -854,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/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 98a78b66d..0e11105a6 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -27,12 +27,13 @@ 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, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; @@ -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 playerMaxStructureTechLevel(player, type); + } + private _desiredUnitLevel(type: UnitType): number { try { const raw = localStorage.getItem("unitUpgradeSettings.levels"); @@ -635,7 +645,94 @@ 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 { + 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 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 = playerMaxStructureTechLevel(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; + } + + // Do not prefix stack count in the label; chip handles it + return name; + } + + // Handle other stackable structures + if (isStackableStructure(unitType)) { + // Do not prefix stack count in the label; chip handles it + return baseName; + } + + return baseName; } public onBuildSelected = (item: BuildItemDisplay) => { @@ -674,16 +771,17 @@ 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, + ); + const desiredStack = this._desiredStackCount(item.unitType); return html` - ${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`
` : ""} @@ -1703,6 +1175,41 @@ export class ControlPanel2 extends LitElement implements Layer { /> Multi-Build Structures +
${this.uiState.pendingBuildUnitType - ? `Set ${this.uiState.pendingBuildUnitType} Level` + ? `Stack ร—${this._structureLevels[this.uiState.pendingBuildUnitType] || 1}` : "Select a structure..."}
-
Multi-Build Units -
-
- - ${this.uiState.pendingBuildUnitType - ? `Set ${this.uiState.pendingBuildUnitType} Level` - : "Select a unit..."} - - - -
-
-
{}, color: null, - icon: null, + icon: boatIcon, }, ], [ @@ -84,7 +86,17 @@ export class RadialMenu implements Layer { disabled: true, action: () => {}, color: null, - icon: null, + icon: airAttackIcon, + }, + ], + [ + Slot.Bomber, + { + name: "bomber", + disabled: true, + action: () => {}, + color: null, + icon: airfieldIcon, }, ], [ @@ -94,10 +106,19 @@ export class RadialMenu implements Layer { disabled: true, action: () => {}, color: null, - icon: null, + icon: infoIcon, + }, + ], + [ + Slot.Ally, + { + name: "ally", + disabled: true, + action: () => {}, + color: null, + icon: allianceIcon, }, ], - [Slot.Ally, { name: "ally", disabled: true, action: () => {} }], [ Slot.Peace, { @@ -116,7 +137,14 @@ export class RadialMenu implements Layer { private readonly centerButtonSize = 30; private readonly iconSize = 32; private readonly centerIconSize = 48; - private readonly disabledColor = d3.rgb(128, 128, 128).toString(); + // When disabled, keep the hue but darken it slightly so the icon stays visible. + private darkenColor(color: string): string { + const parsed = d3.color(color); + if (!parsed) return d3.rgb(80, 80, 80).toString(); + // darker(0.6) keeps the original hue while making it visibly inactive + const darker = (parsed as any).darker?.(0.6); + return (darker ?? parsed).toString(); + } // Scale factor specifically for the Peace (dove) icon relative to iconSize private readonly peaceIconScale = 1.2; @@ -189,12 +217,14 @@ export class RadialMenu implements Layer { .append("path") .attr("d", arc) .attr("fill", (d) => - d.data.disabled ? this.disabledColor : d.data.color, + d.data.disabled + ? this.darkenColor(d.data.color ?? "#444") + : d.data.color, ) .attr("stroke", "#ffffff") .attr("stroke-width", "2") .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) - .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) + .style("opacity", (d) => (d.data.disabled ? 0.7 : 1)) .attr("data-name", (d) => d.data.name) .on("mouseover", function (event, d) { if (!d.data.disabled) { @@ -476,13 +506,72 @@ export class RadialMenu implements Layer { }); } + if (this.shouldShowBomber(myPlayer, tile)) { + this.activateMenuElement(Slot.Bomber, "#FF6B35", airfieldIcon, () => { + if (this.clickedCell === null) return; + const targetPlayer = this.g.owner(tile) as PlayerView; + // Target all structure types with closest-first priority + const allStructures = [ + UnitType.City, + UnitType.DefensePost, + UnitType.SAMLauncher, + UnitType.MissileSilo, + UnitType.Port, + UnitType.Airfield, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.DoomsdayDevice, + ]; + this.eventBus.emit( + new SendBomberIntentEvent(targetPlayer.id(), allStructures, true), + ); + }); + } + if (!this.g.hasOwner(tile)) { return; } } + private shouldShowBomber(player: PlayerView, tile: TileRef): boolean { + // Check if player has at least one active airfield + if (player.units(UnitType.Airfield).length === 0) { + return false; + } + // Check if tile is land + if (!this.g.isLand(tile)) { + return false; + } + // Check if tile is owned by an enemy + const owner = this.g.owner(tile); + if (owner === player || !owner.isPlayer()) { + return false; + } + // Check if player is at war with owner + if (!player.isAtWarWith?.(owner as PlayerView)) { + return false; + } + // Check if any airfield can reach this tile + const airfields = player.units(UnitType.Airfield); + for (const airfield of airfields) { + if (!airfield.isActive()) continue; + const range = this.g + .config() + .bomberTargetRange(airfield.bomberLevel?.() ?? 1); + const dist = Math.sqrt( + this.g.euclideanDistSquared(airfield.tile(), tile), + ); + if (dist <= range) { + return true; + } + } + return false; + } + private shouldShowAirAttack(player: PlayerView, tile: TileRef): boolean { - if (!player.hasUpgrade(UpgradeType.AirUpgrade1)) { + if (!player.hasUpgrade(UpgradeType.JetEngines)) { return false; } if (player.units(UnitType.Airfield).length === 0) { @@ -616,14 +705,17 @@ export class RadialMenu implements Layer { private updateMenuItemState(item: any) { const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`); menuItem - .attr("fill", item.disabled ? this.disabledColor : item.color) + .attr( + "fill", + item.disabled ? this.darkenColor(item.color ?? "#444") : item.color, + ) .style("cursor", item.disabled ? "not-allowed" : "pointer") - .style("opacity", item.disabled ? 0.5 : 1); + .style("opacity", item.disabled ? 0.7 : 1); this.menuElement .select(`image[data-name="${item.name}"]`) - .attr("xlink:href", item.disabled ? disabledIcon : item.icon) - .attr("fill", item.disabled ? "#999999" : "white"); + .attr("xlink:href", item.icon) + .style("opacity", item.disabled ? 0.7 : 1); } private onCenterButtonHover(isHovering: boolean) { diff --git a/src/client/graphics/layers/RangeOverlayLayer.ts b/src/client/graphics/layers/RangeOverlayLayer.ts index 246422be8..6172bb2a8 100644 --- a/src/client/graphics/layers/RangeOverlayLayer.ts +++ b/src/client/graphics/layers/RangeOverlayLayer.ts @@ -3,6 +3,7 @@ import { EventBus } from "../../../core/EventBus"; import { Cell, UnitType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; +import { playerMaxStructureTechLevel } from "../../../core/game/Upgradeables"; import { MouseOverEvent } from "../../InputHandler"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; @@ -404,7 +405,8 @@ export class RangeOverlayLayer implements Layer { if (u.type() === UnitType.SAMLauncher) { const base = this.game.config().defaultSamRange(); const bonus = this.game.config().samRangeUpgradePercent(); - const lvl = u.level(); + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel(u.owner(), UnitType.SAMLauncher); if (lvl <= 1) return base; const factor = Math.pow(1 + bonus, lvl - 1); return Math.round(base * factor); diff --git a/src/client/graphics/layers/ResearchToggleButton.ts b/src/client/graphics/layers/ResearchToggleButton.ts index 5d751e34e..b1ae56d3b 100644 --- a/src/client/graphics/layers/ResearchToggleButton.ts +++ b/src/client/graphics/layers/ResearchToggleButton.ts @@ -19,9 +19,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @state() private _isModalOpen = false; - @state() - private _hasUnseenPolicyDirectives = false; - private modalRef: ResearchTreeModal | null = null; createRenderRoot() { @@ -42,12 +39,6 @@ export class ResearchToggleButton extends LitElement implements Layer { this._isVisible = shouldShow; this.requestUpdate(); } - // Check for unseen policy directives - const hasUnseen = player?.hasUnseenPolicyDirectives?.() ?? false; - if (hasUnseen !== this._hasUnseenPolicyDirectives) { - this._hasUnseenPolicyDirectives = hasUnseen; - this.requestUpdate(); - } this.updateModalState(); } @@ -188,9 +179,6 @@ export class ResearchToggleButton extends LitElement implements Layer { @click=${this.toggleModal} style="position: relative;" > - ${this._hasUnseenPolicyDirectives - ? html`!` - : ""} ${["R", "E", "S", "E", "A", "R", "C", "H"].map( (letter) => html`${letter}`, )} diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 00abf74b5..5632f99e3 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -22,6 +22,7 @@ import { getUnitUpgradeCost } from "../../../core/game/UnitUpgrades"; import { isUpgradeableStructure, playerMaxStructureLevel, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../../../core/game/Upgradeables"; import { ToggleBomberUpgradeModeEvent } from "../../events/ToggleBomberUpgradeModeEvent"; @@ -564,16 +565,16 @@ export class StructureLayer implements Layer { this.ensureStructureLevels(unit); const record = this.structureLevels.get(unit.id()); if (record) { - // Sync primary level from server value. + // Sync primary from server stack count. const prevLevel = record.primary; - const serverLevel = unit.level(); - record.primary = serverLevel; - // If the hovered structure's level changed, refresh the label immediately. + const serverStackCount = unit.stackCount?.() ?? 1; + record.primary = serverStackCount; + // If the hovered structure's stack count changed, refresh the label immediately. if (this.hoveredStructure && this.hoveredStructure.id() === unit.id()) { this.updateLabels(); } - // If level changed and we're in upgrade mode, re-render texture so highlight state updates - if (prevLevel !== serverLevel && this.upgradeMode) { + // If stack count changed and we're in upgrade mode, re-render texture so highlight state updates + if (prevLevel !== serverStackCount && this.upgradeMode) { // Refresh texture so highlight state updates based on new level const target = this.renders.find((r) => r.unit.id() === unit.id()); if (target) { @@ -1043,18 +1044,37 @@ export class StructureLayer implements Layer { !this.structureLevels.has(id) && unit.type() !== UnitType.Construction ) { - // Initialize with server level (typically 1 unless upgraded before client joined) + // Initialize with stack count (for display) instead of level // For airfields, set secondary to bomber upgrade level - const secondary = - unit.type() === UnitType.Airfield ? unit.bomberLevel() : 0; - this.structureLevels.set(id, { primary: unit.level(), secondary }); + // For SAMs, set secondary to SAM tech level + let secondary = 0; + if (unit.type() === UnitType.Airfield) { + secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + secondary = playerMaxStructureTechLevel(player, UnitType.SAMLauncher); + } + } + this.structureLevels.set(id, { + primary: unit.stackCount?.() ?? 1, + secondary, + }); } else if (this.structureLevels.has(id)) { - // Keep in sync with authoritative server level each tick/render cycle + // Keep in sync with authoritative server stack count each tick/render cycle const rec = this.structureLevels.get(id)!; - rec.primary = unit.level(); + rec.primary = unit.stackCount?.() ?? 1; // For airfields, update secondary to bomber upgrade level if (unit.type() === UnitType.Airfield) { rec.secondary = unit.bomberLevel(); + } else if (unit.type() === UnitType.SAMLauncher) { + const player = this.game.myPlayer(); + if (player && unit.owner().id() === player.id()) { + rec.secondary = playerMaxStructureTechLevel( + player, + UnitType.SAMLauncher, + ); + } } } } diff --git a/src/client/graphics/layers/TechUnlockNotification.ts b/src/client/graphics/layers/TechUnlockNotification.ts index 95aaf5dcc..0c1ed838f 100644 --- a/src/client/graphics/layers/TechUnlockNotification.ts +++ b/src/client/graphics/layers/TechUnlockNotification.ts @@ -87,10 +87,11 @@ export class TechUnlockNotification extends LitElement implements Layer { const meta = getTechMeta(techId, { strict: false }); if (!meta) continue; this.seenTechs.add(techId); + const body = meta.shortDescription ?? meta.description ?? ""; this.enqueue({ id: techId, name: meta.name ?? techId, - description: meta.description ?? "", + description: body, }); } for (const techId of filtered) this.seenTechs.add(techId); diff --git a/src/client/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/Schemas.ts b/src/core/Schemas.ts index dcf8a0d3b..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 { @@ -425,16 +415,12 @@ export const BuildUnitIntentSchema = BaseIntentSchema.extend({ tile: z.number(), // Optional desired starting level for upgradeable structures. // Server will clamp based on type and game rules. - targetLevel: z.number().int().min(1).max(99).optional(), + targetLevel: z.number().int().min(1).max(25).optional(), // Optional desired bomber upgrade level for airfields. // Server will clamp based on maxUnitLevel(UnitType.Bomber). bomberLevel: z.number().int().min(1).max(99).optional(), }); -export const ScorchedEarthIntentSchema = BaseIntentSchema.extend({ - type: z.literal("activate_scorched_earth"), -}); - export const UpgradeStructureIntentSchema = BaseIntentSchema.extend({ type: z.literal("upgrade_structure"), unitId: z.number(), @@ -451,16 +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/AirfieldExecution.ts b/src/core/execution/AirfieldExecution.ts index de815a160..20ee4da48 100644 --- a/src/core/execution/AirfieldExecution.ts +++ b/src/core/execution/AirfieldExecution.ts @@ -1,6 +1,6 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { maxUnitLevel, playerMaxStructureLevel } from "../game/Upgradeables"; +import { maxUnitLevel } from "../game/Upgradeables"; import { PseudoRandom } from "../PseudoRandom"; import { BomberExecution } from "./BomberExecution"; import { CargoPlaneExecution } from "./CargoPlaneExecution"; @@ -11,13 +11,13 @@ export class AirfieldExecution implements Execution { private airfield: Unit | null = null; private random: PseudoRandom | null = null; private checkOffset: number | null = null; - private lastLevel = 0; // Track airfield level to detect upgrades + private lastStackCount = 0; // Track airfield stack count to detect upgrades constructor( private player: Player, private tile: TileRef, - private initialBomberLevel: number = 1, // Bomber upgrade level - private desiredLevel: number = 1, // Structure upgrade level + private initialBomberLevel: number = 1, // Bomber tech upgrade level + private stackCount: number = 1, // Stack count (how many bombers to spawn/maintain) ) {} init(mg: Game, ticks: number): void { @@ -43,13 +43,15 @@ export class AirfieldExecution implements Execution { } this.airfield = this.player.buildUnit(UnitType.Airfield, spawn, {}); - // Apply structure upgrades if requested - const structureLevel = this.computeDesiredLevel( - UnitType.Airfield, - this.desiredLevel, - ); - this.applyUpgrades(this.airfield, structureLevel); - this.lastLevel = this.airfield.level?.() ?? 1; + // Set stack count on the airfield + if (this.stackCount > 1) { + (this.airfield as any).setStackCount(this.stackCount); + // Apply HP bonuses for stacking (one upgrade per extra stack) + for (let i = 1; i < this.stackCount; i++) { + (this.airfield as any).upgradeStructure(); + } + } + this.lastStackCount = this.stackCount; // Set initial bomber upgrade level if specified (clamped to max) const bomberLvl = Math.min( @@ -60,8 +62,8 @@ export class AirfieldExecution implements Execution { this.airfield.setBomberLevel?.(bomberLvl); } - // Spawn initial bombers when airfield is built - this.spawnBombersForLevel(mg); + // Spawn initial bombers based on stack count + this.spawnBombersForStackCount(mg); } if (!this.airfield.isActive()) { @@ -73,14 +75,14 @@ export class AirfieldExecution implements Execution { this.player = this.airfield.owner(); } - // Check if airfield was upgraded - spawn additional bombers - const currentLevel = this.airfield.level?.() ?? 1; - if (currentLevel > this.lastLevel) { - const bombersToAdd = currentLevel - this.lastLevel; + // Check if airfield was upgraded (stack count increased) - spawn additional bombers + const currentStackCount = this.airfield.stackCount?.() ?? 1; + if (currentStackCount > this.lastStackCount) { + const bombersToAdd = currentStackCount - this.lastStackCount; for (let i = 0; i < bombersToAdd; i++) { mg.addExecution(new BomberExecution(this.player, this.airfield)); } - this.lastLevel = currentLevel; + this.lastStackCount = currentStackCount; } if ((mg.ticks() + this.checkOffset) % 10 !== 0) { @@ -110,9 +112,9 @@ export class AirfieldExecution implements Execution { } } - private spawnBombersForLevel(mg: Game): void { - const level = this.airfield?.level?.() ?? 1; - for (let i = 0; i < level; i++) { + private spawnBombersForStackCount(mg: Game): void { + const count = this.airfield?.stackCount?.() ?? 1; + for (let i = 0; i < count; i++) { mg.addExecution(new BomberExecution(this.player, this.airfield!)); } } @@ -124,19 +126,4 @@ export class AirfieldExecution implements Execution { activeDuringSpawnPhase(): boolean { return false; } - - private computeDesiredLevel(type: UnitType, target?: number): number { - if (target === undefined || target < 1) return 1; - const cap = playerMaxStructureLevel(this.player, type); - return Math.max(1, Math.min(cap, target)); - } - - private applyUpgrades(unit: Unit, desiredLevel: number): void { - const steps = Math.max(0, desiredLevel - 1); - if (steps <= 0) return; - const impl = unit as any; - if (typeof impl.upgradeStructure === "function") { - for (let i = 0; i < steps; i++) impl.upgradeStructure(); - } - } } diff --git a/src/core/execution/BomberExecution.ts b/src/core/execution/BomberExecution.ts index f88760eea..377a17904 100644 --- a/src/core/execution/BomberExecution.ts +++ b/src/core/execution/BomberExecution.ts @@ -1,6 +1,7 @@ import type { Execution, Game, Player, Unit } from "../game/Game"; import { UnitType } from "../game/Game"; import type { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; import { StraightPathFinder } from "../pathfinding/PathFinding"; import { roadEffectModifiers } from "../tech/TechEffects"; @@ -371,11 +372,17 @@ export class BomberExecution implements Execution { ) { const targetPlayer = this.mg.player(intent.targetPlayerID); if (targetPlayer && this.origOwner.isAtWarWith(targetPlayer)) { - return this.findTargetFromQueue( + const target = this.findTargetFromQueue( targetPlayer, intent.structures, intent.preferClosest, ); + // If we found a target in manual mode, use it + if (target) { + return target; + } + // If no targets remain, clear the manual intent and fall through to auto-bombing + this.origOwner.setBomberIntent(null); } } // Auto-bombing mode if (!this.origOwner.isAutoBombingEnabled()) { @@ -546,7 +553,8 @@ export class BomberExecution implements Execution { private getEffectiveSAMRange(sam: Unit): number { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = sam.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel(sam.owner(), UnitType.SAMLauncher); if (lvl <= 1) return base; // Apply per-upgrade multiplicative increase const factor = Math.pow(1 + bonus, lvl - 1); diff --git a/src/core/execution/ConstructionExecution.ts b/src/core/execution/ConstructionExecution.ts index 67cf40bc3..dbd58e342 100644 --- a/src/core/execution/ConstructionExecution.ts +++ b/src/core/execution/ConstructionExecution.ts @@ -15,8 +15,11 @@ import { } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { + isStackableStructure, + isTechUpgradeableStructure, isUpgradeableUnit, - playerMaxStructureLevel, + maxStackCount, + playerMaxStructureTechLevel, playerMaxUnitLevel, } from "../game/Upgradeables"; import { constructionSpeedModifiers } from "../tech/TechEffects"; @@ -39,13 +42,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 +74,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 +95,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 +115,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 +138,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 +189,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new WarshipExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -232,7 +197,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new SubmarineExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -240,7 +205,7 @@ export class ConstructionExecution implements Execution { this.mg.addExecution( new FighterJetExecution( { owner: player, patrolTile: this.tile }, - this.desiredLevel, + this.desiredTechLevel, ), ); break; @@ -259,15 +224,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 +248,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 +278,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 +307,7 @@ export class ConstructionExecution implements Execution { canSpawn, {}, ); - this.applyUpgradesIfNeeded(built, this.desiredLevel); + this.applyStackingIfNeeded(built, this.desiredStackCount); } break; } @@ -342,22 +321,75 @@ export class ConstructionExecution implements Execution { return false; } - private computeDesiredLevel(type: UnitType, target?: number): number { - if (target === undefined || target < 1) return 1; - // For units, use player-specific max level based on researched techs - // For structures, use player-specific max (e.g., SAM launchers depend on SAM tech level) - const cap = isUpgradeableUnit(type) - ? playerMaxUnitLevel(this.player, type) - : playerMaxStructureLevel(this.player, type); - return Math.max(1, Math.min(cap, target)); + // Compute the stack count (how many instances in one tile) + private computeStackCount(type: UnitType): number { + // Use client-provided stack count, clamped to valid range + if (isStackableStructure(type) && this.stackCount && this.stackCount > 1) { + return Math.min(maxStackCount(type), this.stackCount); + } + return 1; } - // 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 playerMaxStructureTechLevel(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/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/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/ParatrooperAttackExecution.ts b/src/core/execution/ParatrooperAttackExecution.ts index a8ccc0a80..4eb2ca607 100644 --- a/src/core/execution/ParatrooperAttackExecution.ts +++ b/src/core/execution/ParatrooperAttackExecution.ts @@ -45,7 +45,7 @@ export class ParatrooperAttackExecution implements Execution { init(game: Game, ticks: number): void { this.mg = game; - if (!this.attacker.hasUpgrade(UpgradeType.AirUpgrade1)) { + if (!this.attacker.hasUpgrade(UpgradeType.JetEngines)) { return; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 8680f64ba..0ae68705e 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -182,11 +182,12 @@ export class PlayerExecution implements Execution { ); if (available.length === 0) return; - // Allocation: 50% to priority, 50% split among remaining; if no valid priority, split evenly - const priorityId: string | null = - (this.player as any).researchPriority?.() ?? null; - const priorityInSet = - priorityId !== null && available.some((n) => n.id === priorityId); + // Get all priorities and check which are available + const allPriorities: Set = + (this.player as any).researchPriorities?.() ?? new Set(); + const availablePriorities = available.filter((n) => + allPriorities.has(n.id), + ); const k = this.config.researchK(); const bMin = this.config.researchBeakerMin(); @@ -240,16 +241,20 @@ export class PlayerExecution implements Execution { }; const alloc: Record = {}; - if (priorityId && !priorityInSet) { - // Priority target not available: allocate 60% to the frontier of its missing prereqs - const pathSet = buildMissingPrereqPath(priorityId); - const frontier = available.filter((n) => pathSet.has(n.id)); + if (allPriorities.size > 0 && availablePriorities.length === 0) { + // Has priorities but none available: allocate 60% to the frontier of their missing prereqs + const allPathSets = new Set(); + for (const priorityId of allPriorities) { + const pathSet = buildMissingPrereqPath(priorityId); + pathSet.forEach((id) => allPathSets.add(id)); + } + const frontier = available.filter((n) => allPathSets.has(n.id)); if (frontier.length > 0) { const priorityShare = 0.6 * xTotal; const shareFrontier = priorityShare / frontier.length; for (const n of frontier) alloc[n.id] = (alloc[n.id] ?? 0) + shareFrontier; - const others = available.filter((n) => !pathSet.has(n.id)); + const others = available.filter((n) => !allPathSets.has(n.id)); const remaining = xTotal - priorityShare; const shareOthers = others.length > 0 ? remaining / others.length : 0; for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + shareOthers; @@ -258,14 +263,22 @@ export class PlayerExecution implements Execution { const share = xTotal / available.length; for (const n of available) alloc[n.id] = share; } - } else if (priorityInSet && available.length > 1) { + } else if ( + availablePriorities.length > 0 && + available.length > availablePriorities.length + ) { + // Some priorities available: 60% split among priorities, 40% among others const priorityShare = 0.6 * xTotal; - alloc[priorityId!] = (alloc[priorityId!] ?? 0) + priorityShare; - const others = available.filter((n) => n.id !== priorityId); + const sharePriority = priorityShare / availablePriorities.length; + for (const n of availablePriorities) { + alloc[n.id] = sharePriority; + } + const others = available.filter((n) => !allPriorities.has(n.id)); const share = others.length > 0 ? (xTotal - priorityShare) / others.length : 0; for (const n of others) alloc[n.id] = (alloc[n.id] ?? 0) + share; } else { + // No priorities or all available are prioritized: even split const share = xTotal / available.length; for (const n of available) alloc[n.id] = share; } diff --git a/src/core/execution/PolicyDirectiveSelectExecution.ts b/src/core/execution/PolicyDirectiveSelectExecution.ts deleted file mode 100644 index bf14c7cfc..000000000 --- a/src/core/execution/PolicyDirectiveSelectExecution.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Execution, Game, Player, UpgradeType } from "../game/Game"; -import { - getPolicyDirective, - isDirectiveUnlocked, - type PolicyDirectiveId, -} from "../tech/PolicyDirectives"; - -/** - * Execution to set the player's policy directive choice. - */ -export class PolicyDirectiveSelectExecution implements Execution { - private _active = true; - private mg: Game | null = null; - - constructor( - private readonly player: Player, - private readonly directiveId: string, - private readonly optionId: string, - ) {} - - isActive(): boolean { - return this._active; - } - - activeDuringSpawnPhase(): boolean { - return true; - } - - init(_mg: Game, _ticks: number): void { - this.mg = _mg; - } - - tick(_ticks: number): void { - // Validate the directive exists - const directive = getPolicyDirective(this.directiveId as PolicyDirectiveId); - if (!directive) { - console.warn( - `[PolicyDirectiveSelectExecution] Unknown directive: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Validate the directive is unlocked for this player - if ( - !isDirectiveUnlocked( - this.directiveId as PolicyDirectiveId, - (techId) => this.player.hasResearchedTech?.(techId) ?? false, - ) - ) { - console.warn( - `[PolicyDirectiveSelectExecution] Directive not unlocked: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Validate the option exists - const option = directive.options.find((o) => o.id === this.optionId); - if (!option) { - console.warn( - `[PolicyDirectiveSelectExecution] Unknown option: ${this.optionId} for directive ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Check if a choice has already been made (policy directives are one-time choices) - const existingChoice = this.player.getPolicyChoice(this.directiveId); - if (existingChoice !== null) { - console.warn( - `[PolicyDirectiveSelectExecution] Choice already made for directive: ${this.directiveId}`, - ); - this._active = false; - return; - } - - // Set the policy choice - this.player.setPolicyChoice(this.directiveId, this.optionId); - - // Apply upgrade effects from the chosen option - if (option.effects.grantsInternationalTrade) { - this.player.addUpgrade(UpgradeType.InternationalTrade); - } - - this._active = false; - } -} diff --git a/src/core/execution/ResearchTreeSelectExecution.ts b/src/core/execution/ResearchTreeSelectExecution.ts index 4b15407d1..ce893ff7e 100644 --- a/src/core/execution/ResearchTreeSelectExecution.ts +++ b/src/core/execution/ResearchTreeSelectExecution.ts @@ -39,18 +39,19 @@ export class ResearchTreeSelectExecution implements Execution { // Complete the research immediately; side-effects are handled by addResearchedTech() (this.player as any).addResearchedTech?.(this.techId); - // Clear any existing priority since research is completed - (this.player as any).setResearchPriority?.(null); + // Remove from priorities since research is completed + const priorities = (this.player as any).researchPriorities?.(); + if (priorities?.has(this.techId)) { + (this.player as any).setResearchPriority?.(this.techId); // Toggle off + } this._active = false; return; } // Fall through to priority toggle if not available or already researched } - // Default behavior: toggle research priority on click - const current = (this.player as any).researchPriority?.() ?? null; - const next = current === this.techId ? null : this.techId; - (this.player as any).setResearchPriority?.(next); + // Default behavior: toggle research priority (add/remove from set) + (this.player as any).setResearchPriority?.(this.techId); this._active = false; } } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 379561bf0..e21c79cb5 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -8,6 +8,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import { playerMaxStructureTechLevel } from "../game/Upgradeables"; import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; @@ -45,7 +46,11 @@ class SAMTargetingSystem { private effectiveSamRange(): number { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = this.sam.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel( + this.sam.owner(), + UnitType.SAMLauncher, + ); if (lvl <= 1) return base; // Apply per-upgrade multiplicative increase const factor = Math.pow(1 + bonus, lvl - 1); @@ -83,6 +88,12 @@ class SAMTargetingSystem { } public getSingleTarget(): Target | null { + const targets = this.getMultipleTargets(1); + return targets.length > 0 ? targets[0] : null; + } + + // Get multiple targets for stacked SAMs - each SAM can target a different nuke + public getMultipleTargets(maxCount: number): Target[] { // Look beyond the SAM range so it can preshot nukes const detectionRange = this.effectiveSamRange() * 1.5; const nukes = this.mg.nearbyUnits( @@ -104,6 +115,10 @@ class SAMTargetingSystem { if (this.nukesToIgnore.has(nuke.unit.id())) { continue; } + // Skip nukes already being targeted by a SAM missile + if (nuke.unit.targetedBySAM()) { + continue; + } const interceptionTile = this.computeInterceptionTile(nuke.unit); if (interceptionTile !== undefined) { targets.push({ unit: nuke.unit, tile: interceptionTile }); @@ -113,8 +128,9 @@ class SAMTargetingSystem { } } - return ( - targets.sort((a: Target, b: Target) => { + // Sort by priority (H-bombs first) and return up to maxCount + return targets + .sort((a: Target, b: Target) => { // Prioritize Hydrogen Bombs if ( a.unit.type() === UnitType.HydrogenBomb && @@ -128,8 +144,8 @@ class SAMTargetingSystem { return 1; return 0; - })[0] ?? null - ); + }) + .slice(0, maxCount); } } @@ -153,6 +169,7 @@ export class SAMLauncherExecution implements Execution { private tile: TileRef | null, private sam: Unit | null = null, private desiredLevel?: number, + private stackCount: number = 1, // Number of stacked SAMs (fires multiple missiles) ) { if (sam !== null) { this.tile = sam.tile(); @@ -203,12 +220,20 @@ export class SAMLauncherExecution implements Execution { return; } this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {}); - // Apply upgrades up to cap 3 if requested + // Apply tech level upgrades const level = this.computeDesiredLevel( UnitType.SAMLauncher, this.desiredLevel, ); this.applyUpgrades(this.sam, level); + // Set stack count for multiple missiles + if (this.stackCount > 1) { + (this.sam as any).setStackCount(this.stackCount); + // Apply HP bonuses for stacking (one upgrade per extra stack) + for (let i = 1; i < this.stackCount; i++) { + (this.sam as any).upgradeStructure(); + } + } } this.targetingSystem ??= new SAMTargetingSystem( this.mg, @@ -249,6 +274,7 @@ export class SAMLauncherExecution implements Execution { }, ); + // Get a single target - stacked SAMs use launchesRemaining to fire multiple times before cooldown let target: Target | null = null; if (mirvWarheadTargets.length === 0) { target = this.targetingSystem.getSingleTarget(); @@ -259,11 +285,8 @@ export class SAMLauncherExecution implements Execution { this.sam.touch(); } - const isSingleTarget = !!(target && !target.unit.targetedBySAM()); - if ( - (isSingleTarget || mirvWarheadTargets.length > 0) && - !isPeaceTimerActive - ) { + const hasTarget = target !== null; + if ((hasTarget || mirvWarheadTargets.length > 0) && !isPeaceTimerActive) { this.sam.launch(); const type = mirvWarheadTargets.length > 0 @@ -302,6 +325,7 @@ export class SAMLauncherExecution implements Execution { mirvWarheadTargets.length, ); } else if (target !== null) { + // Fire one missile at the target target.unit.setTargetedBySAM(true); this.mg.addExecution( new SAMMissileExecution( @@ -329,7 +353,11 @@ export class SAMLauncherExecution implements Execution { const effectiveRange = (() => { const base = this.mg.config().defaultSamRange(); const bonus = this.mg.config().samRangeUpgradePercent(); - const lvl = this.sam!.level?.() ?? 1; + // Use player's SAM tech level, not unit level (which is stack count) + const lvl = playerMaxStructureTechLevel( + this.sam!.owner(), + UnitType.SAMLauncher, + ); if (lvl <= 1) return base; const factor = Math.pow(1 + bonus, lvl - 1); return Math.round(base * factor); diff --git a/src/core/execution/ScorchedEarthExecution.ts b/src/core/execution/ScorchedEarthExecution.ts deleted file mode 100644 index 3c2215c33..000000000 --- a/src/core/execution/ScorchedEarthExecution.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Execution, Player, UpgradeType } from "../game/Game"; -import { GameImpl } from "../game/GameImpl"; -import { RESEARCH_TECH_IDS } from "../tech/TechEffects"; - -export class ScorchedEarthExecution implements Execution { - private mg: GameImpl; - private _isActive = true; - - constructor(private player: Player) {} - - public static fromIntent( - game: GameImpl, - intent: { - type: "activate_scorched_earth"; - clientID: string; - }, - ): ScorchedEarthExecution { - const player = game.playerByClientID(intent.clientID); - if (!player) { - throw new Error(`Player with clientID ${intent.clientID} not found`); - } - return new ScorchedEarthExecution(player); - } - - public isActive(): boolean { - return this._isActive; - } - - public activeDuringSpawnPhase(): boolean { - return false; - } - - init(mg: GameImpl, ticks: number): void { - this.mg = mg; - - // Already activated - if (this.player.hasUpgrade(UpgradeType.ScorchedEarth)) { - this._isActive = false; - return; - } - - // Must have researched Mechanized Warfare Doctrine to unlock Scorched Earth - if ( - !this.player.hasResearchedTech( - RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE, - ) - ) { - this._isActive = false; - return; - } - - // Check gold cost - const cost = this.mg.config().scorchedEarthActivationCost(this.player); - if (this.player.gold() < cost) { - this._isActive = false; - return; - } - - // Deduct gold and activate - this.player.removeGold(cost); - this.player.addUpgrade(UpgradeType.ScorchedEarth); - - // Destroy roads only (keep techs and upgrades) - this.mg.destroyPlayerRoads(this.player); - this.player.setRoadInvestmentRate(0); - this.mg.markPlayerNodesForReconnection(this.player); - - this._isActive = false; - } - - public tick(ticks: number): void { - // Logic is in init() - } -} diff --git a/src/core/execution/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/Game.ts b/src/core/game/Game.ts index 262214d2b..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([ @@ -572,7 +559,8 @@ export interface Unit { isPeriodicallyVisible(): boolean; // Upgrades - level(): number; // Current upgrade level (>=1) + level(): number; // Current upgrade/tech level (>=1) - for SAM/Airfield this is the tech tier + stackCount(): number; // Number of stacked instances (>=1) - for all structures upgradeStructure(): void; // Applies structure-specific upgrade effects (currently City only) effectiveMaxHealth(): number; // Base max health + bonuses from upgrades } @@ -694,13 +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 @@ -980,7 +961,6 @@ export enum MessageType { SENT_TROOPS_TO_PLAYER, RECEIVED_TROOPS_FROM_PLAYER, CHAT, - INSURANCE_REFUND, WARN, PEACE_TIMER_BLOCKED, DOOMSDAY_DEVICE_ACTIVATED, @@ -1030,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/GameUpdates.ts b/src/core/game/GameUpdates.ts index 019163c79..4c446a403 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -151,6 +151,10 @@ export interface UnitUpdate { ghostExpiresAt?: Tick; // Structure upgrade level (>=1). Cities increase level by 1 per upgrade. level?: number; + // Stack count (>=1). Number of stacked instances for stackable structures. + stackCount?: number; + // Missile silo specific: remaining launches before cooldown (for stacked silos) + launchesRemaining?: number; // Trade-ship specific, for precise UI without heuristics tradeRouteStartOwnerID?: number; // smallID of start port owner tradeRouteEndOwnerID?: number; // smallID of end port owner @@ -232,8 +236,10 @@ export interface PlayerUpdate { researchTreeTechs: string[]; // Research progress (beakers) per tech id (optional; omitted if none) researchTreeBeakers?: Record; - // Currently selected research priority tech id (optional) + // Currently selected research priority tech id (optional, legacy single priority) researchPriorityTech?: string | null; + // All selected research priority tech ids (optional; omitted if none) + researchPriorities?: string[]; // Policy directive choices: directiveId -> optionId (optional; omitted if none) policyChoices?: Record; // Whether the player has unseen policy directives to review diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d2d65c382..eb5547b2f 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -193,6 +193,17 @@ export class UnitView { return (this.data as any).level ?? 1; } + // Stack count (>=1). Number of stacked instances for stackable structures. + stackCount(): number { + return (this.data as any).stackCount ?? 1; + } + + // Missile silo specific: remaining launches before cooldown (for stacked silos) + launchesRemaining(): number | null { + const v = (this.data as any).launchesRemaining as number | undefined; + return v ?? null; + } + // Airfield-specific: bomber upgrade level. Defaults to 1. bomberLevel(): number { return (this.data as any).bomberLevel ?? 1; @@ -333,13 +344,8 @@ export class PlayerView { researchPriorityTech(): string | null { return this.data.researchPriorityTech ?? null; } - - // Policy Directive access - getPolicyChoice(directiveId: string): string | null { - return this.data.policyChoices?.[directiveId] ?? null; - } - hasUnseenPolicyDirectives(): boolean { - return this.data.hasUnseenPolicyDirectives ?? false; + researchPriorities(): Set { + return new Set(this.data.researchPriorities ?? []); } // Aggregate research progress across levels in [0, L] (L = max level in tree) diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index f13485330..3f8e12e43 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, @@ -55,6 +54,7 @@ import { canBuildTransportShip, } from "./TransportShipUtils"; import { UnitImpl } from "./UnitImpl"; +import { playerMaxUnitLevel } from "./Upgradeables"; interface Target { tick: Tick; @@ -100,12 +100,8 @@ export class PlayerImpl implements Player { private _researchTreeTechs: Set = new Set(); // Per-match research progress (beakers) per tech private _researchBeakers: Map = new Map(); - // Currently selected research priority tech id - private _researchPriority: string | null = null; - // Policy directive choices: directiveId -> optionId - private _policyChoices: Map = new Map(); - // Track unseen policy directives (based on newly unlocked techs) - private _unseenPolicyDirectives: Set = new Set(); + // Currently selected research priority tech ids (can have multiple) + private _researchPriorities: Set = new Set(); private _flag: string | undefined; private _name: string; @@ -139,7 +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 @@ -253,12 +249,12 @@ export class PlayerImpl implements Player { this._researchBeakers.size > 0 ? Object.fromEntries(this._researchBeakers) : undefined, - researchPriorityTech: this._researchPriority, - policyChoices: - this._policyChoices.size > 0 - ? Object.fromEntries(this._policyChoices) + researchPriorityTech: + this._researchPriorities.values().next().value ?? null, + researchPriorities: + this._researchPriorities.size > 0 + ? Array.from(this._researchPriorities) : undefined, - hasUnseenPolicyDirectives: this._unseenPolicyDirectives.size > 0, }; } @@ -347,19 +343,26 @@ export class PlayerImpl implements Player { // Count of units owned by the player, including construction unitsOwned(type: UnitType): number { let total = 0; + // All stackable structure types + const stackableTypes = new Set([ + UnitType.City, + UnitType.Port, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.SAMLauncher, + UnitType.Airfield, + UnitType.MissileSilo, + ]); + const isStackable = stackableTypes.has(type); + for (const unit of this._units) { if (unit.type() === type) { - if ( - type === UnitType.City || - type === UnitType.Port || - type === UnitType.Hospital || - type === UnitType.Academy || - type === UnitType.ResearchLab || - type === UnitType.Factory - ) { - // Upgraded cities, ports, hospitals, and academies count toward totals + if (isStackable) { + // Stacked structures count their stackCount toward totals // (affects scaling like new build cost and display counts) - total += (unit as any).level?.() ?? 1; + total += unit.stackCount?.() ?? 1; } else { total++; } @@ -367,15 +370,8 @@ export class PlayerImpl implements Player { } if (unit.type() !== UnitType.Construction) continue; if (unit.constructionType() !== type) continue; - // For upgradeable structures, count the target level instead of just 1 - if ( - type === UnitType.City || - type === UnitType.Port || - type === UnitType.Hospital || - type === UnitType.Academy || - type === UnitType.ResearchLab || - type === UnitType.Factory - ) { + // For stackable structures, count the target level instead of just 1 + if (isStackable) { total += unit.constructionTargetLevel(); } else { total++; @@ -390,12 +386,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 @@ -403,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[] = []; @@ -435,7 +544,6 @@ export class PlayerImpl implements Player { } if (toRemove.length === 0 && progressToClear.length === 0) return; - const priority = this._researchPriority; const cleared = new Set([...toRemove, ...progressToClear]); for (const techId of toRemove) { @@ -445,8 +553,9 @@ export class PlayerImpl implements Player { this._researchBeakers.delete(techId); } - if (priority && cleared.has(priority)) { - this._researchPriority = null; + // Remove cleared techs from priorities + for (const techId of cleared) { + this._researchPriorities.delete(techId); } } hasResearchedTech(techId: string): boolean { @@ -475,36 +584,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; } - - // 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); - } + researchPriorities(): Set { + return this._researchPriorities; } invalidateEffectiveUnitsCache(type: UnitType): void { diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 344ee13ef..d217ec16d 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -1,4 +1,3 @@ -import { renderNumber } from "../../client/Utils"; import { simpleHash, toInt, withinInt } from "../Util"; import { AllUnitParams, @@ -16,6 +15,7 @@ import { GameImpl } from "./GameImpl"; import { TileRef } from "./GameMap"; import { GameUpdateType, UnitUpdate } from "./GameUpdates"; import { PlayerImpl } from "./PlayerImpl"; +import { maxStackCount } from "./Upgradeables"; export class UnitImpl implements Unit { private _active = true; @@ -36,6 +36,8 @@ export class UnitImpl implements Unit { private _returning: boolean = false; private _patrolTile: TileRef | undefined; private _level: number = 1; + private _stackCount: number = 1; // Number of stacked instances (for stackable structures) + private _launchesRemaining: number | null = null; // For stacked silos: remaining launches before cooldown private _bonusMaxHealth: number = 0; // Extra max health from upgrades (e.g. city upgrades) private _targetable: boolean = true; private _accumulatedRegen: number = 0; @@ -100,13 +102,6 @@ export class UnitImpl implements Unit { "sourceAirfield" in params ? (params.sourceAirfield ?? undefined) : undefined; - // TEMPORARILY DISABLED: Structure insurance - // if ( - // isStructureType(this._type) && - // this._owner.hasUpgrade(UpgradeType.StructureInsurance) - // ) { - // this._insuredBy = this._owner; - // } switch (this._type) { case UnitType.Warship: @@ -176,6 +171,11 @@ export class UnitImpl implements Unit { health: this.hasHealth() ? Number(this._health) : undefined, maxHealth: this.hasHealth() ? this.effectiveMaxHealth() : undefined, level: this._level > 1 ? this._level : undefined, + stackCount: this._stackCount > 1 ? this._stackCount : undefined, + launchesRemaining: + this._type === UnitType.MissileSilo && this._launchesRemaining !== null + ? this._launchesRemaining + : undefined, constructionType: this._constructionType, constructionTargetLevel: this._type === UnitType.Construction && @@ -292,6 +292,16 @@ export class UnitImpl implements Unit { return this._level; } + stackCount(): number { + return this._stackCount; + } + + setStackCount(count: number): void { + const cap = maxStackCount(this._type); + this._stackCount = Math.max(1, Math.min(cap, count)); + this.mg.addUpdate(this.toUpdate()); + } + // Port-specific accessor/mutator for scheduled trade ship construction (single legacy) setPendingTradeShipDueTick(due: Tick | null): void { if (this._pendingTradeShipDueTick !== due) { @@ -337,11 +347,12 @@ export class UnitImpl implements Unit { return; } case UnitType.MissileSilo: { - // Cap silo upgrades at level 3 - if (this._level >= 3) { - return; - } + // No cap for silo stacking this._level += 1; + // Reset launches remaining to allow more launches + if (this._launchesRemaining !== null) { + this._launchesRemaining += 1; + } this._bonusMaxHealth += 250; const healed = Number(this._health) + 250; const capped = Math.min(healed, this.effectiveMaxHealth()); @@ -352,10 +363,6 @@ export class UnitImpl implements Unit { return; } case UnitType.SAMLauncher: { - // Cap SAM upgrades at level 3 - if (this._level >= 3) { - return; - } this._level += 1; // Small durability boost per upgrade, aligned with MissileSilo behavior this._bonusMaxHealth += 250; @@ -439,23 +446,6 @@ export class UnitImpl implements Unit { } setOwner(newOwner: PlayerImpl): void { - if (this._insuredBy) { - const baseCost = this.info().cost(this._insuredBy); - if (baseCost > 0n) { - const num = BigInt(this.mg.config().structureInsuranceRefundNum()); - const den = BigInt(this.mg.config().structureInsuranceRefundDen()); - const refundAmount = (baseCost * num) / den; - this._insuredBy.addGold(refundAmount); - this.mg.displayMessage( - "messages.insurance_refund_conquest", - MessageType.INSURANCE_REFUND, - this._insuredBy.id(), - refundAmount, - { amount: renderNumber(refundAmount) }, - ); - } - } - this._insuredBy = null; switch (this._type) { case UnitType.Warship: case UnitType.FighterJet: @@ -525,23 +515,6 @@ export class UnitImpl implements Unit { if (!this.isActive()) { throw new Error(`cannot delete ${this} not active`); } - if (this._insuredBy) { - const baseCost = this.info().cost(this._insuredBy); - if (baseCost > 0n) { - const num = BigInt(this.mg.config().structureInsuranceRefundNum()); - const den = BigInt(this.mg.config().structureInsuranceRefundDen()); - const refundAmount = (baseCost * num) / den; - this._insuredBy.addGold(refundAmount); - this.mg.displayMessage( - "messages.insurance_refund", - MessageType.INSURANCE_REFUND, - this._insuredBy.id(), - refundAmount, - { amount: renderNumber(refundAmount) }, - ); - } - } - this._insuredBy = null; this._owner._units = this._owner._units.filter((b) => b !== this); this._active = false; this.mg.addUpdate(this.toUpdate()); @@ -648,17 +621,39 @@ export class UnitImpl implements Unit { } launch(duration?: Tick): void { + // For stacked missile silos and SAMs: allow multiple launches before cooldown + if ( + (this.type() === UnitType.MissileSilo || + this.type() === UnitType.SAMLauncher) && + this._stackCount > 1 + ) { + // Initialize launches remaining on first launch + if (this._launchesRemaining === null) { + this._launchesRemaining = this._stackCount - 1; // First launch uses one + this.mg.addUpdate(this.toUpdate()); + return; // Don't start cooldown yet + } + // If we have remaining launches, use one + if (this._launchesRemaining > 0) { + this._launchesRemaining--; + this.mg.addUpdate(this.toUpdate()); + if (this._launchesRemaining > 0) { + return; // Still have more launches, don't start cooldown + } + // Fall through to start cooldown when all launches used + } + // Reset launches for next cycle + this._launchesRemaining = null; + } + this._cooldownStartTick = this.mg.ticks(); if (duration !== undefined) { this._cooldownDuration = duration; } else { // Choose default by unit type if (this.type() === UnitType.MissileSilo) { - // Reduce cooldown by 20% per upgrade level beyond 1: L1=100%, L2=80%, L3=60% - const base = this.mg.config().SiloCooldown(); - const levelsAboveOne = Math.max(0, this._level - 1); - const multiplier = Math.max(0, 1 - 0.2 * levelsAboveOne); - this._cooldownDuration = Math.floor(base * multiplier); + // Use base cooldown - stacking doesn't affect cooldown duration + this._cooldownDuration = this.mg.config().SiloCooldown(); } else if (this.type() === UnitType.SAMLauncher) { this._cooldownDuration = this.mg.config().SAMNukeCooldown(); } else if (this.type() === UnitType.City) { @@ -684,7 +679,7 @@ export class UnitImpl implements Unit { ) { if (this.hasHealth()) { const healthPercentage = - Number(this.health()) / (this.info().maxHealth ?? 1); + Number(this.health()) / this.effectiveMaxHealth(); if (healthPercentage > 0) { cooldownDuration /= healthPercentage; } diff --git a/src/core/game/Upgradeables.ts b/src/core/game/Upgradeables.ts index 9d4891a08..3cd5994ce 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,40 @@ 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); } +const MAX_STACK_COUNT = 25; + +// Maximum TECH upgrade level for structures (SAM, Airfield) +// This is NOT the stack count - it's the quality tier from research. +export function maxStructureTechLevel(type: UnitType): number { + if (type === UnitType.SAMLauncher) return 3; + if (type === UnitType.Airfield) return 3; // Based on bomber level + return 1; +} + +// Maximum stack count for stackable structures +export function maxStackCount(type: UnitType): number { + return isStackableStructure(type) ? MAX_STACK_COUNT : 1; +} + +// Legacy function - returns max stack count (25 for all stackable structures) export function maxStructureLevel(type: UnitType): number { - if (type === UnitType.MissileSilo || type === UnitType.SAMLauncher) { - return 3; - } - return isUpgradeableStructure(type) ? 99 : 1; + return isStackableStructure(type) ? MAX_STACK_COUNT : 1; } // Return maximum upgrade level for upgradeable combat units. @@ -109,24 +141,45 @@ export function playerMaxUnitLevel(player: HasUpgrade, type: UnitType): number { return globalMax; } -// Return maximum upgrade level for a structure based on player's researched techs. -// For SAMLauncher: Surface-to-Air Missiles = level 1, Radar-Guided SAMs = level 2, -// Strategic SAM Systems = level 3. +// Return maximum level for a structure based on stacking capability. +// All stackable structures (including SAM, Airfield, MissileSilo) can stack up to 25. +// Note: SAM and Airfield have separate tech upgrades (SAMLevel1-3, BomberLevel1-3) +// that affect the quality/stats, but stacking is independent. export function playerMaxStructureLevel( - player: HasUpgrade, + _player: HasUpgrade, type: UnitType, ): number { - const globalMax = maxStructureLevel(type); + // All stackable structures can go up to 25 stacks + if (isUpgradeableStructure(type)) { + return MAX_STACK_COUNT; + } + // Non-stackable structures have max level 1 + return 1; +} + +// Return the maximum TECH level for a structure based on player's researched techs. +// For SAMLauncher: 1-3 based on SAM upgrades. +// For Airfield: 1-3 based on bomber upgrades. +// This is for quality/stats upgrades, NOT stacking. +export function playerMaxStructureTechLevel( + player: HasUpgrade, + type: UnitType, +): number { if (type === UnitType.SAMLauncher) { - if (player.hasUpgrade(UpgradeType.SAMLevel3)) return Math.min(3, globalMax); - if (player.hasUpgrade(UpgradeType.SAMLevel2)) return Math.min(2, globalMax); - // SAM Level 1 is available by default at game start - return Math.min(1, globalMax); + if (player.hasUpgrade(UpgradeType.SAMLevel3)) return 3; + if (player.hasUpgrade(UpgradeType.SAMLevel2)) return 2; + return 1; } - // For other structures, return global max - return globalMax; + if (type === UnitType.Airfield) { + if (player.hasUpgrade(UpgradeType.BomberLevel3)) return 3; + if (player.hasUpgrade(UpgradeType.BomberLevel2)) return 2; + return 1; + } + + // Non-tech-upgradeable structures always have tech level 1 + return 1; } // Resolve a UnitType value from a stored string value (String(UnitType.X)) @@ -142,9 +195,14 @@ export function tryParseUnitType(value: string): UnitType | null { export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean { switch (type) { case UnitType.Warship: - case UnitType.Submarine: - // Warship and Submarine Level 1 are available by default at game start + // Warship Level 1 is available by default at game start return true; + case UnitType.Submarine: + // Diesel Sub unlocks with Sea Level 1 (Submarine research) + return ( + player.hasUpgrade(UpgradeType.SubmarineResearch) || + player.hasUpgrade(UpgradeType.SubmarineLevel1) + ); case UnitType.Airfield: case UnitType.FighterJet: case UnitType.Bomber: @@ -167,7 +225,8 @@ export function isUnitAvailable(player: HasUpgrade, type: UnitType): boolean { case UnitType.Hospital: return player.hasUpgrade(UpgradeType.HospitalResearch); case UnitType.ResearchLab: - return player.hasUpgrade(UpgradeType.ResearchLabResearch); + // Research Lab is available without a tech gate + return true; default: return true; } diff --git a/src/core/tech/PolicyDirectives.ts b/src/core/tech/PolicyDirectives.ts deleted file mode 100644 index 6eb2ac427..000000000 --- a/src/core/tech/PolicyDirectives.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Policy Directives are optional player choices that unlock when certain techs are researched. - * Each directive offers a choice between two or more policy options, each with distinct effects. - */ - -import { RESEARCH_TECH_IDS } from "./TechIds"; - -// Policy directive identifiers -export const POLICY_DIRECTIVE_IDS = { - NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "policy_research_industrial", - TRADE_POLICY_FRAMEWORK: "policy_trade_policy", - DIGITAL_ADMINISTRATION_SYSTEMS: "policy_digital_administration", - MECHANIZED_WARFARE_DOCTRINE: "policy_mechanized_warfare", - NIGHT_VISION_THERMAL_C3I: "policy_night_vision_thermal", -} as const; - -export type PolicyDirectiveId = - (typeof POLICY_DIRECTIVE_IDS)[keyof typeof POLICY_DIRECTIVE_IDS]; - -// Option identifiers within a directive -export type PolicyOptionId = string; - -export interface PolicyOption { - id: PolicyOptionId; - name: string; - description: string; - effects: PolicyEffects; -} - -export interface PolicyEffects { - // Multiplier for construction speed (e.g., 1.03 = +3% faster) - constructionSpeedMul?: number; - // Multiplier for trade income from roads and trade ships (e.g., 1.05 = +5%) - tradeIncomeMul?: number; - // Multiplier for trade ship income specifically (stacks with tradeIncomeMul) - tradeShipIncomeMul?: number; - // Multiplier for domestic income (non-trade income from population/industry) - domesticIncomeMul?: number; - // If true, grants the InternationalTrade upgrade (enables international road/sea trade) - grantsInternationalTrade?: boolean; - // Multiplier for road effects (e.g., 1.2 = +20% stronger road bonuses) - roadEffectMul?: number; - // Multiplier for infrastructure spending effectiveness (e.g., 1.2 = +20% more roads per gold) - infrastructureSpendingEffectivenessMul?: number; - // Multiplier for research spending effectiveness (e.g., 1.3 = +30% research effectiveness) - researchEffectivenessMul?: number; - // Multiplier for attack speed (e.g., 1.1 = +10% faster offensive speed) - attackSpeedMul?: number; - // Multiplier for attacker losses when attacking (e.g., 0.9 = -10% losses) - attackerLossMul?: number; - // Multiplier for defender losses when defending (e.g., 0.9 = -10% losses) - defenderLossMul?: number; - // Multiplier for enemy (defender) losses when you attack (e.g., 1.1 = +10% enemy losses) - enemyLossMulOnAttack?: number; - // Multiplier for attacker (enemy) losses when you defend (e.g., 1.1 = +10% enemy losses when they attack you) - attackerLossMulOnDefense?: number; - // Multiplier for maintenance cost reduction (e.g., 0.90 = -10% maintenance) - // TODO: Commented out until maintenance is implemented - // maintenanceCostMul?: number; -} - -export interface PolicyDirective { - id: PolicyDirectiveId; - name: string; - description: string; - // Tech that must be researched to unlock this directive - unlockedByTech: string; - // Available options to choose from - options: PolicyOption[]; -} - -// Central registry of all policy directives -export const POLICY_DIRECTIVES: Readonly< - Record -> = Object.freeze({ - [POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: { - id: POLICY_DIRECTIVE_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - name: "National Research & Industrial Foundations", - description: - "Choose your nation's priority between industrial expansion and scientific institutions.", - unlockedByTech: RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - options: [ - { - id: "industrial_expansion", - name: "Industrial Expansion Priority", - description: "+5% domestic income, +20% construction speed", - effects: { - domesticIncomeMul: 1.05, - constructionSpeedMul: 1.2, - }, - }, - { - id: "scientific_institution", - name: "Scientific Institution Priority", - description: "+30% research spending effectiveness", - effects: { - researchEffectivenessMul: 1.3, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK]: { - id: POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - name: "Trade Policy Framework", - description: - "Choose your nation's approach to international commerce and trade relations.", - unlockedByTech: RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK, - options: [ - { - id: "open_trade", - name: "Open Trade Policy", - description: "+5% trade income, +5% income from owned trade ships", - effects: { - grantsInternationalTrade: true, - tradeIncomeMul: 1.05, - tradeShipIncomeMul: 1.05, - }, - }, - { - id: "autarky", - name: "Autarky Doctrine", - description: "Disables international trade, +20% domestic income", - effects: { - domesticIncomeMul: 1.2, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: { - id: POLICY_DIRECTIVE_IDS.DIGITAL_ADMINISTRATION_SYSTEMS, - name: "Digital Administration & Economic Coordination Systems", - description: - "Choose your nation's approach to digital administration and economic coordination.", - unlockedByTech: RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS, - options: [ - { - id: "market_optimization", - name: "Market Optimization Systems", - description: "+10% domestic income, -10% maintenance costs", - effects: { - domesticIncomeMul: 1.1, - // TODO: maintenanceCostMul: 0.90, // 10% reduction when maintenance is implemented - }, - }, - { - id: "central_planning", - name: "Central Planning Automation", - description: - "+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed", - effects: { - domesticIncomeMul: 1.05, - infrastructureSpendingEffectivenessMul: 1.2, - constructionSpeedMul: 1.1, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE]: { - id: POLICY_DIRECTIVE_IDS.MECHANIZED_WARFARE_DOCTRINE, - name: "Mechanized Warfare Doctrine", - description: - "Choose your tactical doctrine emphasis for mechanized operations.", - unlockedByTech: RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE, - options: [ - { - id: "mobile_infantry", - name: "Mobile Infantry Tactics", - description: - "-10% your losses when attacking, +10% enemy losses when they attack you", - effects: { - attackerLossMul: 0.9, - attackerLossMulOnDefense: 1.1, - }, - }, - { - id: "armored_breakthrough", - name: "Armored Breakthrough Doctrine", - description: - "+10% enemy losses when you attack, -10% your losses when defending", - effects: { - enemyLossMulOnAttack: 1.1, - defenderLossMul: 0.9, - }, - }, - ], - }, - [POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I]: { - id: POLICY_DIRECTIVE_IDS.NIGHT_VISION_THERMAL_C3I, - name: "Night Vision, Thermal Imaging & Digital C3I", - description: - "Choose your night combat doctrine with thermal imaging and digital command systems.", - unlockedByTech: RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I, - options: [ - { - id: "high_tempo_maneuver", - name: "High-Tempo Maneuver Warfare", - description: - "+10% enemy losses when you attack, -10% your losses when attacking", - effects: { - enemyLossMulOnAttack: 1.1, - attackerLossMul: 0.9, - }, - }, - { - id: "precision_defensive", - name: "Precision Defensive Fire Doctrine", - description: - "+10% enemy losses when they attack you, -10% your losses when defending", - effects: { - attackerLossMulOnDefense: 1.1, - defenderLossMul: 0.9, - }, - }, - ], - }, -}); - -/** - * Get all policy directives. - */ -export function getAllPolicyDirectives(): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES); -} - -/** - * Get a policy directive by ID. - */ -export function getPolicyDirective( - id: PolicyDirectiveId, -): PolicyDirective | undefined { - return POLICY_DIRECTIVES[id]; -} - -/** - * Get policy directives unlocked by a specific tech. - */ -export function getDirectivesUnlockedByTech(techId: string): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES).filter( - (d) => d.unlockedByTech === techId, - ); -} - -/** - * Get a specific option from a directive. - */ -export function getPolicyOption( - directiveId: PolicyDirectiveId, - optionId: PolicyOptionId, -): PolicyOption | undefined { - const directive = POLICY_DIRECTIVES[directiveId]; - return directive?.options.find((o) => o.id === optionId); -} - -/** - * Check if a player has unlocked a policy directive based on researched techs. - */ -export function isDirectiveUnlocked( - directiveId: PolicyDirectiveId, - hasResearchedTech: (techId: string) => boolean, -): boolean { - const directive = POLICY_DIRECTIVES[directiveId]; - if (!directive) return false; - return hasResearchedTech(directive.unlockedByTech); -} - -/** - * Get all directives that are unlocked based on researched techs. - */ -export function getUnlockedDirectives( - hasResearchedTech: (techId: string) => boolean, -): PolicyDirective[] { - return Object.values(POLICY_DIRECTIVES).filter((d) => - hasResearchedTech(d.unlockedByTech), - ); -} diff --git a/src/core/tech/ResearchTree.ts b/src/core/tech/ResearchTree.ts index f709d4002..b8e85c54b 100644 --- a/src/core/tech/ResearchTree.ts +++ b/src/core/tech/ResearchTree.ts @@ -1,4 +1,4 @@ -export type Category = "Land" | "Sea" | "Air" | "Nuclear" | "Economy"; +export type Category = "Land" | "Sea" | "Air" | "Nuclear"; /** * Core tech node for tree structure - metadata (name, description) is in TechEffects.ts @@ -28,7 +28,7 @@ const baseLevels: TechNode[] = (() => { return nodes; })(); -// Nuclear branch techs (explicit definitions) +// Nuclear branch techs (explicit definitions) - 4 levels const nuclearTechs: TechNode[] = [ { id: "Nuclear-1", category: "Nuclear", level: 1, cost: costForLevel(1) }, { @@ -45,6 +45,7 @@ const nuclearTechs: TechNode[] = [ requiresAllOf: ["Nuclear-2"], cost: costForLevel(3), }, + // Level 4 - TBD (placeholder for future tech) { id: "Nuclear-4", category: "Nuclear", @@ -54,11 +55,11 @@ const nuclearTechs: TechNode[] = [ }, ]; -// Sea branch techs (explicit definitions) - Simplified linear tree +// Sea branch techs (explicit definitions) - 4 levels const seaTechs: TechNode[] = [ - // Level 1 - Early Missile Navy (unlocks Warship L2, Sub L2) + // Level 1 - Missile Navy (unlocks Warship L2, Sub L2) { id: "Sea-1", category: "Sea", level: 1, cost: costForLevel(1) }, - // Level 2 - Submarine Silent Service Modernization (unlocks Sub L3) + // Level 2 - Advanced Fleet (unlocks Warship L3, Ship Anti-Air) { id: "Sea-2", category: "Sea", @@ -66,7 +67,7 @@ const seaTechs: TechNode[] = [ requiresAllOf: ["Sea-1"], cost: costForLevel(2), }, - // Level 3 - SSBN Programs (unlocks SSBNs) + // Level 3 - Nuclear Submarines (unlocks Subs can launch nukes) { id: "Sea-3", category: "Sea", @@ -74,7 +75,7 @@ const seaTechs: TechNode[] = [ requiresAllOf: ["Sea-2"], cost: costForLevel(3), }, - // Level 4 - Modern Fleet Sensor & SAM Integration (unlocks Warship L3, Ship SAM) + // Level 4 - TBD (placeholder for future tech) { id: "Sea-4", category: "Sea", @@ -84,11 +85,13 @@ const seaTechs: TechNode[] = [ }, ]; -// Land branch techs (explicit definitions) - Simplified linear tree +// Land branch techs (explicit definitions) - 4 levels +// Reordered: Land-1 now Roads & Hospitals (moved from former Economy), +// and existing Land techs shift up one level. const landTechs: TechNode[] = [ - // Level 1 - Post-WW2 Ground Forces Modernization (unlocks Military Academy, AA Guns) + // Level 1 - Roads & Hospitals (previously Economy-1) { id: "Land-1", category: "Land", level: 1, cost: costForLevel(1) }, - // Level 2 - Mechanized Warfare Doctrine (unlocks Scorched Earth, policy directive) + // Level 2 - Military Academy { id: "Land-2", category: "Land", @@ -96,7 +99,7 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-1"], cost: costForLevel(2), }, - // Level 3 - Air-Defense Grid Expansion (unlocks SAM Level 2) + // Level 3 - SAM Systems { id: "Land-3", category: "Land", @@ -104,7 +107,7 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-2"], cost: costForLevel(3), }, - // Level 4 - Integrated SAM & Battlefield Command Systems (unlocks SAM Level 3) + // Level 4 - Doomsday Device { id: "Land-4", category: "Land", @@ -112,21 +115,13 @@ const landTechs: TechNode[] = [ requiresAllOf: ["Land-3"], cost: costForLevel(4), }, - // Level 5 - Night Vision, Thermal Imaging & Digital C3I (policy directive) - { - id: "Land-5", - category: "Land", - level: 5, - requiresAllOf: ["Land-4"], - cost: costForLevel(5), - }, ]; -// Air branch techs (explicit definitions) - Simplified linear tree +// Air branch techs (explicit definitions) - 4 levels const airTechs: TechNode[] = [ - // Level 1 - Early Jet Aviation Framework (unlocks Paratroopers) + // Level 1 - Paratroopers (unlocks Paratroopers, Fighter L2) { id: "Air-1", category: "Air", level: 1, cost: costForLevel(1) }, - // Level 2 - Supersonic Airframe Development (unlocks Fighter L2, Bomber L2) + // Level 2 - Advanced Jets (unlocks Fighter L3, Bomber L2) { id: "Air-2", category: "Air", @@ -134,7 +129,7 @@ const airTechs: TechNode[] = [ requiresAllOf: ["Air-1"], cost: costForLevel(2), }, - // Level 3 - Pulse-Doppler Radar & BVR Combat (unlocks Fighter L3, Naval Strike) + // Level 3 - Naval Strike (unlocks Fighter L4, Bomber L3, Naval Strike) { id: "Air-3", category: "Air", @@ -142,7 +137,7 @@ const airTechs: TechNode[] = [ requiresAllOf: ["Air-2"], cost: costForLevel(3), }, - // Level 4 - Fly-By-Wire Platforms & Advanced Maneuverability (unlocks Fighter L4, Bomber L3) + // Level 4 - TBD (placeholder for future tech) { id: "Air-4", category: "Air", @@ -152,51 +147,12 @@ const airTechs: TechNode[] = [ }, ]; -// Economy branch techs (explicit definitions) - Linear 5-level tree -const economyTechs: TechNode[] = [ - // Level 1 - National Reconstruction Program (1950s): Roads, Hospitals, +20% infrastructure effectiveness, +20% road effects - { id: "Economy-1", category: "Economy", level: 1, cost: costForLevel(1) }, - // Level 2 - National Research & Industrial Foundations (1960s): Research Labs, policy directive - { - id: "Economy-2", - category: "Economy", - level: 2, - requiresAllOf: ["Economy-1"], - cost: costForLevel(2), - }, - // Level 3 - Trade Policy Framework (1970s): policy directive (Open Trade vs Autarky) - { - id: "Economy-3", - category: "Economy", - level: 3, - requiresAllOf: ["Economy-2"], - cost: costForLevel(3), - }, - // Level 4 - National Infrastructure Modernization (1980s): +20% infrastructure effectiveness, -20% maintenance, +10% construction speed - { - id: "Economy-4", - category: "Economy", - level: 4, - requiresAllOf: ["Economy-3"], - cost: costForLevel(4), - }, - // Level 5 - Digital Administration & Economic Coordination Systems (Early 1990s): policy directive - { - id: "Economy-5", - category: "Economy", - level: 5, - requiresAllOf: ["Economy-4"], - cost: costForLevel(5), - }, -]; - // Compose full tree const tree: TechNode[] = [ ...baseLevels, ...nuclearTechs, ...seaTechs, ...landTechs, - ...economyTechs, ...airTechs, ]; diff --git a/src/core/tech/TechEffects.ts b/src/core/tech/TechEffects.ts index 22b98ac69..966316f7b 100644 --- a/src/core/tech/TechEffects.ts +++ b/src/core/tech/TechEffects.ts @@ -1,16 +1,12 @@ import { CityAAExecution } from "../execution/CityAAExecution"; import { Game, Player, UpgradeType } from "../game/Game"; -import { - getAllPolicyDirectives, - getPolicyOption, - type PolicyDirectiveId, -} from "./PolicyDirectives"; import { RESEARCH_TECH_IDS } from "./TechIds"; // Re-export for backward compatibility with existing imports export { RESEARCH_TECH_IDS } from "./TechIds"; export interface TechMeta { name: string; + shortDescription?: string; description?: string; } @@ -91,65 +87,59 @@ export type TechDefinition = { // Unified registry containing both metadata and effects per tech export const TECHS: Readonly> = Object.freeze({ - // Sea techs - Level 1: Early Missile Navy - [RESEARCH_TECH_IDS.EARLY_MISSILE_NAVY]: { + // Sea techs - Level 1: Maritime Warfare + [RESEARCH_TECH_IDS.SEA_MISSILE_NAVY]: { meta: { - name: "Early Missile Navy", + name: "Maritime Warfare", + shortDescription: "Cruisers, Diesel-Electric Subs", description: - "Develop guided missile technology for naval warfare. Unlocks Warship Level 2, Submarine Level 2.", + "Develop naval warfare capabilities. Unlocks Cruisers (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395) and Diesel-Electric Submarines (1,000 health, 200-325 damage, stealth capabilities).", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.WarshipLevel2)) { player.addUpgrade?.(UpgradeType.WarshipLevel2); } - if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) { - player.addUpgrade?.(UpgradeType.SubmarineLevel2); + if (!player.hasUpgrade?.(UpgradeType.SubmarineResearch)) { + player.addUpgrade?.(UpgradeType.SubmarineResearch); } - }, - }, - }, - // Sea techs - Level 2: Submarine Silent Service Modernization - [RESEARCH_TECH_IDS.SUBMARINE_SILENT_SERVICE]: { - meta: { - name: "Submarine Silent Service Modernization", - description: - "Advanced quieting and acoustic stealth for submarines. Unlocks Submarine Level 3.", - }, - effects: { - onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) { - player.addUpgrade?.(UpgradeType.SubmarineLevel3); + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel1)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel1); } }, }, }, - // Sea techs - Level 3: SSBN Programs - [RESEARCH_TECH_IDS.SSBN_PROGRAMS]: { + // Sea techs - Level 2: Fleet Modernization + [RESEARCH_TECH_IDS.SEA_ADVANCED_FLEET]: { meta: { - name: "SSBN Programs", + name: "Fleet Modernization", + shortDescription: "Aegis, Tactical Subs", description: - "Ballistic missile submarine programs for strategic deterrence. Unlocks SSBNs (Submarines can launch nuclear weapons).", + "Advanced naval systems and fleet integration. Unlocks Aegis Warships (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Tactical Submarines (+25% health to 1,250, +35% minimum damage to 270, +21.5% maximum damage to 395).", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) { - player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch); + if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) { + player.addUpgrade?.(UpgradeType.WarshipLevel3); + } + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel2)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel2); } }, }, }, - // Sea techs - Level 4: Modern Fleet Sensor & SAM Integration - [RESEARCH_TECH_IDS.MODERN_FLEET_SENSOR_SAM]: { + // Sea techs - Level 3: Submarine Dominance + [RESEARCH_TECH_IDS.SEA_NUCLEAR_SUBMARINES]: { meta: { - name: "Modern Fleet Sensor & SAM Integration", + name: "Submarine Dominance", + shortDescription: "Attack Subs, Ship Anti-Air", description: - "Advanced sensor suites and integrated air defense systems for the fleet. Unlocks Warship Level 3, Ship SAM Systems.", + "Advanced submarine technology and fleet air defense. Unlocks Attack Submarines (+20% health to 1,500, +25.9% minimum damage to 340, +17.7% maximum damage to 465) and Ship Anti-Air Systems (allows warships to engage and destroy enemy aircraft within range).", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.WarshipLevel3)) { - player.addUpgrade?.(UpgradeType.WarshipLevel3); + if (!player.hasUpgrade?.(UpgradeType.SubmarineLevel3)) { + player.addUpgrade?.(UpgradeType.SubmarineLevel3); } if (!player.hasUpgrade?.(UpgradeType.WarshipAntiAir)) { player.addUpgrade?.(UpgradeType.WarshipAntiAir); @@ -157,213 +147,121 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - [RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION]: { + // Sea techs - Level 4: Strategic Deterrent + [RESEARCH_TECH_IDS.SEA_TBD_LEVEL4]: { meta: { - name: "Post-WW2 Ground Forces Modernization", + name: "Strategic Deterrent", + shortDescription: "Nuclear Sub", description: - "Doctrine refined by hard-won experience improves offensive capabilities and tactical efficiency. Effects: Enables Military Academy, AA Guns. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when you attack, -10% your losses when defending.", + "Ballistic missile submarine programs for strategic deterrence. Unlocks Nuclear Submarines (enables submarines to launch nuclear weapons while remaining submerged and undetected, providing second-strike capability).", }, effects: { - onComplete: (player, game) => { - if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { - player.addUpgrade?.(UpgradeType.MilitaryAcademy); - } - if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) { - player.addUpgrade?.(UpgradeType.CityAntiAir); - // Start the city AA execution to fire bullets at planes - game.addExecution(new CityAAExecution(player)); + onComplete: (player) => { + if (!player.hasUpgrade?.(UpgradeType.NuclearSubmarineResearch)) { + player.addUpgrade?.(UpgradeType.NuclearSubmarineResearch); } }, - attack: (mods) => { - mods.defenderLossMul *= 1.1; // enemy (defender) takes 10% more losses when we attack - }, - defense: (mods) => { - mods.defenderLossMul *= 0.9; // we take 10% less losses when defending - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, }, }, - [RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM]: { + // Land techs - Level 1: Road Network + [RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS]: { meta: { - name: "National Reconstruction Program", + name: "Road Network", + shortDescription: "Roads, Trade Routes", description: - "Revitalize infrastructure and industry by mobilizing civilian labor and resources to rebuild the national economy. Effects: Enables Roads, Hospitals. +20% infrastructure spending effectiveness, +20% stronger road effects.", + "Develop critical infrastructure to boost your economy and military mobility. Unlocks Roads (increases unit movement speed and generates passive trade income per connected tile) and Trade Routes (enables trade ships to establish international commerce routes, generating continuous gold income).", }, effects: { onComplete: (player, game) => { - // Unlock Roads upgrade and trigger reconnection if (!player.hasUpgrade?.(UpgradeType.Roads)) { player.addUpgrade?.(UpgradeType.Roads); game.markPlayerNodesForReconnection?.(player); } - if (player.hasUpgrade?.(UpgradeType.ScorchedEarth)) { - player.removeUpgrade?.(UpgradeType.ScorchedEarth); + if (!player.hasUpgrade?.(UpgradeType.InternationalTrade)) { + player.addUpgrade?.(UpgradeType.InternationalTrade); } - // Unlock Hospitals - if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) { - player.addUpgrade?.(UpgradeType.HospitalResearch); - } - }, - infrastructureEffectiveness: (mods) => { - mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness - }, - roadEffect: (mods) => { - mods.effectMul *= 1.2; // +20% stronger road effects }, }, }, - // Economy Level 2 tech - National Research & Industrial Foundations (1960s) - [RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS]: { + // Land techs - Level 2: Ground Air Defense + [RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY]: { meta: { - name: "National Research & Industrial Foundations", + name: "Ground Air Defense", + shortDescription: "City Anti-Air, Improved SAM", description: - "Establish national research institutions and industrial base. Effects: Enables Research Labs. Policy Directive: Industrial Expansion Priority (+5% domestic income, +20% construction speed) or Scientific Institution Priority (+30% research spending effectiveness).", + "Establish comprehensive air defense capabilities 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) => { - if (!player.hasUpgrade?.(UpgradeType.ResearchLabResearch)) { - player.addUpgrade?.(UpgradeType.ResearchLabResearch); + onComplete: (player, game) => { + if (!player.hasUpgrade?.(UpgradeType.CityAntiAir)) { + player.addUpgrade?.(UpgradeType.CityAntiAir); + // Start the city AA execution to fire bullets at planes + game.addExecution(new CityAAExecution(player)); } - }, - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Economy Level 3 tech - Trade Policy Framework (1970s) - [RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK]: { - meta: { - name: "Trade Policy Framework", - description: - "Establish trade agreements and commercial policies. Policy Directive: Open Trade Policy (+5% trade income, +5% trade ship income) or Autarky Doctrine (disables international trade, +20% domestic income).", - }, - effects: { - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Economy Level 4 tech - National Infrastructure Modernization (1980s) - [RESEARCH_TECH_IDS.NATIONAL_INFRASTRUCTURE_MODERNIZATION]: { - meta: { - name: "National Infrastructure Modernization", - description: - "Modernize national infrastructure with advanced technology. Effects: +20% infrastructure spending effectiveness, -20% maintenance costs, +10% construction speed.", - }, - effects: { - infrastructureEffectiveness: (mods) => { - mods.effectivenessMul *= 1.2; // +20% infrastructure spending effectiveness - }, - constructionSpeed: (mods) => { - mods.speedMul *= 1.1; // +10% construction speed - }, - // TODO: -20% maintenance costs when maintenance is implemented - }, - }, - // Economy Level 5 tech - Digital Administration & Economic Coordination Systems (Early 1990s) - [RESEARCH_TECH_IDS.DIGITAL_ADMINISTRATION_SYSTEMS]: { - meta: { - name: "Digital Administration & Economic Coordination Systems", - description: - "Digital systems for administration and economic coordination. Policy Directive: Market Optimization Systems (+10% domestic income, -10% maintenance costs) or Central Planning Automation (+5% domestic income, +20% infrastructure spending effectiveness, +10% construction speed).", - }, - effects: { - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Land Level 2 tech - Mechanized Warfare Doctrine (1960s) - [RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE]: { - meta: { - name: "Mechanized Warfare Doctrine", - description: - "Develop doctrine for mechanized infantry and armored operations. Effects: Unlocks Scorched Earth. +5% offensive speed. Policy Directive (20%): Mobile Infantry Tactics (-10% your losses attacking, +10% enemy losses when they attack you) or Armored Breakthrough Doctrine (+10% enemy losses when you attack, -10% your losses when defending).", - }, - effects: { - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, - // Policy directive effects are applied via getPolicyChoice - }, - }, - // Land Level 3 tech - Air-Defense Grid Expansion (1970s) - [RESEARCH_TECH_IDS.AIR_DEFENSE_GRID_EXPANSION]: { - meta: { - name: "Air-Defense Grid Expansion", - description: - "Expand air defense networks with improved SAM coverage. Effects: Enables SAM Level 2. +5% offensive speed. Casualty Effects (20%): +15% enemy losses when they attack you, -5% your losses when defending.", - }, - effects: { - onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.SAMLevel2)) { player.addUpgrade?.(UpgradeType.SAMLevel2); } }, - defense: (mods) => { - mods.attackerLossMul *= 1.15; // enemy takes 15% more losses when they attack us - mods.defenderLossMul *= 0.95; // we take 5% less losses when defending - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed - }, }, }, - // Land Level 4 tech - Integrated SAM & Battlefield Command Systems (1980s) - [RESEARCH_TECH_IDS.INTEGRATED_SAM_BATTLEFIELD_COMMAND]: { + // Land techs - Level 3: Modern Air Defense + [RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS]: { meta: { - name: "Integrated SAM & Battlefield Command Systems", + name: "Modern Air Defense", + shortDescription: "Advanced SAM, Hospitals", description: - "Integrate SA-10, Patriot-era SAM platforms with C3I systems. Effects: Enables SAM Level 3. +5% offensive speed. Casualty Effects (20%): +10% enemy losses when they attack you, -10% your losses when attacking.", + "Achieve peak defensive and medical capabilities. Unlocks Advanced SAM (+82.25% range to 127.6 pixels 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) => { if (!player.hasUpgrade?.(UpgradeType.SAMLevel3)) { player.addUpgrade?.(UpgradeType.SAMLevel3); } - }, - defense: (mods) => { - mods.attackerLossMul *= 1.1; // enemy takes 10% more losses when they attack us - }, - attack: (mods) => { - mods.attackerLossMul *= 0.9; // we take 10% less losses when attacking - }, - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed + if (!player.hasUpgrade?.(UpgradeType.HospitalResearch)) { + player.addUpgrade?.(UpgradeType.HospitalResearch); + } }, }, }, - // Land Level 5 tech - Night Vision, Thermal Imaging & Digital C3I (Early 1990s) - [RESEARCH_TECH_IDS.NIGHT_VISION_THERMAL_C3I]: { + // Land techs - Level 4: Military Academy + [RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE]: { meta: { - name: "Night Vision, Thermal Imaging & Digital C3I", + name: "Military Academy", + shortDescription: "Academy, casualty bonus", description: - "Equip forces with night vision, thermal imaging, and digital command systems for 24-hour combat capability. Effects: +5% offensive speed. Policy Directive (20%): High-Tempo Maneuver Warfare (+10% enemy losses when you attack, -10% your losses when attacking) or Precision Defensive Fire Doctrine (+10% enemy losses when they attack you, -10% your losses when defending).", + "Establish elite military training infrastructure. Unlocks 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: { - attackSpeed: (mods) => { - mods.speedMul *= 1.05; // 5% faster offensive speed + onComplete: (player) => { + if (!player.hasUpgrade?.(UpgradeType.MilitaryAcademy)) { + player.addUpgrade?.(UpgradeType.MilitaryAcademy); + } }, - // Policy directive effects are applied via getPolicyChoice }, }, - // Air techs - Level 1: Early Jet Aviation Framework - [RESEARCH_TECH_IDS.EARLY_JET_AVIATION_FRAMEWORK]: { + // Air techs - Level 1: Early Air Power + [RESEARCH_TECH_IDS.AIR_PARATROOPERS]: { meta: { - name: "Early Jet Aviation Framework", + name: "Early Air Power", + shortDescription: "Gen 1 Fighters, Paratroopers", description: - "Establish jet aviation infrastructure and doctrine. Unlocks Paratroopers.", + "Develop airborne warfare capabilities. Unlocks Jet Engines enabling 1st Generation Fighters (750 health, 200-325 damage, engages enemy aircraft) and Paratroopers (airborne infantry units that can be deployed behind enemy lines for rapid territorial expansion).", }, effects: { onComplete: (player) => { - if (!player.hasUpgrade?.(UpgradeType.AirUpgrade1)) { - player.addUpgrade?.(UpgradeType.AirUpgrade1); + if (!player.hasUpgrade?.(UpgradeType.JetEngines)) { + player.addUpgrade?.(UpgradeType.JetEngines); } }, }, }, - // Air techs - Level 2: Supersonic Airframe Development - [RESEARCH_TECH_IDS.SUPERSONIC_AIRFRAME_DEVELOPMENT]: { + // Air techs - Level 2: Jet Technology + [RESEARCH_TECH_IDS.AIR_ADVANCED_JETS]: { meta: { - name: "Supersonic Airframe Development", + name: "Jet Technology", + shortDescription: "Gen 2 Fighters, Heavy Bombers", description: - "Develop supersonic aircraft designs. Unlocks Fighter Level 2, Bomber Level 2.", + "Advance to next-generation aircraft systems. Unlocks 2nd Generation Fighters (+33.3% health to 1,000, +50% minimum damage to 300, +30.8% maximum damage to 425) and Heavy Bombers (+20% health to 600, +20% damage to 300, +40% range to 350, +50% speed to 3).", }, effects: { onComplete: (player) => { @@ -376,12 +274,13 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Air techs - Level 3: Pulse-Doppler Radar & BVR Combat - [RESEARCH_TECH_IDS.PULSE_DOPPLER_RADAR_BVR]: { + // Air techs - Level 3: Anti-Ship Warfare + [RESEARCH_TECH_IDS.AIR_NAVAL_STRIKE]: { meta: { - name: "Pulse-Doppler Radar & BVR Combat", + name: "Anti-Ship Warfare", + shortDescription: "Gen 3 Fighters, Anti-ship", description: - "Advanced radar and beyond-visual-range combat systems. Unlocks Fighter Level 3, Naval Strike Capability.", + "Develop advanced anti-ship capabilities for air superiority. Unlocks 3rd Generation Fighters (+25% health to 1,250, +33.3% minimum damage to 400, +23.5% maximum damage to 525) and Naval Strike Weapons (enables fighters to target and attack warships, transport ships, and trade ships).", }, effects: { onComplete: (player) => { @@ -394,12 +293,13 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - // Air techs - Level 4: Fly-By-Wire Platforms & Advanced Maneuverability - [RESEARCH_TECH_IDS.FLY_BY_WIRE_PLATFORMS]: { + // Air techs - Level 4: TBD + [RESEARCH_TECH_IDS.AIR_TBD_LEVEL4]: { meta: { - name: "Fly-By-Wire Platforms & Advanced Maneuverability", + name: "Advanced Fighters", + shortDescription: "Gen 4 Fighters, Supersonic Bombers", description: - "Digital flight control systems for maximum aircraft performance. Unlocks Fighter Level 4, Bomber Level 3.", + "Master cutting-edge aerospace technology. Unlocks 4th Generation Fighters (+20% health to 1,500, +25% minimum damage to 500, +19% maximum damage to 625) and Supersonic Bombers (+16.7% health to 700, +16.7% damage to 350, +28.6% range to 450, +33.3% speed to 4).", }, effects: { onComplete: (player) => { @@ -412,23 +312,30 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, + // Nuclear techs - Level 1: Atomic Weapons [RESEARCH_TECH_IDS.NUCLEAR_FISSION]: { meta: { - name: "Nuclear Fission", - description: "Enables: Atom Bomb", + name: "Atomic Weapons", + shortDescription: "Atom Bomb, Silo", + description: + "Harness nuclear fission technology. Unlocks Atom Bomb (basic nuclear weapon with large blast radius causing massive area damage) and Missile Silo (required launch facility for deploying nuclear weapons against enemy targets).", }, effects: { onComplete: (player) => { if (!player.hasUpgrade?.(UpgradeType.NuclearFission)) { player.addUpgrade?.(UpgradeType.NuclearFission); } + // Note: MissileSilo building is unlocked via gameplay progression }, }, }, + // Nuclear techs - Level 2: Thermonuclear Weapons [RESEARCH_TECH_IDS.THERMONUCLEAR_STAGING]: { meta: { - name: "Thermonuclear Staging", - description: "Enables: Hydrogen Bomb", + name: "Thermonuclear Weapons", + shortDescription: "Hydrogen Bomb", + description: + "Advance to fusion-based thermonuclear weapons. Unlocks Hydrogen Bomb (high-yield nuclear weapon with significantly larger blast radius than atom bombs, capable of devastating multi-tile areas and causing catastrophic damage to enemy infrastructure).", }, effects: { onComplete: (player) => { @@ -438,10 +345,13 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, + // Nuclear techs - Level 3: MIRV Warheads [RESEARCH_TECH_IDS.MIRV_TECHNOLOGY]: { meta: { - name: "MIRV Technology", - description: "Enables: MIRV", + name: "MIRV Warheads", + shortDescription: "MIRV", + description: + "Develop Multiple Independent Reentry Vehicle technology. Unlocks MIRV (advanced nuclear missiles deploying multiple independently targetable warheads from a single missile, significantly harder for enemy SAM systems to intercept, ensuring delivery of nuclear payload).", }, effects: { onComplete: (player) => { @@ -451,10 +361,13 @@ export const TECHS: Readonly> = Object.freeze({ }, }, }, - [RESEARCH_TECH_IDS.DOOMSDAY_DEVICE]: { + // Nuclear techs - Level 4: TBD + [RESEARCH_TECH_IDS.NUCLEAR_TBD_LEVEL4]: { meta: { name: "Doomsday Device", - description: "Enables: Doomsday Device", + shortDescription: "Global deterrence", + description: + "Construct the ultimate deterrent. Unlocks Doomsday Device. When any of your tiles are hit by a nuclear detonation, the device auto-triggers: it consumes itself, plays a global alert, and unleashes an expanding fallout wave across every land tile. The wave instantly destroys all bombers, fighters, warships, and trade ships; damages remaining structures by 80% of current health; relinquishes claimed land; and seeds widespread fallout (noise-pattern coverage) world-wide.", }, effects: { onComplete: (player) => { @@ -531,7 +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, @@ -542,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; } @@ -569,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, @@ -580,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; } @@ -605,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, @@ -615,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; } @@ -637,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, @@ -648,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; } @@ -689,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, @@ -700,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; } @@ -722,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, @@ -732,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; } @@ -756,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, @@ -767,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; } @@ -792,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, @@ -803,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 2835309cf..f9f1be8aa 100644 --- a/src/core/tech/TechIds.ts +++ b/src/core/tech/TechIds.ts @@ -1,49 +1,47 @@ /** * Central tech IDs for research tree items. * This file has NO dependencies to prevent circular imports. - * Keep IDs aligned with ResearchTreeModal generation (e.g., "Land-1"). + * Keep IDs aligned with ResearchTree definitions (e.g., "Land-1"). */ export const RESEARCH_TECH_IDS = { - // Air techs - Level 1 + // Air techs + AIR_PARATROOPERS: "Air-1", + AIR_ADVANCED_JETS: "Air-2", + AIR_NAVAL_STRIKE: "Air-3", + AIR_TBD_LEVEL4: "Air-4", + // Sea techs + SEA_MISSILE_NAVY: "Sea-1", + SEA_ADVANCED_FLEET: "Sea-2", + SEA_NUCLEAR_SUBMARINES: "Sea-3", + SEA_TBD_LEVEL4: "Sea-4", + // Land techs + LAND_ROADS_HOSPITALS: "Land-1", + LAND_MILITARY_ACADEMY: "Land-2", + LAND_SAM_SYSTEMS: "Land-3", + LAND_DOOMSDAY_DEVICE: "Land-4", + // Economy techs (legacy; category removed, kept for backwards compatibility) + ECONOMY_ROADS_HOSPITALS: "Land-1", + // Nuclear techs + NUCLEAR_FISSION: "Nuclear-1", + THERMONUCLEAR_STAGING: "Nuclear-2", + MIRV_TECHNOLOGY: "Nuclear-3", + NUCLEAR_TBD_LEVEL4: "Nuclear-4", + + // Legacy mappings for backwards compatibility during migration EARLY_JET_AVIATION_FRAMEWORK: "Air-1", - // Air techs - Level 2 SUPERSONIC_AIRFRAME_DEVELOPMENT: "Air-2", - // Air techs - Level 3 PULSE_DOPPLER_RADAR_BVR: "Air-3", - // Air techs - Level 4 FLY_BY_WIRE_PLATFORMS: "Air-4", - // Sea techs - Level 1 EARLY_MISSILE_NAVY: "Sea-1", - // Sea techs - Level 2 SUBMARINE_SILENT_SERVICE: "Sea-2", - // Sea techs - Level 3 SSBN_PROGRAMS: "Sea-3", - // Sea techs - Level 4 MODERN_FLEET_SENSOR_SAM: "Sea-4", - // Land techs - Level 1 POST_WW2_GROUND_FORCES_MODERNIZATION: "Land-1", - // Land techs - Level 2 MECHANIZED_WARFARE_DOCTRINE: "Land-2", - // Land techs - Level 3 AIR_DEFENSE_GRID_EXPANSION: "Land-3", - // Land techs - Level 4 INTEGRATED_SAM_BATTLEFIELD_COMMAND: "Land-4", - // Land techs - Level 5 NIGHT_VISION_THERMAL_C3I: "Land-5", - // Economy techs - Level 1 (1950s) NATIONAL_RECONSTRUCTION_PROGRAM: "Economy-1", - // Economy techs - Level 2 (1960s) - NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS: "Economy-2", - // Economy techs - Level 3 (1970s) - TRADE_POLICY_FRAMEWORK: "Economy-3", - // Economy techs - Level 4 (1980s) - NATIONAL_INFRASTRUCTURE_MODERNIZATION: "Economy-4", - // Economy techs - Level 5 (Early 1990s) - DIGITAL_ADMINISTRATION_SYSTEMS: "Economy-5", - // Nuclear techs - NUCLEAR_FISSION: "Nuclear-1", - THERMONUCLEAR_STAGING: "Nuclear-2", - MIRV_TECHNOLOGY: "Nuclear-3", DOOMSDAY_DEVICE: "Nuclear-4", } as const; diff --git a/tests/core/execution/ResearchPriorityAllocation.test.ts b/tests/core/execution/ResearchPriorityAllocation.test.ts index 7d8152868..8da5ba4d8 100644 --- a/tests/core/execution/ResearchPriorityAllocation.test.ts +++ b/tests/core/execution/ResearchPriorityAllocation.test.ts @@ -44,7 +44,7 @@ describe("Research Priority Allocation", () => { }); describe("buildMissingPrereqPath logic validation", () => { - it("should identify all prerequisites for Economy-4 when nothing is researched", () => { + it("should identify all prerequisites for Land-4 when nothing is researched", () => { // Simulate the buildMissingPrereqPath logic const nodes = getTechNodes(); const researched = new Set(); @@ -90,19 +90,19 @@ describe("Research Priority Allocation", () => { return path; }; - // Test: When setting priority to Economy-4, it should identify Economy-1, Economy-2, Economy-3 as prerequisites - const pathSet = buildMissingPrereqPath("Economy-4"); + // Test: When setting priority to Land-4, it should identify Land-1, Land-2, Land-3 as prerequisites + const pathSet = buildMissingPrereqPath("Land-4"); - expect(pathSet.has("Economy-1")).toBe(true); - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); - expect(pathSet.has("Economy-4")).toBe(false); // Target itself should not be in path + expect(pathSet.has("Land-1")).toBe(true); + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); + expect(pathSet.has("Land-4")).toBe(false); // Target itself should not be in path expect(pathSet.size).toBe(3); }); - it("should identify correct frontier when Economy-1 is already researched", () => { + it("should identify correct frontier when Land-1 is already researched", () => { const nodes = getTechNodes(); - const researched = new Set(["Economy-1"]); // Already researched Economy-1 + const researched = new Set(["Land-1"]); // Already researched Land-1 const byId = new Map(nodes.map((n) => [n.id, n] as const)); const sameCat = (a: string, b: string) => (byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? ""); @@ -145,12 +145,12 @@ describe("Research Priority Allocation", () => { return path; }; - // Test: When Economy-1 is researched and priority is Economy-4, path should only include Economy-2, Economy-3 - const pathSet = buildMissingPrereqPath("Economy-4"); + // Test: When Land-1 is researched and priority is Land-4, path should only include Land-2, Land-3 + const pathSet = buildMissingPrereqPath("Land-4"); - expect(pathSet.has("Economy-1")).toBe(false); // Already researched - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); + expect(pathSet.has("Land-1")).toBe(false); // Already researched + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); expect(pathSet.size).toBe(2); }); @@ -206,21 +206,20 @@ describe("Research Priority Allocation", () => { // Only level-1 techs should be available when nothing is researched const availableIds = available.map((n) => n.id); - expect(availableIds).toContain("Economy-1"); expect(availableIds).toContain("Land-1"); expect(availableIds).toContain("Sea-1"); expect(availableIds).toContain("Air-1"); expect(availableIds).toContain("Nuclear-1"); - // Set priority to Economy-4 - const pathSet = buildMissingPrereqPath("Economy-4"); + // Set priority to Land-4 + const pathSet = buildMissingPrereqPath("Land-4"); // Frontier = intersection of pathSet and available const frontier = available.filter((n) => pathSet.has(n.id)); - // Only Economy-1 should be in the frontier (the only available prereq) + // Only Land-1 should be in the frontier (the only available prereq) expect(frontier.length).toBe(1); - expect(frontier[0].id).toBe("Economy-1"); + expect(frontier[0].id).toBe("Land-1"); }); it("should prioritize frontier techs when priority target is not directly available", () => { @@ -268,13 +267,13 @@ describe("Research Priority Allocation", () => { return path; }; - const priorityId = "Economy-4"; + const priorityId = "Land-4"; const available = nodes.filter( (n) => !researched.has(n.id) && isTechAvailable(n.id, researched), ); const priorityInSet = available.some((n) => n.id === priorityId); - expect(priorityInSet).toBe(false); // Economy-4 should NOT be directly available + expect(priorityInSet).toBe(false); // Land-4 should NOT be directly available // Simulate allocation logic const xTotal = 1000; // arbitrary total research points @@ -300,12 +299,12 @@ describe("Research Priority Allocation", () => { } } - // Economy-1 should receive 50% of the total (500 points) - expect(alloc["Economy-1"]).toBe(500); + // Land-1 should receive 50% of the total (500 points) + expect(alloc["Land-1"]).toBe(500); // Other level-1 techs should share the remaining 50% - const otherTechs = ["Land-1", "Sea-1", "Air-1", "Nuclear-1"]; - const expectedShareOthers = 500 / otherTechs.length; // 125 each + const otherTechs = ["Sea-1", "Air-1", "Nuclear-1"]; + const expectedShareOthers = 500 / otherTechs.length; // ~166.67 each for (const techId of otherTechs) { expect(alloc[techId]).toBeCloseTo(expectedShareOthers, 5); } @@ -316,8 +315,8 @@ describe("Research Priority Allocation", () => { it("should eventually progress frontier as prerequisite techs complete", () => { const nodes = getTechNodes(); - // Simulate: Economy-1 is now researched - const researched = new Set(["Economy-1"]); + // Simulate: Land-1 is now researched + const researched = new Set(["Land-1"]); const byId = new Map(nodes.map((n) => [n.id, n] as const)); const sameCat = (a: string, b: string) => (byId.get(a)?.category ?? "") === (byId.get(b)?.category ?? ""); @@ -360,24 +359,24 @@ describe("Research Priority Allocation", () => { return path; }; - const priorityId = "Economy-4"; + const priorityId = "Land-4"; const available = nodes.filter( (n) => !researched.has(n.id) && isTechAvailable(n.id, researched), ); - // Now Economy-2 should be available (since Economy-1 is researched) - expect(available.some((n) => n.id === "Economy-2")).toBe(true); + // Now Land-2 should be available (since Land-1 is researched) + expect(available.some((n) => n.id === "Land-2")).toBe(true); - // Path should now only include Economy-2 and Economy-3 + // Path should now only include Land-2 and Land-3 const pathSet = buildMissingPrereqPath(priorityId); - expect(pathSet.has("Economy-1")).toBe(false); // Already researched - expect(pathSet.has("Economy-2")).toBe(true); - expect(pathSet.has("Economy-3")).toBe(true); + expect(pathSet.has("Land-1")).toBe(false); // Already researched + expect(pathSet.has("Land-2")).toBe(true); + expect(pathSet.has("Land-3")).toBe(true); - // Frontier should be Economy-2 (the only currently available prereq) + // Frontier should be Land-2 (the only currently available prereq) const frontier = available.filter((n) => pathSet.has(n.id)); expect(frontier.length).toBe(1); - expect(frontier[0].id).toBe("Economy-2"); + expect(frontier[0].id).toBe("Land-2"); }); }); }); diff --git a/tests/core/execution/ResearchTreeSelectExecution.test.ts b/tests/core/execution/ResearchTreeSelectExecution.test.ts index bd2fd698c..c518ed6ef 100644 --- a/tests/core/execution/ResearchTreeSelectExecution.test.ts +++ b/tests/core/execution/ResearchTreeSelectExecution.test.ts @@ -34,7 +34,7 @@ describe("ResearchTreeSelectExecution", () => { // Simulate PlayerImpl side-effects when research completes (mockPlayer.addResearchedTech as jest.Mock).mockImplementation( (id: string) => { - if (id === RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM) { + if (id === RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS) { const alreadyHas = (mockPlayer.hasUpgrade as jest.Mock)( UpgradeType.Roads, ); @@ -60,10 +60,10 @@ describe("ResearchTreeSelectExecution", () => { ); }); - it("grants Roads and reconnects when Economy-1 is selected", () => { + it("grants Roads and reconnects when Land-1 is selected", () => { const exec = new ResearchTreeSelectExecution( mockPlayer as any, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); exec.init(mockGame as any, 0); exec.tick(0); @@ -78,7 +78,7 @@ describe("ResearchTreeSelectExecution", () => { (mockPlayer.hasUpgrade as jest.Mock).mockReturnValue(true); const exec = new ResearchTreeSelectExecution( mockPlayer as any, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); exec.init(mockGame as any, 0); exec.tick(0); diff --git a/tests/core/execution/ScorchedEarthExecution.test.ts b/tests/core/execution/ScorchedEarthExecution.test.ts deleted file mode 100644 index e0c6f1944..000000000 --- a/tests/core/execution/ScorchedEarthExecution.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ScorchedEarthExecution } from "../../../src/core/execution/ScorchedEarthExecution"; -import { Gold, Player, UpgradeType } from "../../../src/core/game/Game"; -import { GameImpl } from "../../../src/core/game/GameImpl"; - -describe("ScorchedEarthExecution", () => { - let mockPlayer: jest.Mocked; - let mockGame: jest.Mocked; - - beforeEach(() => { - mockPlayer = { - gold: jest.fn(), - hasUpgrade: jest.fn(), - addUpgrade: jest.fn(), - removeUpgrade: jest.fn(), - removeGold: jest.fn(), - hasResearchedTech: jest.fn(), - removeResearchedTechsByCategory: jest.fn(), - setRoadInvestmentRate: jest.fn(), - } as unknown as jest.Mocked; - (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(true); - - mockGame = { - config: jest.fn().mockReturnValue({ - scorchedEarthActivationCost: jest.fn().mockReturnValue(3_000_000n), - }), - destroyPlayerRoads: jest.fn(), - markPlayerNodesForReconnection: jest.fn(), - } as unknown as jest.Mocked; - }); - - it("should do nothing if player already has ScorchedEarth", () => { - mockPlayer.hasUpgrade.mockReturnValue(true); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("requires the Scorched Earth tech to be researched before activation", () => { - (mockPlayer.hasResearchedTech as jest.Mock).mockReturnValue(false); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("should fail if player does not have enough gold", () => { - mockPlayer.gold.mockReturnValue(2_999_999n as Gold); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).not.toHaveBeenCalled(); - expect(mockPlayer.addUpgrade).not.toHaveBeenCalled(); - expect(mockGame.destroyPlayerRoads).not.toHaveBeenCalled(); - }); - - it("should activate Scorched Earth with sufficient gold and tech", () => { - mockPlayer.gold.mockReturnValue(3_000_000n as Gold); - mockPlayer.hasUpgrade.mockReturnValue(false); - - const exec = new ScorchedEarthExecution(mockPlayer); - exec.init(mockGame, 0); - - expect(mockPlayer.removeGold).toHaveBeenCalledWith(3_000_000n); - expect(mockPlayer.addUpgrade).toHaveBeenCalledWith( - UpgradeType.ScorchedEarth, - ); - expect(mockGame.destroyPlayerRoads).toHaveBeenCalledWith(mockPlayer); - expect(mockPlayer.setRoadInvestmentRate).toHaveBeenCalledWith(0); - expect(mockGame.markPlayerNodesForReconnection).toHaveBeenCalledWith( - mockPlayer, - ); - }); -}); diff --git a/tests/core/execution/UpgradeStructureExecution.test.ts b/tests/core/execution/UpgradeStructureExecution.test.ts index f06f3ac15..9fa25ec47 100644 --- a/tests/core/execution/UpgradeStructureExecution.test.ts +++ b/tests/core/execution/UpgradeStructureExecution.test.ts @@ -22,8 +22,16 @@ describe("UpgradeStructureExecution", () => { isUnit: jest.fn().mockReturnValue(true), type: jest.fn().mockReturnValue(unitType), owner: jest.fn().mockReturnValue(mockPlayer), + level: jest.fn().mockReturnValue(1), + stackCount: jest.fn().mockReturnValue(1), + setStackCount: jest.fn(), upgradeStructure: jest.fn(), - } as unknown as jest.Mocked void }>; + } as unknown as jest.Mocked< + Unit & { + upgradeStructure: () => void; + setStackCount: (count: number) => void; + } + >; return { mockPlayer, mockGame, mockUnit }; }; @@ -76,16 +84,23 @@ describe("UpgradeStructureExecution", () => { expect(mockUnit.upgradeStructure).toHaveBeenCalled(); }); - it("does not charge or upgrade a Missile Silo at max level (3)", () => { + it("does not charge or upgrade a Missile Silo at max stack level", () => { const { mockPlayer, mockGame } = makeMocks(UnitType.MissileSilo); - // Create a unit mock that reports level 3 + // Create a unit mock that reports level 25 (max stack count) const mockUnit = { isUnit: jest.fn().mockReturnValue(true), type: jest.fn().mockReturnValue(UnitType.MissileSilo), owner: jest.fn().mockReturnValue(mockPlayer), - level: jest.fn().mockReturnValue(3), + level: jest.fn().mockReturnValue(25), // MAX_STACK_COUNT + stackCount: jest.fn().mockReturnValue(25), + setStackCount: jest.fn(), upgradeStructure: jest.fn(), - } as unknown as jest.Mocked void }>; + } as unknown as jest.Mocked< + Unit & { + upgradeStructure: () => void; + setStackCount: (count: number) => void; + } + >; const exec = new UpgradeStructureExecution(mockPlayer, mockUnit); exec.init(mockGame, 0); diff --git a/tests/core/game/CargoManager.test.ts b/tests/core/game/CargoManager.test.ts index 30341b870..0609b1d8f 100644 --- a/tests/core/game/CargoManager.test.ts +++ b/tests/core/game/CargoManager.test.ts @@ -46,7 +46,7 @@ describe("CargoManager", () => { } // Grant Roads via research tech so RoadManager reconnects nodes immediately - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); // Let roads form for (let i = 0; i < 200; i++) { diff --git a/tests/core/game/PlayerImpl.tech.test.ts b/tests/core/game/PlayerImpl.tech.test.ts index 6d17a27de..3d649a5f5 100644 --- a/tests/core/game/PlayerImpl.tech.test.ts +++ b/tests/core/game/PlayerImpl.tech.test.ts @@ -5,48 +5,38 @@ import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects"; import { playerInfo, setup } from "../../util/Setup"; describe("PlayerImpl.removeResearchedTechsByCategory", () => { - it("revokes economy techs and clears associated progress", async () => { + it("revokes land techs and clears associated progress", async () => { const game = (await setup("ocean_and_land")) as GameImpl; const info = playerInfo("Tester", PlayerType.Human); game.addPlayer(info); const player = game.player(info.id) as PlayerImpl; - player.addResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - player.addResearchBeakers("Economy-4", 500, 1_000); - player.setResearchPriority("Economy-4"); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_DOOMSDAY_DEVICE); + player.addResearchBeakers("Land-4", 500, 1_000); + player.setResearchPriority("Land-4"); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.researchBeakers("Economy-4")).toBe(500); + expect(player.researchBeakers("Land-4")).toBe(500); - player.removeResearchedTechsByCategory("Economy"); + player.removeResearchedTechsByCategory("Land"); expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ), - ).toBe(true); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS), ).toBe(false); expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_MILITARY_ACADEMY), ).toBe(false); + expect(player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS)).toBe( + false, + ); // Upgrades are NOT removed by removeResearchedTechsByCategory - only techs and progress expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.researchBeakers("Economy-4")).toBe(0); + expect(player.researchBeakers("Land-4")).toBe(0); expect(player.researchPriority()).toBeNull(); }); }); diff --git a/tests/core/tech/EconomyTechEffects.test.ts b/tests/core/tech/EconomyTechEffects.test.ts index d3cb3980f..3fbd2138c 100644 --- a/tests/core/tech/EconomyTechEffects.test.ts +++ b/tests/core/tech/EconomyTechEffects.test.ts @@ -1,75 +1,27 @@ import { PlayerType, UpgradeType } from "../../../src/core/game/Game"; import { GameImpl } from "../../../src/core/game/GameImpl"; import { PlayerImpl } from "../../../src/core/game/PlayerImpl"; -import { POLICY_DIRECTIVE_IDS } from "../../../src/core/tech/PolicyDirectives"; import { RESEARCH_TECH_IDS } from "../../../src/core/tech/TechEffects"; import { playerInfo, setup } from "../../util/Setup"; -describe("Economy tech integrations", () => { - it("enables Roads after researching National Reconstruction Program", async () => { +describe("Land infrastructure tech integrations", () => { + it("enables Roads after researching Roads & Hospitals", async () => { const info = playerInfo("builder", PlayerType.Human); const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; expect(player.hasUpgrade(UpgradeType.Roads)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); }); - it("enables InternationalTrade after choosing Open Trade policy", async () => { - const info = playerInfo("trader", PlayerType.Human); - const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; - const player = game.player(info.id) as PlayerImpl; - - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - // Tech alone doesn't grant the upgrade anymore - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - - // Choosing Open Trade policy grants the upgrade - player.setPolicyChoice( - POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - "open_trade", - ); - player.addUpgrade(UpgradeType.InternationalTrade); // Simulating what execution does - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(true); - }); - - it("Autarky policy does not grant InternationalTrade", async () => { - const info = playerInfo("autarky", PlayerType.Human); - const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; - const player = game.player(info.id) as PlayerImpl; - - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - - // Choosing Autarky policy does NOT grant the upgrade - player.setPolicyChoice( - POLICY_DIRECTIVE_IDS.TRADE_POLICY_FRAMEWORK, - "autarky", - ); - expect(player.hasUpgrade(UpgradeType.InternationalTrade)).toBe(false); - }); - - // TEMPORARILY DISABLED: Structure insurance tests - // it("refunds 33% of a structure's cost on destruction with Infrastructure Recovery Fund", ...) - // it("refunds insured structures when conquered", ...) - - it("enables HospitalResearch after researching National Reconstruction Program", async () => { + it("enables HospitalResearch after researching Modern Air Defense (Land-3)", async () => { const info = playerInfo("health", PlayerType.Human); const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; - // Hospitals are unlocked at Level 1 now (National Reconstruction Program) expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(false); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_SAM_SYSTEMS); expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); }); @@ -78,15 +30,13 @@ describe("Economy tech integrations", () => { const game = (await setup("ocean_and_land", {}, [info])) as GameImpl; const player = game.player(info.id) as PlayerImpl; - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); + player.addResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - player.removeResearchedTechsByCategory("Economy"); + player.removeResearchedTechsByCategory("Land"); // Techs are removed but upgrades remain expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), + player.hasResearchedTech(RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS), ).toBe(false); expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); }); diff --git a/tests/core/tech/TechEffects.test.ts b/tests/core/tech/TechEffects.test.ts index 325b05b4c..920ca8876 100644 --- a/tests/core/tech/TechEffects.test.ts +++ b/tests/core/tech/TechEffects.test.ts @@ -5,8 +5,8 @@ import { } from "../../../src/core/tech/TechEffects"; describe("TechEffects", () => { - it("removes Scorched Earth upgrade when National Reconstruction Program completes", () => { - const owned = new Set([UpgradeType.ScorchedEarth]); + it("grants Roads when Land-1 (Road Network) completes", () => { + const owned = new Set(); const player = { hasUpgrade: jest.fn((upgrade: UpgradeType) => owned.has(upgrade)), addUpgrade: jest.fn((upgrade: UpgradeType) => owned.add(upgrade)), @@ -19,14 +19,10 @@ describe("TechEffects", () => { applyTechCompletionEffects( player, game, - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, + RESEARCH_TECH_IDS.LAND_ROADS_HOSPITALS, ); expect(player.addUpgrade).toHaveBeenCalledWith(UpgradeType.Roads); expect(game.markPlayerNodesForReconnection).toHaveBeenCalledWith(player); - expect(player.removeUpgrade).toHaveBeenCalledWith( - UpgradeType.ScorchedEarth, - ); - expect(owned.has(UpgradeType.ScorchedEarth)).toBe(false); }); }); diff --git a/tests/integrations/ScorchedEarth.test.ts b/tests/integrations/ScorchedEarth.test.ts deleted file mode 100644 index 2665f5f35..000000000 --- a/tests/integrations/ScorchedEarth.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ScorchedEarthExecution } from "../../src/core/execution/ScorchedEarthExecution"; -import { PlayerType, UnitType, UpgradeType } from "../../src/core/game/Game"; -import { GameImpl } from "../../src/core/game/GameImpl"; -import { PlayerImpl } from "../../src/core/game/PlayerImpl"; -import { RESEARCH_TECH_IDS } from "../../src/core/tech/TechEffects"; -import { playerInfo, setup } from "../util/Setup"; - -describe("Scorched Earth Full Cycle Integration Test", () => { - it("should allow a player to build, destroy, and rebuild their road network", async () => { - // Given: A game with a player having several cities and enough gold - const game = (await setup("ocean_and_land", { - instantBuild: true, - })) as GameImpl; - const pInfo = playerInfo("Player A", PlayerType.Human); - game.addPlayer(pInfo); - const player = game.player(pInfo.id); - player.addGold(10_000_000n); - // Allocate income to road building so construction progresses in tests - player.setRoadInvestmentRate(1); - (player as any).addWorkers(10000000); - const city1 = player.buildUnit(UnitType.City, game.ref(0, 10), {}); - const city2 = player.buildUnit(UnitType.City, game.ref(0, 12), {}); - - // Conquer a path between the cities - for (let i = 10; i <= 12; i++) { - const tile = game.ref(0, i); - if (game.owner(tile) !== player) { - game.conquer(player as PlayerImpl, tile); - } - } - - // Research core economy techs to unlock and test revocation behavior - player.addResearchedTech( - RESEARCH_TECH_IDS.POST_WW2_GROUND_FORCES_MODERNIZATION, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - player.addResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ); - player.addResearchedTech(RESEARCH_TECH_IDS.TRADE_POLICY_FRAMEWORK); - - // Allow the automatic road upgrade to build out the network - for (let i = 0; i < 200; i++) { - game.executeNextTick(); - } - expect(game.roads().length).toBeGreaterThan(0); - expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - - // Step 2: Research and activate Scorched Earth, verify network destruction - player.addResearchedTech(RESEARCH_TECH_IDS.MECHANIZED_WARFARE_DOCTRINE); - game.addExecution(new ScorchedEarthExecution(player)); - game.executeNextTick(); - expect(game.roads().length).toBe(0); - // Scorched Earth only destroys roads, keeps upgrades and techs - expect(player.hasUpgrade(UpgradeType.Roads)).toBe(true); - expect(player.hasUpgrade(UpgradeType.HospitalResearch)).toBe(true); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(true); - expect(player.roadInvestmentRate()).toBe(0); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM, - ), - ).toBe(true); - expect( - player.hasResearchedTech( - RESEARCH_TECH_IDS.NATIONAL_RESEARCH_INDUSTRIAL_FOUNDATIONS, - ), - ).toBe(true); - - // Step 3: Re-unlock roads and verify Scorched Earth deactivates - player.addResearchedTech(RESEARCH_TECH_IDS.NATIONAL_RECONSTRUCTION_PROGRAM); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false); - player.setRoadInvestmentRate(1); - for (let i = 0; i < 200; i++) { - game.executeNextTick(); - } - expect(game.roads().length).toBeGreaterThan(0); - expect(player.hasUpgrade(UpgradeType.ScorchedEarth)).toBe(false); - }); -});