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
+
+
+
+
+
+
+
+ Technology tree — preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+