From 9fc94a8e0010a88a81eaf418c90bb2349552a3e8 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Mon, 28 Sep 2026 01:06:07 +0200 Subject: [PATCH] Rebuilt the technology panel as one lane-routed draggable graph --- client/css/style.css | 198 +++++++++++- client/index.html | 4 +- client/js/devlog_data.js | 12 +- client/js/modals/nation.js | 184 ++--------- client/js/ui/card.js | 1 + client/js/ui/tech_tree.js | 273 +++++++++++++++++ client/js/ui/tech_tree_view.js | 459 ++++++++++++++++++++++++++++ shared/data/technologies.js | 41 +-- tests/data_test.js | 2 +- tests/game_screen_test.js | 18 +- tests/game_state_buildings_test.js | 2 +- tests/game_state_government_test.js | 2 +- tests/game_state_technology_test.js | 55 ++-- tests/modals_test.js | 98 +++--- tests/resources_test.js | 2 +- tests/rules_test.js | 10 +- tests/tech_tree_test.js | 187 ++++++++++++ tests/treaty_test.js | 2 +- tests/wmd_test.js | 1 - tools/tech-tree.html | 108 +++++++ 20 files changed, 1364 insertions(+), 295 deletions(-) create mode 100644 client/js/ui/tech_tree.js create mode 100644 client/js/ui/tech_tree_view.js create mode 100644 tests/tech_tree_test.js create mode 100644 tools/tech-tree.html diff --git a/client/css/style.css b/client/css/style.css index 819e10c..d9f87c3 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1703,14 +1703,198 @@ input:focus, select:focus { border-color: var(--accent); } .row-card .budget-detail { margin-top: 4px; } .row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; } -/* Technology cards are the same building cards as the city tab, tiled across - the tab so many read at once, grouped by the theme sub-tabs. */ -#technology-list { - display: grid; - grid-template-columns: repeat(auto-fill, 178px); - gap: 8px; - align-content: start; +/* The technology tree: one theme at a time, cards placed on a canvas with the + dependency links drawn into an SVG layer beneath them. */ +.tech-research { + display: flex; + align-items: center; + gap: 14px; + margin: 4px 18px 8px; + padding: 9px 14px; + border-radius: 9px; + border: 1px solid var(--panel-border); + background: linear-gradient(160deg, rgba(40, 52, 70, 0.55), rgba(18, 24, 34, 0.7)); } +.tech-research.idle { color: var(--muted); } +.tech-research .tech-research-head { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; } +.tech-research .tech-research-icon { width: 32px; height: 32px; flex: 0 0 auto; opacity: 0.95; } +.tech-research .tech-research-text { flex: 0 0 auto; min-width: 190px; } +.tech-research .tech-research-name { font-weight: 600; font-size: 13.5px; } +.tech-research .tech-research-sub { font-size: 11px; color: var(--muted); } +.tech-research .tech-research-cost { flex: 1 1 auto; color: var(--muted); font-variant-numeric: tabular-nums; } +.tech-research .tech-research-cost .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; } +.tech-research .tech-research-focus { flex: 0 0 auto; min-width: 96px; padding: 6px 16px; } +.tech-research .tech-research-bar { + position: relative; + flex: 1 1 auto; + height: 26px; + border-radius: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(160, 190, 230, 0.25); +} +.tech-research .tech-research-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: linear-gradient(90deg, rgba(79, 140, 255, 0.75), rgba(127, 209, 255, 0.75)); +} +.tech-research .tech-research-count { + position: relative; + display: block; + padding: 5px 10px; + text-align: right; + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} +.tech-research .tech-research-count .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; } + +#technology-list.tech-tree-body { overflow: hidden; } +.tech-pan { position: relative; width: 100%; height: 100%; overflow: hidden; cursor: grab; } +.tech-pan.dragging { cursor: grabbing; } +.tech-canvas { position: absolute; top: 0; left: 0; transform: translate(0, 0); } +.tech-links { position: absolute; inset: 0; pointer-events: none; overflow: visible; } +.tech-links path { fill: none; stroke: rgba(150, 190, 240, 0.5); stroke-width: 2; } +.tech-links circle { fill: #0a1220; stroke: rgba(150, 190, 240, 0.55); stroke-width: 2; } + +/* A faint fade and chevron sit on every edge the tree still extends past, so + the panel reads as draggable without the clutter of a scrollbar. */ +.tech-hint { + position: absolute; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 220ms ease; +} +.tech-hint.show { opacity: 1; } +.tech-hint .tech-hint-arrow { + position: absolute; + display: block; + padding: 3px; + border: solid rgba(185, 205, 232, 0.75); + border-width: 0 2px 2px 0; +} +.tech-hint-top { + top: 0; + left: 0; + right: 0; + height: 64px; + background: linear-gradient(to bottom, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0)); +} +.tech-hint-top .tech-hint-arrow { + top: 10px; + left: 50%; + margin-left: -5px; + transform: rotate(-135deg); + animation: tech-hint-bob-up 1.7s ease-in-out infinite; +} +.tech-hint-bottom { + bottom: 0; + left: 0; + right: 0; + height: 64px; + background: linear-gradient(to top, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0)); +} +.tech-hint-bottom .tech-hint-arrow { + bottom: 10px; + left: 50%; + margin-left: -5px; + transform: rotate(45deg); + animation: tech-hint-bob-down 1.7s ease-in-out infinite; +} +.tech-hint-left { + top: 0; + bottom: 0; + left: 0; + width: 64px; + background: linear-gradient(to right, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0)); +} +.tech-hint-left .tech-hint-arrow { + left: 10px; + top: 50%; + margin-top: -5px; + transform: rotate(135deg); + animation: tech-hint-bob-left 1.7s ease-in-out infinite; +} +.tech-hint-right { + top: 0; + bottom: 0; + right: 0; + width: 64px; + background: linear-gradient(to left, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0)); +} +.tech-hint-right .tech-hint-arrow { + right: 10px; + top: 50%; + margin-top: -5px; + transform: rotate(-45deg); + animation: tech-hint-bob-right 1.7s ease-in-out infinite; +} +@keyframes tech-hint-bob-up { 50% { translate: 0 -5px; } } +@keyframes tech-hint-bob-down { 50% { translate: 0 5px; } } +@keyframes tech-hint-bob-left { 50% { translate: -5px 0; } } +@keyframes tech-hint-bob-right { 50% { translate: 5px 0; } } + +.tech-card { position: absolute; padding: 0; overflow: hidden; border-left-width: 4px; } +.tech-card.theme-science { border-left-color: #5aa9ff; } +.tech-card.theme-economy { border-left-color: #e0b050; } +.tech-card.theme-society { border-left-color: #9a7bff; } +.tech-card.theme-military { border-left-color: #ff6b5e; } + +/* The category key above the tree. */ +.tech-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin: 2px 18px 6px; + font-size: 11px; + color: var(--muted); +} +.tech-legend .tech-legend-item { display: flex; align-items: center; gap: 6px; } +.tech-legend .tech-legend-swatch { + width: 4px; + height: 14px; + border-radius: 2px; +} +.tech-card .card-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: linear-gradient(90deg, rgba(79, 140, 255, 0.5), rgba(127, 209, 255, 0.4)); +} +.tech-card.built .card-fill { + background: linear-gradient(90deg, rgba(74, 160, 110, 0.5), rgba(143, 227, 168, 0.4)); +} +.tech-card .card-body { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 4px; + height: 100%; + padding: 9px; +} +.tech-card .bt-level.good { color: #8fe3a8; } +.tech-card .bt-foot { + display: flex; + align-items: center; + gap: 4px; + margin-top: auto; + font-size: 10.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.tech-card .bt-foot img { width: 11px; height: 11px; vertical-align: -1px; } +.tech-card.clickable { cursor: pointer; } +.tech-card.locked::after { z-index: 2; } +.tech-card .free-button { width: 100%; font-size: 10.5px; padding: 3px 6px; } +/* Tracing an unavailable technology's outstanding requirements. */ +.tech-card.warn.locked { opacity: 0.95; } +.tech-card.warn { border-color: #ffb56b; } +.tech-card.warn-self { border-color: #ff7a5c; box-shadow: 0 0 0 2px rgba(255, 122, 92, 0.55); } +.tech-links path.warn { stroke: #ffb56b; stroke-width: 2.6; stroke-dasharray: none; } +.tech-links circle.warn { stroke: #ffb56b; } /* A focused technology turns its action button into the progress bar: the committed share fills behind, while the "Focused" label and the points sit on top. The disabled dimming is lifted so the bar stays legible. */ diff --git a/client/index.html b/client/index.html index 8a7c5d4..8bc2328 100644 --- a/client/index.html +++ b/client/index.html @@ -373,8 +373,8 @@
-
- +
+
diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index 3f51de2..456f067 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "3c737e9", + "date": "2026-09-28", + "subject": "Derived every description figure from the rule it describes", + "body": "" + }, { "hash": "c85f79f", "date": "2026-09-27", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-24", "subject": "Cut the migration sweep's repeated lookups and population churn", "body": "" - }, - { - "hash": "a219cf6", - "date": "2026-09-24", - "subject": "Flew aircraft and missiles straight and let strikes target fogged cities", - "body": "" } ]; diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 71615c6..8ced7c7 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -9,7 +9,6 @@ import { POLICIES, PROPAGANDA, policyById, - TECH_THEMES, technologyCost, RESOURCES, RESOURCE_IDS, @@ -25,7 +24,8 @@ import { renderMonthlyBudget } from "./budget.js"; import { effectNodes, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; -import { buildCard, researchNode, setCardMeta } from "../ui/card.js"; +import { researchNode } from "../ui/card.js"; +import { TechnologyTreeView } from "../ui/tech_tree_view.js"; import { resourceNode, valueNode } from "../ui/value.js"; import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; @@ -33,21 +33,13 @@ import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../cur import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js"; import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js"; -// Each research theme borrows the building-card icon of its field, so a -// technology card reads with the same visual grammar as a building. -const TECH_THEME_ICONS = { - science: "icon_university.svg", - economy: "icon_bank.svg", - society: "icon_museum.svg", - military: "icon_fortifications.svg", -}; - export class NationModal { constructor() { this.$modal = $("#modal-nation"); this.$governmentSub = $("#government-sub"); this.$governmentList = $("#government-list"); this.$technologySub = $("#technology-sub"); + this.$technologyStrip = $("#technology-research"); this.$technologyList = $("#technology-list"); this.$budgetSub = $("#budget-sub"); this.$budgetMonths = $("#budget-months"); @@ -101,9 +93,8 @@ export class NationModal { this.currentGovernment = 0; this.researched = new Set(); this.governmentRows = []; - this.technologyRows = []; - // Focused research: which theme tab is open and the current focus figures. - this.activeTheme = TECH_THEMES[0].id; + // The technology tree is drawn by a self-contained view, shared with the + // stand-alone catalogue preview; the modal only feeds it the snapshot. this.techInfo = { focus: -1, progress: 0, repeatCounts: {} }; this.diplomacy = null; this.diplomacySort = { key: "name", dir: 1 }; @@ -148,6 +139,13 @@ export class NationModal { this.onConfirm = () => {}; this.$modal.find(".modal-close").on("click", () => this.close()); bindTabs(this.$modal, "tab-"); + this.techTree = new TechnologyTreeView({ + graph: this.$technologyList, + strip: this.$technologyStrip, + tipFor: (index) => this._technologyTip(index), + onFocusTechnology: (index) => this.onFocusTechnology(index), + onUnlockTechnology: (index) => this.onUnlockTechnology(index), + }); } isShowing() { @@ -195,11 +193,9 @@ export class NationModal { // second set of buttons on top of the previous one, or the stale rows keep // their old labels and disabled state and appear unresponsive. this.$governmentList.empty(); - this.$technologyList.empty(); + this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number)); this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); - this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index)); - for (const row of this.technologyRows) this.$technologyList.append(row.card.$card); - this._buildTechnologyThemes(); + this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo }); this._buildResourceSubtabs(); this._buildIntelligenceSubtabs(); // Reopening the panel shows the server's rates, not an unsent edit left @@ -215,31 +211,6 @@ export class NationModal { this.$modal.removeClass("hidden"); } - // The technology tab's theme sub-tabs, built once per open. Switching a tab - // only shows and hides the rows that are already there. - _buildTechnologyThemes() { - const $themes = $("#technology-themes"); - if (!$themes.length) return; - $themes.empty(); - for (const theme of TECH_THEMES) { - const $button = $("").text(theme.name); - if (theme.id === this.activeTheme) $button.addClass("active"); - $button.on("click", () => { - this.activeTheme = theme.id; - $themes.find(".subtab").removeClass("active"); - $button.addClass("active"); - this._applyTechnologyFilter(); - }); - $themes.append($button); - } - } - - _applyTechnologyFilter() { - for (const row of this.technologyRows) { - if (row.$card) row.$card.toggle(row.proto.theme === this.activeTheme); - } - } - // The Resources tab's sub-tabs: the nation's own stores and trade, and the // world ranking of each commodity's major exporters and importers. Both views // are rendered together; switching only shows and hides them. The simple @@ -1494,82 +1465,26 @@ export class NationModal { return { proto, index, kind, $name, $effects, $cost, $action, $card }; } - // One technology card in the shared building-card idiom: the theme icon and - // name up top with the research state below, the description and effect in - // the hover tooltip, and a Focus button carrying the research cost. A focused - // technology fills the card's progress bar instead. - _makeTechnologyCard(proto, index) { - const row = { proto, index, repeat: 0 }; - const card = buildCard({ - icon: TECH_THEME_ICONS[proto.theme] || "icon_university.svg", - name: proto.name, - status: "", - action: { label: "Focus" }, - progress: true, - tip: () => this._technologyTip(row), - onAction: () => this.onFocusTechnology(index), - }); - row.card = card; - row.$card = card.$card; - row.$name = card.$card.find(".bt-name"); - row.$status = card.$status; - row.$action = card.$action; - row.$label = card.$label; - row.$meta = card.$meta; - row.$fill = card.$progressFill; - // A local testing game gains a second button that grants the technology at - // once, ignoring its cost and prerequisites. - if (this.techInfo && this.techInfo.testing) { - const $free = $("") - .text("Unlock now") - .on("click", (event) => { - event.stopPropagation(); - this.onUnlockTechnology(index); - }); - card.$card.append($free); - row.$free = $free; - } - return row; - } - - // Whether the viewer's nation holds the building a technology is gated - // behind, such as the uranium enrichment centre that unlocks nuclear weapons. - _hasTechBuilding(id) { - return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id)); - } - - // The hover detail for a technology card: its description, what its next - // level yields, and what it still needs before it can be focused. - _technologyTip(row) { - const proto = row.proto; + // The hover detail for a technology card: its description and what its next + // level yields. Outstanding requirements are traced on the tree itself rather + // than listed here. + _technologyTip(index) { + const proto = this.technologies[index]; // A repeatable's next level is what is being weighed, so its diminishing // gain is shown rather than the level already held. - const level = proto.repeatable ? row.repeat + 1 : 1; + const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0; + const level = proto.repeatable ? repeat + 1 : 1; const sections = []; if (proto.description) { sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] }); } sections.push({ - label: proto.repeatable ? `Next level (${row.repeat + 1})` : "Effect", + label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect", cls: "tip-produce", sign: "+", suffix: "", items: [{ node: effectNodes(proto.effects, level) }], }); - if ( - row.$action && - row.$action.prop("disabled") && - !this.researched.has(row.index) && - !prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id)) - ) { - const names = (proto.prerequisites || []) - .map((id) => { - const found = this.technologies.find((t) => t.id === id); - return found ? found.name : id; - }) - .join(", "); - sections.push({ label: "Requires", cls: "tip-cost", sign: "−", suffix: "", items: [{ text: names }] }); - } return sections; } @@ -1628,60 +1543,7 @@ export class NationModal { } } - const repeatCounts = this.techInfo.repeatCounts || {}; - for (const row of this.technologyRows) { - const proto = row.proto; - const repeat = repeatCounts[proto.id] || 0; - const focused = this.techInfo.focus === row.index; - row.repeat = repeat; - row.$name.text(proto.name); - row.$fill.css("width", "0%"); - row.$action.removeClass("progressing"); - row.$card.removeClass("built locked"); - row.$status.removeClass("built"); - row.$action.prop("disabled", false); - - if (this.researched.has(row.index) && !proto.repeatable) { - row.$status.text("Researched").addClass("built"); - row.$card.addClass("built"); - row.$label.text("Researched"); - row.$action.prop("disabled", true); - setCardMeta(row.$meta, {}); - } else if (!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))) { - row.$status.text("Locked"); - row.$card.addClass("locked"); - row.$label.text("Locked"); - row.$action.prop("disabled", true); - setCardMeta(row.$meta, {}); - } else if (focused) { - // A focused technology fills the card's progress bar, with the committed - // and total points on the button. - const cost = technologyCost(proto, repeat); - const progress = this.techInfo.progress || 0; - const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0; - row.$status.text(repeat > 0 ? `Level ${repeat} · Researching` : "Researching").addClass("built"); - row.$card.addClass("built"); - row.$label.text("Focused"); - row.$action.prop("disabled", true).addClass("progressing"); - row.$fill.css("width", `${pct}%`); - setCardMeta(row.$meta, {}); - row.$meta - .append(researchNode(progress)) - .append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`)); - } else { - const cost = technologyCost(proto, repeat); - row.$status.text(repeat > 0 ? `Level ${repeat}` : "Not researched"); - row.$label.text("Focus"); - setCardMeta(row.$meta, {}); - row.$meta.append(researchNode(cost)); - } - // The testing unlock disappears once a one-off technology is held; a - // repeatable keeps it, to grant another level. - if (row.$free) { - row.$free.toggleClass("hidden", this.researched.has(row.index) && !proto.repeatable); - } - } - this._applyTechnologyFilter(); + this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo }); this._renderDiplomacy(); this._renderConflicts(); diff --git a/client/js/ui/card.js b/client/js/ui/card.js index 22328c0..f869b92 100644 --- a/client/js/ui/card.js +++ b/client/js/ui/card.js @@ -95,6 +95,7 @@ export function hideCardTooltip() { export function showCardTooltip($anchor, sections) { hideCardTooltip(); if (!$anchor || !$anchor.length) return; + if (!sections || !sections.length) return; $tooltip = $("
"); for (const section of sections) { if (section.desc) { diff --git a/client/js/ui/tech_tree.js b/client/js/ui/tech_tree.js new file mode 100644 index 0000000..799e447 --- /dev/null +++ b/client/js/ui/tech_tree.js @@ -0,0 +1,273 @@ +// The technology tree's geometry and pure helpers. +// +// Every technology shares one graph: a technology's column is its distance from +// a root (the longest chain of prerequisites behind it), and each column holds +// every technology of that tier. +// +// Links are routed orthogonally so that no two runs ever lie on top of each +// other: each edge leaves its parent's right edge at its own port, travels down +// a vertical lane unique to its gutter, crosses a horizontal track unique to its +// row gap, then enters the child's left edge at its own port. That costs some +// width (the gutters widen with the number of lanes) but keeps every dependency +// readable even when a node has many parents or children. + +// Each research theme borrows the building-card icon of its field, so a +// technology card reads with the same visual grammar as a building. The colour +// tints the card's left edge and keys the legend above the tree. +export const TECH_THEMES = { + science: { name: "Science", color: "#5aa9ff", icon: "icon_university.svg" }, + economy: { name: "Economy", color: "#e0b050", icon: "icon_bank.svg" }, + society: { name: "Society", color: "#9a7bff", icon: "icon_museum.svg" }, + military: { name: "Military", color: "#ff6b5e", icon: "icon_fortifications.svg" }, +}; +export const TECH_THEME_ORDER = ["science", "economy", "society", "military"]; + +export function techTheme(id) { + return TECH_THEMES[id] || TECH_THEMES.science; +} + +// Repeatable technologies carry a Roman-numeral level up to X; beyond that a +// plain number is used (exponential costs make it unreachable in practice). +const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]; +export function romanNumeral(n) { + return ROMAN[n] || String(n); +} + +export const TECH_LAYOUT = { + W: 196, + H: 76, + GX: 36, + GY: 18, + PADX: 16, + PADY: 20, + LANE: 12, + LANE_MARGIN: 12, + TRACK: 12, + TRACK_MARGIN: 9, +}; + +// A technology's tier: one past the deepest prerequisite. +function depthOf(index, technologies, byId, cache) { + if (cache.has(index)) return cache.get(index); + let depth = 0; + for (const id of technologies[index].prerequisites || []) { + const parent = byId.get(id); + if (parent !== undefined) depth = Math.max(depth, 1 + depthOf(parent, technologies, byId, cache)); + } + cache.set(index, depth); + return depth; +} + +// Positions every card and routes every prerequisite link. The returned +// `placed` map gives each technology's column and pixel box; `edges` carries the +// link's polyline points (ready for `chamferPath`) and the dot where it meets +// the child. +export function layoutTechnologies(technologies) { + const { W, H, GX, GY: BASE_GY, PADX, PADY, LANE, LANE_MARGIN, TRACK, TRACK_MARGIN } = TECH_LAYOUT; + const byId = new Map(technologies.map((t, i) => [t.id, i])); + const cache = new Map(); + const byDepth = new Map(); + technologies.forEach((t, index) => { + const d = depthOf(index, technologies, byId, cache); + if (!byDepth.has(d)) byDepth.set(d, []); + byDepth.get(d).push(index); + }); + + // Rows within a column: order children by where their parents sit, so links + // cross as little as possible, then by name for a stable layout. + const rowOf = new Map(); + const colRow = new Map(); + const depths = [...byDepth.keys()].sort((a, b) => a - b); + for (const d of depths) { + const group = byDepth.get(d); + group.sort((a, b) => { + const key = (index) => { + const rows = (technologies[index].prerequisites || []) + .map((id) => rowOf.get(byId.get(id))) + .filter((r) => r !== undefined); + return rows.length ? rows.reduce((x, y) => x + y, 0) / rows.length : 0; + }; + return key(a) - key(b) || technologies[a].name.localeCompare(technologies[b].name); + }); + group.forEach((index, row) => { + rowOf.set(index, row); + colRow.set(index, { col: d, row }); + }); + } + const numCols = depths.length ? depths[depths.length - 1] + 1 : 0; + let maxRow = 0; + for (const pos of colRow.values()) maxRow = Math.max(maxRow, pos.row); + + // Every prerequisite link, with the columns and rows at each end. + const edges = []; + technologies.forEach((tech, to) => { + for (const id of tech.prerequisites || []) { + const from = byId.get(id); + if (from === undefined || !colRow.has(from) || !colRow.has(to)) continue; + const a = colRow.get(from); + const b = colRow.get(to); + edges.push({ from, to, fromCol: a.col, fromRow: a.row, toCol: b.col, toRow: b.row, span: b.col - a.col }); + } + }); + edges.sort( + (a, b) => + a.fromCol - b.fromCol || a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow + ); + + // Vertical lanes live in the gutter between two columns: links leaving a + // column near its left side, links arriving at the next column near its right + // side. Wide enough gutters keep those lanes apart. + const numGutters = Math.max(0, numCols - 1); + const parentRuns = Array.from({ length: numGutters }, () => []); + const childRuns = Array.from({ length: numGutters }, () => []); + for (const e of edges) { + if (e.span > 1) parentRuns[e.fromCol].push(e); + if (e.toCol >= 1) childRuns[e.toCol - 1].push(e); + } + const gutterWidth = []; + for (let g = 0; g < numGutters; g++) { + parentRuns[g].sort((a, b) => a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow); + // Child runs are ordered so the lane closest to the child belongs to the + // link whose port sits lowest. That keeps siblings from criss-crossing as + // their stubs leave one parent. + childRuns[g].sort((a, b) => b.toRow - a.toRow || a.fromCol - b.fromCol || a.fromRow - b.fromRow); + const lanes = parentRuns[g].length + childRuns[g].length; + gutterWidth[g] = lanes ? Math.max(GX, 2 * LANE_MARGIN + lanes * LANE) : GX; + } + + const colX = []; + let x = PADX; + for (let c = 0; c < numCols; c++) { + colX[c] = x; + x += W + (gutterWidth[c] || 0); + } + const width = x + PADX; + + // Horizontal tracks: one per spanning link, spread across the row gap just + // below its parent. The gap grows when several links fan out from one row. + const gapTracks = new Map(); + for (const e of edges) { + if (e.span <= 1) continue; + const k = e.fromRow + 1; + gapTracks.set(k, (gapTracks.get(k) || 0) + 1); + } + const maxTracks = Math.max(1, ...gapTracks.values()); + const GY = Math.max(BASE_GY, (maxTracks - 1) * TRACK + 2 * TRACK_MARGIN); + const rowY = (row) => PADY + row * (H + GY); + const gapCenter = (k) => PADY + k * (H + GY) - GY / 2; + const trackSeen = new Map(); + for (const e of edges) { + if (e.span <= 1) continue; + const k = e.fromRow + 1; + const n = gapTracks.get(k); + const i = trackSeen.get(k) || 0; + trackSeen.set(k, i + 1); + e.trackY = n === 1 ? gapCenter(k) : gapCenter(k) + (i - (n - 1) / 2) * TRACK; + } + + // Attach a lane x to each vertical run. + for (let g = 0; g < numGutters; g++) { + const left = colX[g] + W; + const right = colX[g + 1]; + parentRuns[g].forEach((e, i) => { + e.parentLaneX = left + LANE_MARGIN + (i + 0.5) * LANE; + }); + childRuns[g].forEach((e, i) => { + e.childLaneX = right - LANE_MARGIN - (i + 0.5) * LANE; + }); + } + + // A card has a single port each way: every dependency leaves the middle of + // its right edge and every dependency arrives at the middle of its left edge, + // so a fan of links spreads from one point rather than a row of stubs. + const midY = (row) => rowY(row) + H / 2; + for (const e of edges) { + e.outY = midY(e.fromRow); + e.inY = midY(e.toRow); + } + + const placed = new Map(); + for (const [index, pos] of colRow) { + placed.set(index, { col: pos.col, row: pos.row, x: colX[pos.col], y: rowY(pos.row) }); + } + + // Out of the parent's port, along its lane, across a track (only when the + // child is more than one column away), down the child's lane, into its port. + for (const e of edges) { + const parent = placed.get(e.from); + const child = placed.get(e.to); + const start = parent.x + W; + const end = child.x; + if (e.span <= 1) { + e.points = [[start, e.outY], [e.childLaneX, e.outY], [e.childLaneX, e.inY], [end, e.inY]]; + } else { + e.points = [ + [start, e.outY], + [e.parentLaneX, e.outY], + [e.parentLaneX, e.trackY], + [e.childLaneX, e.trackY], + [e.childLaneX, e.inY], + [end, e.inY], + ]; + } + e.dot = { x: end, y: e.inY }; + } + + const height = PADY * 2 + (maxRow + 1) * H + maxRow * GY; + return { + nodes: technologies.map((_, index) => ({ index })), + byId, + placed, + edges, + maxRow, + width, + height, + }; +} + +// A path of axis-aligned runs with each 90-degree corner cut by a short +// 45-degree chamfer. +export function chamferPath(points, size = 7) { + const pts = points.filter( + (p, i, a) => i === 0 || Math.hypot(p[0] - a[i - 1][0], p[1] - a[i - 1][1]) > 0.5 + ); + if (pts.length < 2) return ""; + let d = `M ${pts[0][0]} ${pts[0][1]}`; + for (let i = 1; i < pts.length - 1; i++) { + const p = pts[i], prev = pts[i - 1], next = pts[i + 1]; + const cross = (p[0] - prev[0]) * (next[1] - p[1]) - (p[1] - prev[1]) * (next[0] - p[0]); + if (Math.abs(cross) < 0.001) { d += ` L ${p[0]} ${p[1]}`; continue; } + const l1 = Math.hypot(p[0] - prev[0], p[1] - prev[1]); + const l2 = Math.hypot(next[0] - p[0], next[1] - p[1]); + const c = Math.min(size, l1 / 2, l2 / 2); + const u1 = [(prev[0] - p[0]) / l1, (prev[1] - p[1]) / l1]; + const u2 = [(next[0] - p[0]) / l2, (next[1] - p[1]) / l2]; + d += ` L ${p[0] + u1[0] * c} ${p[1] + u1[1] * c} L ${p[0] + u2[0] * c} ${p[1] + u2[1] * c}`; + } + const last = pts[pts.length - 1]; + return `${d} L ${last[0]} ${last[1]}`; +} + +// Every prerequisite still outstanding for the technology at `index`, at any +// depth: the trace of what stands between the nation and this research. A +// prerequisite gated behind a missing building counts as outstanding too. +export function unmetPrerequisites(index, technologies, researched, hasBuilding) { + const byId = new Map(technologies.map((t, i) => [t.id, i])); + const unmet = new Set(); + const walk = (at) => { + for (const id of technologies[at].prerequisites || []) { + const parent = byId.get(id); + if (parent === undefined) continue; + const proto = technologies[parent]; + const buildingMissing = !!proto.requiresBuilding && !hasBuilding(proto.requiresBuilding); + if (!researched.has(parent) || buildingMissing) { + if (!unmet.has(parent)) { + unmet.add(parent); + walk(parent); + } + } + } + }; + walk(index); + return unmet; +} diff --git a/client/js/ui/tech_tree_view.js b/client/js/ui/tech_tree_view.js new file mode 100644 index 0000000..aea7f00 --- /dev/null +++ b/client/js/ui/tech_tree_view.js @@ -0,0 +1,459 @@ +// The technology panel's tree: a draggable graph of cards and routed +// prerequisite links, plus the focus/progress strip above it. +// +// It is deliberately independent of the nation modal so the same view backs +// both the game and the stand-alone development page used when the technology +// catalogue is edited. The host supplies the data and the copy (a tooltip +// builder and the two order callbacks); everything else lives here. + +import { technologyCost } from "../../../shared/data.js"; +import { prerequisitesMet } from "../../../shared/rules.js"; +import { groupDigits } from "../../../shared/text_format.js"; +import { researchNode, showCardTooltip, hideCardTooltip } from "./card.js"; +import { + TECH_THEMES, + TECH_THEME_ORDER, + techTheme, + TECH_LAYOUT, + romanNumeral, + layoutTechnologies, + chamferPath, + unmetPrerequisites, +} from "./tech_tree.js"; + +const EMPTY_TECH_INFO = { + focus: -1, + progress: 0, + progressByTech: {}, + repeatCounts: {}, + buildings: new Set(), + testing: false, +}; + +let nextInstance = 0; + +export class TechnologyTreeView { + constructor({ graph, strip, tipFor, onFocusTechnology, onUnlockTechnology, assetBase }) { + // A namespace unique to this instance so two trees on one page (the game + // and the development preview do not share a page, but be safe) do not + // unbind each other's drag listeners. + this._ns = `.techpan${nextInstance++}`; + this.$graph = graph; + this.$strip = strip; + this.tipFor = tipFor || (() => []); + this.onFocusTechnology = onFocusTechnology || (() => {}); + this.onUnlockTechnology = onUnlockTechnology || (() => {}); + // Where the theme icons live, relative to the hosting page. + this.assetBase = assetBase || "assets/"; + this.technologies = []; + this.researched = new Set(); + this.techInfo = EMPTY_TECH_INFO; + this.cards = new Map(); + this.edges = []; + this.layout = null; + this.selected = null; + this.hover = null; + this._panMoved = false; + } + + // Rebuild the whole tree from the catalogue. The layout only depends on the + // catalogue, so this runs when the panel opens; `refresh` repaints states in + // place afterwards. + render({ technologies, researched, techInfo }) { + if (technologies) this.technologies = technologies; + if (researched) this.researched = researched; + if (techInfo) this.techInfo = techInfo; + const $graph = this.$graph; + if (!$graph || !$graph.length) return; + hideCardTooltip(); + $(document).off(this._ns); + $(window).off(this._ns); + $graph.empty(); + this.cards = new Map(); + this.edges = []; + this.selected = null; + this.hover = null; + this._panMoved = false; + this.$pan = null; + this.$hints = null; + this._panState = null; + if (!this.technologies.length) { + this.layout = null; + this._renderLegend(); + return; + } + const layout = layoutTechnologies(this.technologies); + this.layout = layout; + this._renderLegend(); + const { W, H } = TECH_LAYOUT; + const $pan = $("
"); + const $canvas = $("
") + .css({ width: `${layout.width}px`, height: `${layout.height}px` }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "tech-links"); + svg.setAttribute("width", layout.width); + svg.setAttribute("height", layout.height); + $canvas.append(svg); + + // Links first, so a card always paints over the line that reaches it. + for (const edge of layout.edges) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", chamferPath(edge.points)); + path.dataset.from = edge.from; + path.dataset.to = edge.to; + svg.appendChild(path); + const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + dot.setAttribute("cx", edge.dot.x); + dot.setAttribute("cy", edge.dot.y); + dot.setAttribute("r", 3); + dot.dataset.from = edge.from; + dot.dataset.to = edge.to; + svg.appendChild(dot); + this.edges.push({ from: edge.from, to: edge.to, $path: $(path), $dot: $(dot) }); + } + + for (const node of layout.nodes) { + const proto = this.technologies[node.index]; + const pos = layout.placed.get(node.index); + const $card = $("
") + .attr("data-index", node.index) + .addClass(`theme-${proto.theme}`) + .css({ left: `${pos.x}px`, top: `${pos.y}px`, width: `${W}px`, height: `${H}px` }); + const $fill = $("
"); + const $status = $("
"); + const $foot = $("
"); + const $body = $("
").append( + $("
").append( + $("").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name), + $("
").append( + $("
").text(proto.name), + $status + ) + ), + $foot + ); + $card.append($fill, $body); + const ref = { index: node.index, proto, $card, $status, $fill, $foot, $free: null }; + if (this.techInfo && this.techInfo.testing) { + ref.$free = $("").text("Unlock now") + .on("click", (event) => { event.stopPropagation(); this.onUnlockTechnology(node.index); }); + $card.append(ref.$free); + } + $card.on("mouseenter", () => { + showCardTooltip($card, this.tipFor(node.index)); + if (!this._canFocus(node.index) && !this.researched.has(node.index)) { + this.hover = node.index; + this._applyHighlight(node.index); + } + }); + $card.on("mouseleave", () => { + hideCardTooltip(); + if (this.hover === node.index) this.hover = null; + if (this.selected !== null) this._applyHighlight(this.selected); + else this._clearHighlight(); + }); + $card.on("click", () => { + // A drag that happened to start on a card must not also select it. + if (this._panMoved) { this._panMoved = false; return; } + this.selected = this.selected === node.index ? null : node.index; + this.hover = null; + this.refresh(); + }); + this.cards.set(node.index, ref); + $canvas.append($card); + } + $pan.append($canvas); + // Faint fades and chevrons on whichever edges still hide part of the tree, + // so the panel reads as pannable without a scrollbar. + this.$pan = $pan; + this.$hints = {}; + for (const side of ["top", "bottom", "left", "right"]) { + const $hint = $(`
`) + .append(""); + this.$hints[side] = $hint; + $pan.append($hint); + } + $graph.append($pan); + this._bindPan($pan, $canvas, layout.width, layout.height); + this.refresh(); + } + + // The category key that sits between the focus strip and the tree. It is a + // sibling of the pannable area, so it stays put while the tree is dragged. + _renderLegend() { + if (this.$legend) this.$legend.remove(); + this.$legend = null; + if (!this.$graph || !this.$graph.length || !this.technologies.length) return; + const $legend = $("
"); + for (const id of TECH_THEME_ORDER) { + const theme = TECH_THEMES[id]; + if (!theme) continue; + $legend.append( + $("") + .attr("data-theme", id) + .append( + $("").css("background", theme.color), + document.createTextNode(theme.name) + ) + ); + } + this.$legend = $legend; + this.$graph.before($legend); + } + + // Drag anywhere on the tree to pan it, rather than a scrollbar. + _bindPan($pan, $canvas, width, height) { + let x = 0, y = 0, moved = false; + let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0; + this._panState = { x: 0, y: 0, width, height }; + const apply = () => { + $canvas.css("transform", `translate(${x}px, ${y}px)`); + this._panState.x = x; + this._panState.y = y; + this._updateScrollHints(); + }; + const clamp = () => { + const vw = $pan.width(), vh = $pan.height(); + x = Math.max(Math.min(0, vw - width), Math.min(0, x)); + y = Math.max(Math.min(0, vh - height), Math.min(0, y)); + }; + $pan.on("mousedown", (event) => { + if (event.button !== 0) return; + dragging = true; + moved = false; + startX = event.pageX; + startY = event.pageY; + startL = x; + startT = y; + $pan.addClass("dragging"); + event.preventDefault(); + }); + $(document).on(`mousemove${this._ns}`, (event) => { + if (!dragging) return; + x = startL + (event.pageX - startX); + y = startT + (event.pageY - startY); + if (Math.abs(event.pageX - startX) > 4 || Math.abs(event.pageY - startY) > 4) moved = true; + clamp(); + apply(); + }); + $(document).on(`mouseup${this._ns}`, () => { + if (!dragging) return; + dragging = false; + // Leave the flag for the click that follows, so a drag is not a selection. + this._panMoved = moved; + $pan.removeClass("dragging"); + setTimeout(() => { this._panMoved = false; }, 0); + }); + $(window).on(`resize${this._ns}`, () => { clamp(); apply(); }); + $pan.on("click", (event) => { + if (moved) return; + if ($(event.target).closest(".tech-card").length) return; + this.selected = null; + this.refresh(); + }); + apply(); + } + + // Show a fade + chevron on every edge where the tree extends past the view. + _updateScrollHints() { + const hints = this.$hints; + const $pan = this.$pan; + const state = this._panState; + if (!hints || !$pan || !state || !$pan.length) return; + const vw = $pan.width(), vh = $pan.height(); + const { x, y, width, height } = state; + const wide = width - vw > 1; + const tall = height - vh > 1; + hints.left.toggleClass("show", wide && x < -1); + hints.right.toggleClass("show", wide && x > vw - width + 1); + hints.top.toggleClass("show", tall && y < -1); + hints.bottom.toggleClass("show", tall && y > vh - height + 1); + } + + _hasBuilding(id) { + return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id)); + } + + // Whether a technology can be focused now: not already held (unless it is + // repeatable) and with every prerequisite met. + _canFocus(index) { + const proto = this.technologies[index]; + if (!proto) return false; + if (this.researched.has(index) && !proto.repeatable) return false; + return prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasBuilding(id)); + } + + // Repaint every card and the focus strip from the current snapshot. + refresh({ researched, techInfo } = {}) { + if (researched) this.researched = researched; + if (techInfo) this.techInfo = techInfo; + if (!this.layout) { + this._renderStrip(); + return; + } + const repeatCounts = this.techInfo.repeatCounts || {}; + for (const ref of this.cards.values()) { + const proto = ref.proto; + const repeat = repeatCounts[proto.id] || 0; + const focusable = this._canFocus(ref.index); + const done = this.researched.has(ref.index) && !proto.repeatable; + const focused = this.techInfo.focus === ref.index; + const points = (this.techInfo.progressByTech || {})[ref.index] || 0; + const cost = technologyCost(proto, repeat); + const pct = cost > 0 ? Math.min(1, points / cost) : 0; + const fill = proto.repeatable ? Math.min(1, (repeat + pct) / 10) * 100 : pct * 100; + + ref.$card.removeClass("built locked researching clickable"); + ref.$card.toggleClass("clickable", focusable); + ref.$fill.css("width", `${fill}%`); + ref.$foot.empty(); + if (done) { + ref.$card.addClass("built"); + ref.$status.text("Researched").addClass("good"); + ref.$foot.text("Complete"); + } else if (!focusable) { + ref.$card.addClass("locked"); + ref.$status.text("Locked").removeClass("good"); + ref.$foot.append(researchNode(cost)); + } else if (focused) { + ref.$card.addClass("researching"); + ref.$status + .text(repeat > 0 ? `Level ${romanNumeral(repeat)} · Researching` : "Researching") + .addClass("good"); + ref.$foot + .append(researchNode(points)) + .append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`)); + } else { + ref.$status + .text(repeat > 0 ? `Level ${romanNumeral(repeat)}` : "Not researched") + .removeClass("good"); + ref.$foot.append(researchNode(cost)); + } + if (ref.$free) ref.$free.toggleClass("hidden", done); + } + this._renderStrip(); + const traced = this.selected !== null ? this.selected : this.hover; + if (traced !== null && traced !== undefined) this._applyHighlight(traced); + else this._clearHighlight(); + } + + // The focus + progress strip above the tree. It follows the selected card + // (offering a Focus button for a technology that can be researched) and falls + // back to the technology already being researched. + _renderStrip() { + const $strip = this.$strip; + if (!$strip || !$strip.length) return; + const selected = this.selected; + if (selected !== null && selected !== undefined && this.technologies[selected]) { + this._renderSelectedStrip($strip, selected); + return; + } + const focus = this.techInfo.focus; + const proto = focus >= 0 ? this.technologies[focus] : null; + if (!proto) { + $strip.addClass("idle").empty() + .append($("").text("No research focus — select a technology.")); + return; + } + $strip.removeClass("idle").empty(); + const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0; + const points = this.techInfo.progress || 0; + const cost = technologyCost(proto, repeat); + const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0; + $strip + .append(this._stripHead(proto, proto.repeatable ? `Researching toward Level ${romanNumeral(repeat + 1)}` : "Researching")) + .append( + $("
").append( + $("
").css("width", `${pct}%`), + $("") + .append(researchNode(points)) + .append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`)) + ) + ); + } + + _renderSelectedStrip($strip, index) { + const proto = this.technologies[index]; + const focusable = this._canFocus(index); + const focused = this.techInfo.focus === index; + const held = this.researched.has(index) && !proto.repeatable; + $strip.removeClass("idle").empty(); + const sub = held ? "Researched" : focused ? "Researching" : focusable ? "Ready to research" : "Locked"; + $strip.append(this._stripHead(proto, sub)); + if (focused) { + const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0; + const points = this.techInfo.progress || 0; + const cost = technologyCost(proto, repeat); + const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0; + $strip.append( + $("
").append( + $("
").css("width", `${pct}%`), + $("") + .append(researchNode(points)) + .append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`)) + ) + ); + } else { + const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0; + $strip.append( + $("
").append( + researchNode(technologyCost(proto, repeat)), + document.createTextNode(held ? " complete" : focusable ? " to research" : " — prerequisites missing") + ) + ); + } + if (focusable && !focused) { + $strip.append( + $("").text("Focus") + .on("click", () => { + // Show the commitment at once; a snapshot confirms it a moment later. + this.techInfo = { + ...this.techInfo, + focus: index, + progress: (this.techInfo.progressByTech || {})[index] || 0, + }; + this.selected = null; + this.onFocusTechnology(index); + this.refresh(); + }) + ); + } + } + + _stripHead(proto, sub) { + return $("").append( + $("").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name), + $("
").append( + $("
").text(proto.name), + $("
").text(sub) + ) + ); + } + + // Trace every outstanding requirement of a locked card, and the links + // between them, so a chain of missing research reads at a glance. + _applyHighlight(index) { + this._clearHighlight(); + const ref = this.cards.get(index); + if (ref) ref.$card.addClass("warn-self"); + const unmet = unmetPrerequisites(index, this.technologies, this.researched, (id) => this._hasBuilding(id)); + for (const i of unmet) { + const card = this.cards.get(i); + if (card) card.$card.addClass("warn"); + } + for (const edge of this.edges) { + if (unmet.has(edge.from) && (edge.to === index || unmet.has(edge.to))) { + edge.$path.addClass("warn"); + edge.$dot.addClass("warn"); + } + } + } + + _clearHighlight() { + for (const ref of this.cards.values()) ref.$card.removeClass("warn warn-self"); + for (const edge of this.edges || []) { + edge.$path.removeClass("warn"); + edge.$dot.removeClass("warn"); + } + } +} diff --git a/shared/data/technologies.js b/shared/data/technologies.js index 248e4bf..8cdfe04 100644 --- a/shared/data/technologies.js +++ b/shared/data/technologies.js @@ -25,24 +25,9 @@ import { effect, } from "./effects.js"; -// The tabs the technology panel groups research under. -export const TECH_THEMES = [ - { id: "science", name: "Science" }, - { id: "economy", name: "Economy" }, - { id: "society", name: "Society" }, - { id: "military", name: "Military" }, -]; - +// The lowest tier: every technology below is a root, available from the start. +// Research is focused, so nothing is unlocked by a single gateway technology. export const TECHNOLOGIES = [ - { - id: "scientific_method", - name: "Scientific Method", - description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.", - theme: "science", - cost: 20, - prerequisites: [], - effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_PRODUCTION, 0.02, 1.0)], - }, { id: "industrial_automation", name: "Industrial Automation", @@ -50,7 +35,7 @@ export const TECHNOLOGIES = [ theme: "economy", repeatable: true, cost: 60, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)], }, { @@ -59,7 +44,7 @@ export const TECHNOLOGIES = [ description: "Clean generation cuts waste and lifts national production.", theme: "economy", cost: 80, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0)], }, { @@ -68,7 +53,7 @@ export const TECHNOLOGIES = [ description: "Machines that reason in superposition. A step change in the pace of discovery.", theme: "science", cost: 120, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)], }, { @@ -97,7 +82,7 @@ export const TECHNOLOGIES = [ theme: "military", // 300,000 research points: a project a whole economy bends toward. cost: 3_000, - prerequisites: ["scientific_method"], + prerequisites: [], // The technology is unlocked by the nation's first uranium enrichment // centre; without one it cannot be focused at all. requiresBuilding: "uranium_enrichment_center", @@ -109,7 +94,7 @@ export const TECHNOLOGIES = [ description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.", theme: "society", cost: 250, - prerequisites: ["scientific_method"], + prerequisites: [], // Unlocks the spy unit rather than carrying a numeric effect of its own. unlocks: true, effects: [], @@ -136,7 +121,7 @@ export const TECHNOLOGIES = [ cost: 40, repeatable: true, repeatCostGrowth: 0.02, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)], }, { @@ -147,7 +132,7 @@ export const TECHNOLOGIES = [ cost: 40, repeatable: true, repeatCostGrowth: 0.02, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)], }, { @@ -158,7 +143,7 @@ export const TECHNOLOGIES = [ cost: 50, repeatable: true, repeatCostGrowth: 0.02, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)], }, { @@ -169,7 +154,7 @@ export const TECHNOLOGIES = [ cost: 50, repeatable: true, repeatCostGrowth: 0.02, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)], }, { @@ -180,7 +165,7 @@ export const TECHNOLOGIES = [ cost: 50, repeatable: true, repeatCostGrowth: 0.02, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)], }, @@ -217,7 +202,7 @@ export const TECHNOLOGIES = [ cost: 50, repeatable: true, repeatCostGrowth: 0.03, - prerequisites: ["scientific_method"], + prerequisites: [], effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)], }, { diff --git a/tests/data_test.js b/tests/data_test.js index 5c8fba1..5805773 100644 --- a/tests/data_test.js +++ b/tests/data_test.js @@ -19,7 +19,7 @@ export class DataTest extends TestCase { this.assertEqual(protoUnitById("modern_infantry"), PROTO_UNITS.find((u) => u.id === "modern_infantry")); this.assertEqual(buildingById("barracks"), BUILDINGS.find((b) => b.id === "barracks")); this.assertEqual(governmentById("republic"), GOVERNMENTS[0]); - this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]); + this.assertEqual(technologyById("industrial_automation"), TECHNOLOGIES[0]); this.assertNull(protoUnitById("nope")); this.assertNull(buildingById("nope")); this.assertNull(governmentById("nope")); diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index 3d7e61f..ecc24b5 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -147,7 +147,11 @@ export class GameScreenTest extends TestCase { env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertSize(env.$("#government-list .row-card"), 3); - this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length); + this.assertSize( + env.$("#technology-list .tech-card"), + TECHNOLOGIES.length, + "the whole technology graph is drawn" + ); screen.leave(); } finally { teardownDom(env); @@ -959,7 +963,7 @@ export class GameScreenTest extends TestCase { screen.enter(); const state = smallState(); state.budgets.set(0, 1.0e15); - const method = state.technologies.findIndex((t) => t.id === "scientific_method"); + const method = state.technologies.findIndex((t) => t.id === "renewable_energy"); state.requestFocusTechnology(0, method); state._addResearch(0, state.getTechnologyCost(0, method)); screen.onState(state.snapshot(0)); @@ -1389,14 +1393,12 @@ export class GameScreenTest extends TestCase { screen.onState(smallState().snapshot(0)); env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab=technology]").click(); - const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); - env.$("#technology-list .building-tile") - .eq(method) - .find(".bt-upgrade") - .click(); + const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing"); + env.$(`#technology-list .tech-card[data-index="${quantum}"]`).click(); + env.$("#technology-research .tech-research-focus").click(); this.assertEqual(network.orders[0], { type: "focus_technology", - technology: method, + technology: quantum, }); screen.leave(); } finally { diff --git a/tests/game_state_buildings_test.js b/tests/game_state_buildings_test.js index c965ccd..84e6616 100644 --- a/tests/game_state_buildings_test.js +++ b/tests/game_state_buildings_test.js @@ -343,7 +343,7 @@ export class GameStateBuildingsTest extends TestCase { const index = this.addBuilding(state, building); this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); // Research only accumulates into a focused technology, so focus one. - const method = state.technologies.findIndex((t) => t.id === "scientific_method"); + const method = state.technologies.findIndex((t) => t.id === "renewable_energy"); this.assertTrue(state.requestFocusTechnology(0, method)); this.assertEqual(state.getTechnologyProgress(0), 0); state.advanceHour(); diff --git a/tests/game_state_government_test.js b/tests/game_state_government_test.js index 6da03d8..bb0b39b 100644 --- a/tests/game_state_government_test.js +++ b/tests/game_state_government_test.js @@ -55,7 +55,7 @@ export class GameStateGovernmentTest extends TestCase { state.budgets.set(0, BIG_BUDGET); const technocracy = state.governments.findIndex((g) => g.id === "technocracy"); this.assertTrue(state.requestSetGovernment(0, technocracy)); - const method = state.technologies.findIndex((t) => t.id === "scientific_method"); + const method = state.technologies.findIndex((t) => t.id === "renewable_energy"); this.assertTrue(state.requestFocusTechnology(0, method)); this.assertEqual(state.getTechnologyProgress(0), 0); state.advanceHour(); diff --git a/tests/game_state_technology_test.js b/tests/game_state_technology_test.js index 3e82565..d1796e0 100644 --- a/tests/game_state_technology_test.js +++ b/tests/game_state_technology_test.js @@ -1,6 +1,12 @@ import { TestCase } from "./framework/test_case.js"; import { smallState, stripPorts } from "./framework/helpers.js"; -import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js"; +import { + TECHNOLOGIES, + EFFECT_INFANTRY_ATTACK, + EFFECT_CONSTRUCTION_SPEED, + EFFECT_PRODUCTION, + EFFECT_RESEARCH, +} from "../shared/data.js"; import { NEWS_TECHNOLOGY } from "../shared/data/relations.js"; export class GameStateTechnologyTest extends TestCase { @@ -18,11 +24,11 @@ export class GameStateTechnologyTest extends TestCase { } test_catalogue_links_prerequisites() { - const method = TECHNOLOGIES.find((t) => t.id === "scientific_method"); - this.assertEmpty(method.prerequisites); - this.assertGreater(method.cost, 0); - const automation = TECHNOLOGIES.find((t) => t.id === "industrial_automation"); - this.assertEqual(automation.prerequisites, ["scientific_method"]); + const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation"); + this.assertEmpty(root.prerequisites, "the lowest tier needs nothing"); + this.assertGreater(root.cost, 0); + const space = TECHNOLOGIES.find((t) => t.id === "space_program"); + this.assertEqual(space.prerequisites, ["industrial_automation", "quantum_computing"]); } test_every_civilisation_starts_with_nothing_researched() { @@ -34,7 +40,7 @@ export class GameStateTechnologyTest extends TestCase { test_research_points_fill_the_focused_technology() { const state = smallState(); - const index = this.index(state, "scientific_method"); + const index = this.index(state, "quantum_computing"); this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted"); const cost = state.getTechnologyCost(0, index); // Points flow in as they are created; just short of the cost leaves it in @@ -52,7 +58,7 @@ export class GameStateTechnologyTest extends TestCase { // A treasury deep in the red does not stop a nation focusing or accruing // research: the shortfall is central-bank debt, not a spending limit. state.budgets.set(0, -1_000_000); - const index = this.index(state, "scientific_method"); + const index = this.index(state, "quantum_computing"); this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt"); state._addResearch(0, 5); this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue"); @@ -61,7 +67,6 @@ export class GameStateTechnologyTest extends TestCase { test_switching_focus_keeps_committed_progress() { const state = smallState(); - this.complete(state, "scientific_method"); const weapons = this.index(state, "infantry_weapons"); const armour = this.index(state, "body_armour"); this.assertTrue(state.requestFocusTechnology(0, weapons)); @@ -82,7 +87,7 @@ export class GameStateTechnologyTest extends TestCase { test_nothing_is_researched_without_a_focus() { const state = smallState(); - const index = this.index(state, "scientific_method"); + const index = this.index(state, "quantum_computing"); // Points created with nothing focused are simply not stored anywhere. state._addResearch(0, state.getTechnologyCost(0, index) * 10); this.assertFalse(state.isResearched(0, index)); @@ -104,38 +109,41 @@ export class GameStateTechnologyTest extends TestCase { test_prerequisites_unlock_the_tree() { const state = smallState(); + const space = this.index(state, "space_program"); + this.assertFalse(state.requestFocusTechnology(0, space), "locked before its prerequisites"); const automation = this.index(state, "industrial_automation"); - this.assertFalse(state.requestFocusTechnology(0, automation), "locked before its prerequisite"); - this.complete(state, "scientific_method"); - this.assertFalse(state.isResearched(0, automation), "a prerequisite is not auto-granted"); this.complete(state, "industrial_automation"); this.assertTrue(state.isResearched(0, automation)); + this.assertFalse(state.canResearchTechnology(0, space), "one prerequisite is not enough"); this.complete(state, "quantum_computing"); - this.assertTrue(state.requestFocusTechnology(0, this.index(state, "space_program"))); + this.assertTrue(state.requestFocusTechnology(0, space), "both prerequisites met"); } test_technology_effects_are_folded_into_gdp() { const state = smallState(); stripPorts(state, 0); + const index = this.index(state, "renewable_energy"); + const added = TECHNOLOGIES[index].effects.find((e) => e.stat === EFFECT_PRODUCTION).baseValue; + const base = state.getNationalModifiers(0)[EFFECT_PRODUCTION] || 0; const before = state.getPlayerGdp(0); - this.complete(state, "scientific_method"); - this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01); + this.complete(state, "renewable_energy"); + this.assertApprox(state.getPlayerGdp(0), (before * (1 + base + added)) / (1 + base), 0.01); } test_researched_technology_adds_its_research_rate() { const withTech = smallState(); const without = smallState(); - this.complete(withTech, "scientific_method"); + this.complete(withTech, "quantum_computing"); this.assertApprox( withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate, - 1.0, + 4.0, 1e-9 ); } test_snapshot_exposes_technologies_and_progress() { const state = smallState(); - const index = this.complete(state, "scientific_method"); + const index = this.complete(state, "quantum_computing"); const snapshot = state.snapshot(0); this.assertSize(snapshot.technologies, state.technologies.length); this.assertSize(snapshot.researched, state.civilisations.length); @@ -145,7 +153,6 @@ export class GameStateTechnologyTest extends TestCase { test_repeatable_research_costs_two_percent_more_each_level() { const state = smallState(); - state.researched.get(0).add(this.index(state, "scientific_method")); const index = this.index(state, "infantry_weapons"); const base = state.getTechnologyCost(0, index); this.assertTrue(state.requestFocusTechnology(0, index), "first level"); @@ -165,7 +172,6 @@ export class GameStateTechnologyTest extends TestCase { test_repeatable_effects_stack_with_each_level() { const state = smallState(); - state.researched.get(0).add(this.index(state, "scientific_method")); const weapons = this.index(state, "infantry_weapons"); const construction = this.index(state, "construction_technology"); for (let level = 0; level < 2; level++) { @@ -182,10 +188,10 @@ export class GameStateTechnologyTest extends TestCase { test_repeatable_research_notifies_without_news() { const state = smallState(); - this.complete(state, "scientific_method"); + this.complete(state, "quantum_computing"); this.complete(state, "body_armour"); const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY); - const normalNotice = notices.find((entry) => entry.technology === "scientific_method"); + const normalNotice = notices.find((entry) => entry.technology === "quantum_computing"); const repeatNotice = notices.find((entry) => entry.technology === "body_armour"); this.assertNotNull(normalNotice); this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event"); @@ -204,7 +210,6 @@ export class GameStateTechnologyTest extends TestCase { test_completing_a_repeatable_keeps_it_focused() { const state = smallState(); - state.researched.get(0).add(this.index(state, "scientific_method")); const index = this.complete(state, "infantry_weapons"); this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done"); this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused"); @@ -216,7 +221,7 @@ export class GameStateTechnologyTest extends TestCase { test_research_costs_a_hundred_times_the_catalogue_price() { const state = smallState(); - const index = this.index(state, "scientific_method"); + const index = this.index(state, "quantum_computing"); const proto = TECHNOLOGIES[index]; this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100); } diff --git a/tests/modals_test.js b/tests/modals_test.js index a28c191..bb004f9 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -451,25 +451,24 @@ export class NationModalTest extends TestCase { }; modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length); - this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length); const governmentRows = env.$("#government-list .row-card"); this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked"); governmentRows.eq(1).find(".actions button").click(); this.assertEqual(government, 1); - // A prerequisite-gated technology starts locked. - const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); - const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation"); - const techRows = env.$("#technology-list .building-tile"); - this.assertEqual(techRows.eq(automation).find(".bt-upgrade").text(), "Locked"); + // A gated technology starts locked; researching its prerequisite unlocks + // it, then selecting it offers a Focus button in the strip. + const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing"); + const litho = TECHNOLOGIES.findIndex((t) => t.id === "advanced_lithography"); + const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`); + this.assertTrue(card(litho).hasClass("locked"), "a gated technology starts locked"); - // Researching its prerequisite unlocks it. - modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6); - this.assertTrue(techRows.eq(method).find(".bt-upgrade").text().startsWith("Researched")); - this.assertTrue(techRows.eq(automation).find(".bt-upgrade").text().startsWith("Focus")); - techRows.eq(automation).find(".bt-upgrade").click(); - this.assertEqual(technology, automation); + modal.refresh(0, new Set([quantum]), 1.0e9, 1.0e15, 1.0e6); + this.assertFalse(card(litho).hasClass("locked"), "the prerequisite unlocks it"); + card(litho).click(); + env.$("#technology-research .tech-research-focus").click(); + this.assertEqual(technology, litho); env.$("#modal-nation .tab[data-tab=technology]").click(); this.assertTrue(env.$("#tab-technology").hasClass("active")); @@ -482,21 +481,25 @@ export class NationModalTest extends TestCase { } } - async test_technology_theme_tabs_filter_the_list() { + async test_the_technology_tree_draws_every_technology() { const env = await setupDom(); try { const modal = new NationModal(); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); - const $themes = env.$("#technology-themes .subtab"); - this.assertGreater($themes.length, 1, "the technologies are split into themes"); - const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); + this.assertSize(env.$("#technology-list .tech-card"), TECHNOLOGIES.length, "one card per technology"); + this.assertSize(env.$("#technology-list .tech-pan"), 1, "the tree is draggable"); + for (const side of ["top", "bottom", "left", "right"]) { + this.assertSize(env.$(`#technology-list .tech-hint-${side} .tech-hint-arrow`), 1, `a ${side} pan clue is present`); + } + const themed = env.$("#technology-list .tech-card").filter((_, el) => + el.classList.contains(`theme-${TECHNOLOGIES[Number(el.dataset.index)].theme}`) + ); + this.assertSize(themed, TECHNOLOGIES.length, "every card carries its theme's colour"); + this.assertSize(env.$(".tech-legend .tech-legend-item"), 4, "the category legend keys every theme"); + const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing"); const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation"); - const rows = env.$("#technology-list .building-tile"); - this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme"); - this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden"); - $themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click"); - this.assertEqual(rows.eq(method).css("display"), "none", "science is hidden"); - this.assertNotEqual(rows.eq(automation).css("display"), "none", "the economy theme shows"); + const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`); + this.assertTrue(card(quantum).length > 0 && card(automation).length > 0, "every theme shares the graph"); modal.close(); } finally { teardownDom(env); @@ -507,23 +510,22 @@ export class NationModalTest extends TestCase { const env = await setupDom(); try { const modal = new NationModal(); - const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); - modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, { + modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, { focus: -1, progress: 0, repeatCounts: { infantry_weapons: 3 }, }); const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); - const card = env.$("#technology-list .building-tile").eq(index); - this.assertTrue(card.find(".bt-level").text().includes("Level 3"), "the level is shown"); - const $meta = card.find(".bt-meta"); + const card = env.$(`#technology-list .tech-card[data-index="${index}"]`); + this.assertTrue(card.find(".bt-level").text().includes("Level III"), "the level is Roman"); + const $foot = card.find(".bt-foot"); this.assertEqual( - $meta.find("img[src$='icon_research.svg']").length, + $foot.find("img[src$='icon_research.svg']").length, 1, "the erlenmeyer icon stands in for the research unit" ); - this.assertTrue($meta.text().length > 0, `the next level is priced: ${$meta.text()}`); - this.assertFalse($meta.text().includes("RP"), "no text 'RP' unit is left"); + this.assertTrue($foot.text().length > 0, `the next level is priced: ${$foot.text()}`); + this.assertFalse($foot.text().includes("RP"), "no text 'RP' unit is left"); modal.close(); } finally { teardownDom(env); @@ -618,21 +620,22 @@ export class NationModalTest extends TestCase { const env = await setupDom(); try { const modal = new NationModal(); - const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); + const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing"); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, { - focus: method, - progress: 10, + focus: quantum, + progress: 6000, + progressByTech: { [quantum]: 6000 }, repeatCounts: {}, }); - const card = env.$("#technology-list .building-tile").eq(method); - this.assertTrue(card.find(".bt-upgrade .bt-progress-fill").length > 0, "the button is the bar"); - this.assertNotEqual(card.find(".bt-upgrade .bt-progress-fill").css("width"), "0%", "the bar is filled"); - this.assertEqual( - card.find(".bt-meta").text(), - `10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`, - "X / Y beside it" + const $strip = env.$("#technology-research"); + this.assertTrue($strip.find(".tech-research-fill").length > 0, "the strip carries the bar"); + this.assertNotEqual($strip.find(".tech-research-fill").css("width"), "0%", "the bar is filled"); + this.assertTrue( + $strip.find(".tech-research-count").text().includes(`${groupDigits(6000)} / ${groupDigits(technologyCost(TECHNOLOGIES[quantum]))}`), + "the committed and total points are shown beside it" ); - this.assertEqual(card.find(".bt-up-label").text(), "Focused", "no cost on the button"); + const card = env.$(`#technology-list .tech-card[data-index="${quantum}"]`); + this.assertTrue(card.hasClass("researching"), "the focused card is marked"); modal.close(); } finally { teardownDom(env); @@ -645,7 +648,7 @@ export class NationModalTest extends TestCase { const modal = new NationModal(); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5); const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); - env.$("#technology-list .building-tile").eq(infantry).find(".bt-upgrade").trigger("mouseenter"); + env.$(`#technology-list .tech-card[data-index="${infantry}"]`).trigger("mouseenter"); this.assertTrue( env.$(".card-tooltip").text().includes("+1.0% infantry attack"), "the effect is in the hover detail" @@ -685,15 +688,16 @@ export class NationModalTest extends TestCase { technology = index; }; modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0); + const firstCount = env.$("#technology-list .tech-card").length; modal.close(); // Reopen with enough research to afford the first technology. modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments"); - this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length, "no stacked technologies"); - const button = env.$("#technology-list .building-tile").first().find(".bt-upgrade"); - this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget"); - button.click(); - this.assertEqual(technology, 0); + this.assertSize(env.$("#technology-list .tech-card"), firstCount, "no stacked technologies"); + const first = env.$("#technology-list .tech-card").first(); + first.click(); + env.$("#technology-research .tech-research-focus").click(); + this.assertEqual(technology, Number(first.attr("data-index"))); } finally { teardownDom(env); } diff --git a/tests/resources_test.js b/tests/resources_test.js index 17c5579..263bd1d 100644 --- a/tests/resources_test.js +++ b/tests/resources_test.js @@ -365,7 +365,7 @@ export class ResourceModelTest extends TestCase { state.budgets.set(0, 1e15); const city = cityOf(state, 0); grantBuilding(state, city, "research_lab"); - const tech = state.technologies.findIndex((t) => t.id === "scientific_method"); + const tech = state.technologies.findIndex((t) => t.id === "renewable_energy"); this.assertTrue(state.requestFocusTechnology(0, tech)); const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0; // With a store of high-tech the lab's one point an hour comes through. diff --git a/tests/rules_test.js b/tests/rules_test.js index 6535cf8..de21076 100644 --- a/tests/rules_test.js +++ b/tests/rules_test.js @@ -80,21 +80,21 @@ export class RulesTest extends TestCase { } test_prerequisites_met() { - const method = TECHNOLOGIES.find((t) => t.id === "scientific_method"); - this.assertEmpty(method.prerequisites); - this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES), "a root tech needs nothing"); + const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation"); + this.assertEmpty(root.prerequisites); + this.assertTrue(prerequisitesMet(root, new Set(), TECHNOLOGIES), "a root tech needs nothing"); const space = TECHNOLOGIES.find((t) => t.id === "space_program"); const index = (id) => TECHNOLOGIES.findIndex((t) => t.id === id); this.assertFalse(prerequisitesMet(space, new Set(), TECHNOLOGIES), "missing all prerequisites"); this.assertFalse( - prerequisitesMet(space, new Set([index("scientific_method"), index("industrial_automation")]), TECHNOLOGIES), + prerequisitesMet(space, new Set([index("industrial_automation")]), TECHNOLOGIES), "still missing one" ); this.assertTrue( prerequisitesMet( space, - new Set([index("scientific_method"), index("industrial_automation"), index("quantum_computing")]), + new Set([index("industrial_automation"), index("quantum_computing")]), TECHNOLOGIES ), "all prerequisites met" diff --git a/tests/tech_tree_test.js b/tests/tech_tree_test.js new file mode 100644 index 0000000..cafe52b --- /dev/null +++ b/tests/tech_tree_test.js @@ -0,0 +1,187 @@ +import { TestCase } from "./framework/test_case.js"; +import { TECHNOLOGIES } from "../shared/data.js"; +import { + romanNumeral, + layoutTechnologies, + chamferPath, + unmetPrerequisites, + TECH_LAYOUT, +} from "../client/js/ui/tech_tree.js"; + +const indexOf = (id) => TECHNOLOGIES.findIndex((t) => t.id === id); + +// Every axis-aligned run of every link, as {x1,y1,x2,y2,label,port}. The first +// and last run are the port stubs, which several links sharing a card port are +// allowed to overlap (they fan out of, or converge into, one point). +function segments(layout) { + const runs = []; + for (const e of layout.edges) { + const label = `${TECHNOLOGIES[e.from].id}->${TECHNOLOGIES[e.to].id}`; + for (let i = 1; i < e.points.length; i++) { + const [x1, y1] = e.points[i - 1]; + const [x2, y2] = e.points[i]; + if (Math.hypot(x2 - x1, y2 - y1) < 0.5) continue; + const port = i === 1 || i === e.points.length - 1; + runs.push({ x1, y1, x2, y2, label, port }); + } + } + return runs; +} + +const horizontal = (s) => Math.abs(s.y1 - s.y2) < 0.01; +const vertical = (s) => Math.abs(s.x1 - s.x2) < 0.01; + +export class TechTreeTest extends TestCase { + test_repeatable_levels_are_roman_up_to_ten() { + this.assertEqual(romanNumeral(1), "I"); + this.assertEqual(romanNumeral(4), "IV"); + this.assertEqual(romanNumeral(9), "IX"); + this.assertEqual(romanNumeral(10), "X"); + // Beyond the catalogue's practical reach a plain number is used. + this.assertEqual(romanNumeral(11), "11"); + } + + test_column_is_the_distance_from_a_root() { + const layout = layoutTechnologies(TECHNOLOGIES); + this.assertEqual(layout.placed.get(indexOf("industrial_automation")).col, 0, "a root opens column 0"); + this.assertEqual(layout.placed.get(indexOf("quantum_computing")).col, 0); + this.assertEqual(layout.placed.get(indexOf("space_program")).col, 1, "one step from a root"); + this.assertEqual(layout.placed.get(indexOf("fusion_power")).col, 2, "two steps from a root"); + // Every technology of a tier shares its column. + for (const [index, pos] of layout.placed) { + const roots = TECHNOLOGIES[index].prerequisites; + if (roots.length) { + const parents = roots.map((id) => layout.placed.get(indexOf(id)).col); + this.assertEqual(pos.col, Math.max(...parents) + 1, `${TECHNOLOGIES[index].id} sits one past its deepest parent`); + } else { + this.assertEqual(pos.col, 0, `${TECHNOLOGIES[index].id} is a root`); + } + } + } + + test_every_prerequisite_sits_to_the_left() { + const layout = layoutTechnologies(TECHNOLOGIES); + for (const [index, pos] of layout.placed) { + for (const id of TECHNOLOGIES[index].prerequisites) { + const parent = layout.placed.get(indexOf(id)); + this.assertTrue(parent.x < pos.x, `${TECHNOLOGIES[index].id} follows ${id}`); + } + } + } + + test_every_prerequisite_has_a_routed_link() { + const layout = layoutTechnologies(TECHNOLOGIES); + let expected = 0; + for (const tech of TECHNOLOGIES) expected += tech.prerequisites.length; + this.assertSize(layout.edges, expected, "one link per prerequisite"); + for (const e of layout.edges) { + const parent = layout.placed.get(e.from); + const child = layout.placed.get(e.to); + this.assertEqual(e.points[0][0], parent.x + TECH_LAYOUT.W, "the link leaves the parent's right edge"); + const last = e.points[e.points.length - 1]; + this.assertEqual(last[0], child.x, "the link enters the child's left edge"); + for (let i = 1; i < e.points.length; i++) { + const a = e.points[i - 1]; + const b = e.points[i]; + this.assertTrue( + Math.abs(a[0] - b[0]) < 0.5 || Math.abs(a[1] - b[1]) < 0.5, + "every run is axis-aligned" + ); + } + } + } + + test_links_are_chamfered_without_curves() { + const layout = layoutTechnologies(TECHNOLOGIES); + const edge = layout.edges.find( + (e) => TECHNOLOGIES[e.from].id === "quantum_computing" && TECHNOLOGIES[e.to].id === "space_program" + ); + const d = chamferPath(edge.points); + this.assertTrue(d.startsWith("M "), "the path starts with a move"); + this.assertFalse(d.includes("NaN"), "no coordinate is missing"); + this.assertFalse(d.includes("C "), "there is no bezier curve"); + } + + // The whole point of the lane routing: distinct dependencies must never be + // drawn on top of each other. The short stubs that fan out of a shared card + // port are the one exception, allowed to lie along the same line. + test_no_two_links_overlap() { + const layout = layoutTechnologies(TECHNOLOGIES); + const runs = segments(layout); + for (let i = 0; i < runs.length; i++) { + for (let j = i + 1; j < runs.length; j++) { + const a = runs[i]; + const b = runs[j]; + if (a.port || b.port) continue; + if (horizontal(a) && horizontal(b) && Math.abs(a.y1 - b.y1) < 0.01) { + const lo = Math.max(Math.min(a.x1, a.x2), Math.min(b.x1, b.x2)); + const hi = Math.min(Math.max(a.x1, a.x2), Math.max(b.x1, b.x2)); + this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`); + } + if (vertical(a) && vertical(b) && Math.abs(a.x1 - b.x1) < 0.01) { + const lo = Math.max(Math.min(a.y1, a.y2), Math.min(b.y1, b.y2)); + const hi = Math.min(Math.max(a.y1, a.y2), Math.max(b.y1, b.y2)); + this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`); + } + } + } + } + + test_every_card_has_a_single_port_each_way() { + const layout = layoutTechnologies(TECHNOLOGIES); + const { W, H } = TECH_LAYOUT; + for (const e of layout.edges) { + const parent = layout.placed.get(e.from); + const child = layout.placed.get(e.to); + const start = e.points[0]; + const end = e.points[e.points.length - 1]; + this.assertEqual(start[0], parent.x + W, "the link leaves the parent's right edge"); + this.assertEqual(start[1], parent.y + H / 2, "every outgoing link leaves the middle of the right edge"); + this.assertEqual(end[0], child.x, "the link enters the child's left edge"); + this.assertEqual(end[1], child.y + H / 2, "every incoming link arrives at the middle of the left edge"); + } + } + + test_no_link_passes_behind_a_card() { + const layout = layoutTechnologies(TECHNOLOGIES); + const { W, H } = TECH_LAYOUT; + for (const run of segments(layout)) { + for (const [index, pos] of layout.placed) { + const left = pos.x, right = pos.x + W; + const top = pos.y, bottom = pos.y + H; + if (horizontal(run)) { + const xlo = Math.min(run.x1, run.x2); + const xhi = Math.max(run.x1, run.x2); + const crosses = run.y1 > top + 0.5 && run.y1 < bottom - 0.5 && + xhi > left + 0.5 && xlo < right - 0.5; + this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`); + } else { + const ylo = Math.min(run.y1, run.y2); + const yhi = Math.max(run.y1, run.y2); + const crosses = run.x1 > left + 0.5 && run.x1 < right - 0.5 && + yhi > top + 0.5 && ylo < bottom - 0.5; + this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`); + } + } + } + } + + test_unmet_prerequisites_recurse_to_the_roots() { + const hasBuilding = () => false; + const deep = unmetPrerequisites(indexOf("fusion_power"), TECHNOLOGIES, new Set(), hasBuilding); + this.assertTrue(deep.has(indexOf("space_program"))); + this.assertTrue(deep.has(indexOf("quantum_computing"))); + this.assertTrue(deep.has(indexOf("industrial_automation")), "the chain is followed to the roots"); + + // A met prerequisite stops the walk: its own requirements are satisfied. + const shallow = unmetPrerequisites( + indexOf("fusion_power"), + TECHNOLOGIES, + new Set([indexOf("industrial_automation")]), + hasBuilding + ); + this.assertFalse(shallow.has(indexOf("industrial_automation"))); + this.assertTrue(shallow.has(indexOf("space_program"))); + this.assertTrue(shallow.has(indexOf("quantum_computing"))); + } +} diff --git a/tests/treaty_test.js b/tests/treaty_test.js index 35b9600..929ed7f 100644 --- a/tests/treaty_test.js +++ b/tests/treaty_test.js @@ -263,7 +263,7 @@ export class TreatyTest extends TestCase { test_joint_research_shares_points_but_not_military_ones() { const state = smallState(); this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH)); - const civil = state.technologies.findIndex((t) => t.id === "scientific_method"); + const civil = state.technologies.findIndex((t) => t.id === "renewable_energy"); const military = state.technologies.findIndex((t) => t.id === "body_armour"); state.focusTechnology.set(0, civil); state.focusTechnology.set(1, civil); diff --git a/tests/wmd_test.js b/tests/wmd_test.js index dadbde4..5b7791d 100644 --- a/tests/wmd_test.js +++ b/tests/wmd_test.js @@ -69,7 +69,6 @@ export class WmdTest extends TestCase { test_nuclear_weapons_research_needs_an_enrichment_centre() { const state = smallState(); const index = techIndex(state, "nuclear_weapons"); - research(state, 0, "scientific_method"); this.assertFalse( state.canResearchTechnology(0, index), "the bomb is locked without a uranium enrichment centre" diff --git a/tools/tech-tree.html b/tools/tech-tree.html new file mode 100644 index 0000000..78032c7 --- /dev/null +++ b/tools/tech-tree.html @@ -0,0 +1,108 @@ + + + + + +Technology tree — preview + + + + + + + + +