Rebuilt the technology panel as one lane-routed draggable graph

This commit is contained in:
2026-09-28 01:06:07 +02:00
parent 3c737e9ef9
commit 9fc94a8e00
20 changed files with 1364 additions and 295 deletions
+191 -7
View File
@@ -1703,14 +1703,198 @@ input:focus, select:focus { border-color: var(--accent); }
.row-card .budget-detail { margin-top: 4px; } .row-card .budget-detail { margin-top: 4px; }
.row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; } .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 technology tree: one theme at a time, cards placed on a canvas with the
the tab so many read at once, grouped by the theme sub-tabs. */ dependency links drawn into an SVG layer beneath them. */
#technology-list { .tech-research {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, 178px); align-items: center;
gap: 8px; gap: 14px;
align-content: start; 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 /* 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 committed share fills behind, while the "Focused" label and the points sit on
top. The disabled dimming is lifted so the bar stays legible. */ top. The disabled dimming is lifted so the bar stays legible. */
+2 -2
View File
@@ -373,8 +373,8 @@
</div> </div>
<div class="tab-page" id="tab-technology"> <div class="tab-page" id="tab-technology">
<div id="technology-sub" class="modal-sub"></div> <div id="technology-sub" class="modal-sub"></div>
<div id="technology-themes" class="subtabs"></div> <div id="technology-research" class="tech-research"></div>
<div id="technology-list" class="modal-body"></div> <div id="technology-list" class="modal-body tech-tree-body"></div>
</div> </div>
<div class="tab-page" id="tab-diplomacy"> <div class="tab-page" id="tab-diplomacy">
<div id="diplomacy-sub" class="modal-sub"></div> <div id="diplomacy-sub" class="modal-sub"></div>
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit. // 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. // The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [ export const DEVLOG = [
{
"hash": "3c737e9",
"date": "2026-09-28",
"subject": "Derived every description figure from the rule it describes",
"body": ""
},
{ {
"hash": "c85f79f", "hash": "c85f79f",
"date": "2026-09-27", "date": "2026-09-27",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24", "date": "2026-09-24",
"subject": "Cut the migration sweep's repeated lookups and population churn", "subject": "Cut the migration sweep's repeated lookups and population churn",
"body": "" "body": ""
},
{
"hash": "a219cf6",
"date": "2026-09-24",
"subject": "Flew aircraft and missiles straight and let strikes target fogged cities",
"body": ""
} }
]; ];
+23 -161
View File
@@ -9,7 +9,6 @@ import {
POLICIES, POLICIES,
PROPAGANDA, PROPAGANDA,
policyById, policyById,
TECH_THEMES,
technologyCost, technologyCost,
RESOURCES, RESOURCES,
RESOURCE_IDS, RESOURCE_IDS,
@@ -25,7 +24,8 @@ import { renderMonthlyBudget } from "./budget.js";
import { effectNodes, money, quotedMoney } from "./format.js"; import { effectNodes, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js"; import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.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 { resourceNode, valueNode } from "../ui/value.js";
import { lineChart } from "../ui/line_chart.js"; import { lineChart } from "../ui/line_chart.js";
import { trendArrow } from "../ui/trend.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 { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.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 { export class NationModal {
constructor() { constructor() {
this.$modal = $("#modal-nation"); this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub"); this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list"); this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub"); this.$technologySub = $("#technology-sub");
this.$technologyStrip = $("#technology-research");
this.$technologyList = $("#technology-list"); this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub"); this.$budgetSub = $("#budget-sub");
this.$budgetMonths = $("#budget-months"); this.$budgetMonths = $("#budget-months");
@@ -101,9 +93,8 @@ export class NationModal {
this.currentGovernment = 0; this.currentGovernment = 0;
this.researched = new Set(); this.researched = new Set();
this.governmentRows = []; this.governmentRows = [];
this.technologyRows = []; // The technology tree is drawn by a self-contained view, shared with the
// Focused research: which theme tab is open and the current focus figures. // stand-alone catalogue preview; the modal only feeds it the snapshot.
this.activeTheme = TECH_THEMES[0].id;
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} }; this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null; this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 }; this.diplomacySort = { key: "name", dir: 1 };
@@ -148,6 +139,13 @@ export class NationModal {
this.onConfirm = () => {}; this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close()); this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-"); 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() { isShowing() {
@@ -195,11 +193,9 @@ export class NationModal {
// second set of buttons on top of the previous one, or the stale rows keep // second set of buttons on top of the previous one, or the stale rows keep
// their old labels and disabled state and appear unresponsive. // their old labels and disabled state and appear unresponsive.
this.$governmentList.empty(); 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.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index)); this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo });
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
this._buildTechnologyThemes();
this._buildResourceSubtabs(); this._buildResourceSubtabs();
this._buildIntelligenceSubtabs(); this._buildIntelligenceSubtabs();
// Reopening the panel shows the server's rates, not an unsent edit left // Reopening the panel shows the server's rates, not an unsent edit left
@@ -215,31 +211,6 @@ export class NationModal {
this.$modal.removeClass("hidden"); 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 = $("<button type='button' class='subtab'></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 // 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 // world ranking of each commodity's major exporters and importers. Both views
// are rendered together; switching only shows and hides them. The simple // 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 }; return { proto, index, kind, $name, $effects, $cost, $action, $card };
} }
// One technology card in the shared building-card idiom: the theme icon and // The hover detail for a technology card: its description and what its next
// name up top with the research state below, the description and effect in // level yields. Outstanding requirements are traced on the tree itself rather
// the hover tooltip, and a Focus button carrying the research cost. A focused // than listed here.
// technology fills the card's progress bar instead. _technologyTip(index) {
_makeTechnologyCard(proto, index) { const proto = this.technologies[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 = $("<button class='aero bt-free free-button'></button>")
.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;
// A repeatable's next level is what is being weighed, so its diminishing // A repeatable's next level is what is being weighed, so its diminishing
// gain is shown rather than the level already held. // 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 = []; const sections = [];
if (proto.description) { if (proto.description) {
sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] }); sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] });
} }
sections.push({ sections.push({
label: proto.repeatable ? `Next level (${row.repeat + 1})` : "Effect", label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect",
cls: "tip-produce", cls: "tip-produce",
sign: "+", sign: "+",
suffix: "", suffix: "",
items: [{ node: effectNodes(proto.effects, level) }], 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; return sections;
} }
@@ -1628,60 +1543,7 @@ export class NationModal {
} }
} }
const repeatCounts = this.techInfo.repeatCounts || {}; this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo });
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._renderDiplomacy(); this._renderDiplomacy();
this._renderConflicts(); this._renderConflicts();
+1
View File
@@ -95,6 +95,7 @@ export function hideCardTooltip() {
export function showCardTooltip($anchor, sections) { export function showCardTooltip($anchor, sections) {
hideCardTooltip(); hideCardTooltip();
if (!$anchor || !$anchor.length) return; if (!$anchor || !$anchor.length) return;
if (!sections || !sections.length) return;
$tooltip = $("<div class='card-tooltip'></div>"); $tooltip = $("<div class='card-tooltip'></div>");
for (const section of sections) { for (const section of sections) {
if (section.desc) { if (section.desc) {
+273
View File
@@ -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;
}
+459
View File
@@ -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 = $("<div class='tech-pan'></div>");
const $canvas = $("<div class='tech-canvas'></div>")
.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 = $("<div class='building-tile tech-card'></div>")
.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 = $("<div class='card-fill'></div>");
const $status = $("<div class='bt-level'></div>");
const $foot = $("<div class='bt-foot'></div>");
const $body = $("<div class='card-body'></div>").append(
$("<div class='bt-top'></div>").append(
$("<img class='building-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='bt-id'></div>").append(
$("<div class='bt-name'></div>").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 = $("<button class='aero free-button'></button>").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 = $(`<div class='tech-hint tech-hint-${side}'></div>`)
.append("<span class='tech-hint-arrow'></span>");
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 = $("<div class='tech-legend'></div>");
for (const id of TECH_THEME_ORDER) {
const theme = TECH_THEMES[id];
if (!theme) continue;
$legend.append(
$("<span class='tech-legend-item'></span>")
.attr("data-theme", id)
.append(
$("<span class='tech-legend-swatch'></span>").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($("<span class='tech-research-idle'></span>").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(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.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(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`))
)
);
} else {
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
$strip.append(
$("<div class='tech-research-cost'></div>").append(
researchNode(technologyCost(proto, repeat)),
document.createTextNode(held ? " complete" : focusable ? " to research" : " — prerequisites missing")
)
);
}
if (focusable && !focused) {
$strip.append(
$("<button type='button' class='aero tech-research-focus'></button>").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 $("<span class='tech-research-head'></span>").append(
$("<img class='tech-research-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='tech-research-text'></div>").append(
$("<div class='tech-research-name'></div>").text(proto.name),
$("<div class='tech-research-sub'></div>").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");
}
}
}
+13 -28
View File
@@ -25,24 +25,9 @@ import {
effect, effect,
} from "./effects.js"; } from "./effects.js";
// The tabs the technology panel groups research under. // The lowest tier: every technology below is a root, available from the start.
export const TECH_THEMES = [ // Research is focused, so nothing is unlocked by a single gateway technology.
{ id: "science", name: "Science" },
{ id: "economy", name: "Economy" },
{ id: "society", name: "Society" },
{ id: "military", name: "Military" },
];
export const TECHNOLOGIES = [ 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", id: "industrial_automation",
name: "Industrial Automation", name: "Industrial Automation",
@@ -50,7 +35,7 @@ export const TECHNOLOGIES = [
theme: "economy", theme: "economy",
repeatable: true, repeatable: true,
cost: 60, cost: 60,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)], effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)],
}, },
{ {
@@ -59,7 +44,7 @@ export const TECHNOLOGIES = [
description: "Clean generation cuts waste and lifts national production.", description: "Clean generation cuts waste and lifts national production.",
theme: "economy", theme: "economy",
cost: 80, cost: 80,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0)], 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.", description: "Machines that reason in superposition. A step change in the pace of discovery.",
theme: "science", theme: "science",
cost: 120, cost: 120,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)], effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
}, },
{ {
@@ -97,7 +82,7 @@ export const TECHNOLOGIES = [
theme: "military", theme: "military",
// 300,000 research points: a project a whole economy bends toward. // 300,000 research points: a project a whole economy bends toward.
cost: 3_000, cost: 3_000,
prerequisites: ["scientific_method"], prerequisites: [],
// The technology is unlocked by the nation's first uranium enrichment // The technology is unlocked by the nation's first uranium enrichment
// centre; without one it cannot be focused at all. // centre; without one it cannot be focused at all.
requiresBuilding: "uranium_enrichment_center", 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.", description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.",
theme: "society", theme: "society",
cost: 250, cost: 250,
prerequisites: ["scientific_method"], prerequisites: [],
// Unlocks the spy unit rather than carrying a numeric effect of its own. // Unlocks the spy unit rather than carrying a numeric effect of its own.
unlocks: true, unlocks: true,
effects: [], effects: [],
@@ -136,7 +121,7 @@ export const TECHNOLOGIES = [
cost: 40, cost: 40,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.02, repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)], effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
}, },
{ {
@@ -147,7 +132,7 @@ export const TECHNOLOGIES = [
cost: 40, cost: 40,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.02, repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)], effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
}, },
{ {
@@ -158,7 +143,7 @@ export const TECHNOLOGIES = [
cost: 50, cost: 50,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.02, repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)], effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
}, },
{ {
@@ -169,7 +154,7 @@ export const TECHNOLOGIES = [
cost: 50, cost: 50,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.02, repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)], effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
}, },
{ {
@@ -180,7 +165,7 @@ export const TECHNOLOGIES = [
cost: 50, cost: 50,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.02, repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)], effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
}, },
@@ -217,7 +202,7 @@ export const TECHNOLOGIES = [
cost: 50, cost: 50,
repeatable: true, repeatable: true,
repeatCostGrowth: 0.03, repeatCostGrowth: 0.03,
prerequisites: ["scientific_method"], prerequisites: [],
effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)], effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)],
}, },
{ {
+1 -1
View File
@@ -19,7 +19,7 @@ export class DataTest extends TestCase {
this.assertEqual(protoUnitById("modern_infantry"), PROTO_UNITS.find((u) => u.id === "modern_infantry")); 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(buildingById("barracks"), BUILDINGS.find((b) => b.id === "barracks"));
this.assertEqual(governmentById("republic"), GOVERNMENTS[0]); 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(protoUnitById("nope"));
this.assertNull(buildingById("nope")); this.assertNull(buildingById("nope"));
this.assertNull(governmentById("nope")); this.assertNull(governmentById("nope"));
+10 -8
View File
@@ -147,7 +147,11 @@ export class GameScreenTest extends TestCase {
env.$("#civ-flag").click(); env.$("#civ-flag").click();
this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
this.assertSize(env.$("#government-list .row-card"), 3); 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(); screen.leave();
} finally { } finally {
teardownDom(env); teardownDom(env);
@@ -959,7 +963,7 @@ export class GameScreenTest extends TestCase {
screen.enter(); screen.enter();
const state = smallState(); const state = smallState();
state.budgets.set(0, 1.0e15); 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.requestFocusTechnology(0, method);
state._addResearch(0, state.getTechnologyCost(0, method)); state._addResearch(0, state.getTechnologyCost(0, method));
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
@@ -1389,14 +1393,12 @@ export class GameScreenTest extends TestCase {
screen.onState(smallState().snapshot(0)); screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click(); env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=technology]").click(); env.$("#modal-nation .tab[data-tab=technology]").click();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
env.$("#technology-list .building-tile") env.$(`#technology-list .tech-card[data-index="${quantum}"]`).click();
.eq(method) env.$("#technology-research .tech-research-focus").click();
.find(".bt-upgrade")
.click();
this.assertEqual(network.orders[0], { this.assertEqual(network.orders[0], {
type: "focus_technology", type: "focus_technology",
technology: method, technology: quantum,
}); });
screen.leave(); screen.leave();
} finally { } finally {
+1 -1
View File
@@ -343,7 +343,7 @@ export class GameStateBuildingsTest extends TestCase {
const index = this.addBuilding(state, building); const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index)); this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
// Research only accumulates into a focused technology, so focus one. // 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.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0); this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour(); state.advanceHour();
+1 -1
View File
@@ -55,7 +55,7 @@ export class GameStateGovernmentTest extends TestCase {
state.budgets.set(0, BIG_BUDGET); state.budgets.set(0, BIG_BUDGET);
const technocracy = state.governments.findIndex((g) => g.id === "technocracy"); const technocracy = state.governments.findIndex((g) => g.id === "technocracy");
this.assertTrue(state.requestSetGovernment(0, 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.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0); this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour(); state.advanceHour();
+30 -25
View File
@@ -1,6 +1,12 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { smallState, stripPorts } from "./framework/helpers.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"; import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
export class GameStateTechnologyTest extends TestCase { export class GameStateTechnologyTest extends TestCase {
@@ -18,11 +24,11 @@ export class GameStateTechnologyTest extends TestCase {
} }
test_catalogue_links_prerequisites() { test_catalogue_links_prerequisites() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method"); const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(method.prerequisites); this.assertEmpty(root.prerequisites, "the lowest tier needs nothing");
this.assertGreater(method.cost, 0); this.assertGreater(root.cost, 0);
const automation = TECHNOLOGIES.find((t) => t.id === "industrial_automation"); const space = TECHNOLOGIES.find((t) => t.id === "space_program");
this.assertEqual(automation.prerequisites, ["scientific_method"]); this.assertEqual(space.prerequisites, ["industrial_automation", "quantum_computing"]);
} }
test_every_civilisation_starts_with_nothing_researched() { test_every_civilisation_starts_with_nothing_researched() {
@@ -34,7 +40,7 @@ export class GameStateTechnologyTest extends TestCase {
test_research_points_fill_the_focused_technology() { test_research_points_fill_the_focused_technology() {
const state = smallState(); 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"); this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted");
const cost = state.getTechnologyCost(0, index); const cost = state.getTechnologyCost(0, index);
// Points flow in as they are created; just short of the cost leaves it in // 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 // 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. // research: the shortfall is central-bank debt, not a spending limit.
state.budgets.set(0, -1_000_000); 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"); this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt");
state._addResearch(0, 5); state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue"); 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() { test_switching_focus_keeps_committed_progress() {
const state = smallState(); const state = smallState();
this.complete(state, "scientific_method");
const weapons = this.index(state, "infantry_weapons"); const weapons = this.index(state, "infantry_weapons");
const armour = this.index(state, "body_armour"); const armour = this.index(state, "body_armour");
this.assertTrue(state.requestFocusTechnology(0, weapons)); this.assertTrue(state.requestFocusTechnology(0, weapons));
@@ -82,7 +87,7 @@ export class GameStateTechnologyTest extends TestCase {
test_nothing_is_researched_without_a_focus() { test_nothing_is_researched_without_a_focus() {
const state = smallState(); 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. // Points created with nothing focused are simply not stored anywhere.
state._addResearch(0, state.getTechnologyCost(0, index) * 10); state._addResearch(0, state.getTechnologyCost(0, index) * 10);
this.assertFalse(state.isResearched(0, index)); this.assertFalse(state.isResearched(0, index));
@@ -104,38 +109,41 @@ export class GameStateTechnologyTest extends TestCase {
test_prerequisites_unlock_the_tree() { test_prerequisites_unlock_the_tree() {
const state = smallState(); 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"); 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.complete(state, "industrial_automation");
this.assertTrue(state.isResearched(0, automation)); this.assertTrue(state.isResearched(0, automation));
this.assertFalse(state.canResearchTechnology(0, space), "one prerequisite is not enough");
this.complete(state, "quantum_computing"); 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() { test_technology_effects_are_folded_into_gdp() {
const state = smallState(); const state = smallState();
stripPorts(state, 0); 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); const before = state.getPlayerGdp(0);
this.complete(state, "scientific_method"); this.complete(state, "renewable_energy");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01); this.assertApprox(state.getPlayerGdp(0), (before * (1 + base + added)) / (1 + base), 0.01);
} }
test_researched_technology_adds_its_research_rate() { test_researched_technology_adds_its_research_rate() {
const withTech = smallState(); const withTech = smallState();
const without = smallState(); const without = smallState();
this.complete(withTech, "scientific_method"); this.complete(withTech, "quantum_computing");
this.assertApprox( this.assertApprox(
withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate, withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate,
1.0, 4.0,
1e-9 1e-9
); );
} }
test_snapshot_exposes_technologies_and_progress() { test_snapshot_exposes_technologies_and_progress() {
const state = smallState(); const state = smallState();
const index = this.complete(state, "scientific_method"); const index = this.complete(state, "quantum_computing");
const snapshot = state.snapshot(0); const snapshot = state.snapshot(0);
this.assertSize(snapshot.technologies, state.technologies.length); this.assertSize(snapshot.technologies, state.technologies.length);
this.assertSize(snapshot.researched, state.civilisations.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() { test_repeatable_research_costs_two_percent_more_each_level() {
const state = smallState(); const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.index(state, "infantry_weapons"); const index = this.index(state, "infantry_weapons");
const base = state.getTechnologyCost(0, index); const base = state.getTechnologyCost(0, index);
this.assertTrue(state.requestFocusTechnology(0, index), "first level"); this.assertTrue(state.requestFocusTechnology(0, index), "first level");
@@ -165,7 +172,6 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_effects_stack_with_each_level() { test_repeatable_effects_stack_with_each_level() {
const state = smallState(); const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const weapons = this.index(state, "infantry_weapons"); const weapons = this.index(state, "infantry_weapons");
const construction = this.index(state, "construction_technology"); const construction = this.index(state, "construction_technology");
for (let level = 0; level < 2; level++) { for (let level = 0; level < 2; level++) {
@@ -182,10 +188,10 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_research_notifies_without_news() { test_repeatable_research_notifies_without_news() {
const state = smallState(); const state = smallState();
this.complete(state, "scientific_method"); this.complete(state, "quantum_computing");
this.complete(state, "body_armour"); this.complete(state, "body_armour");
const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY); 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"); const repeatNotice = notices.find((entry) => entry.technology === "body_armour");
this.assertNotNull(normalNotice); this.assertNotNull(normalNotice);
this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event"); 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() { test_completing_a_repeatable_keeps_it_focused() {
const state = smallState(); const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.complete(state, "infantry_weapons"); const index = this.complete(state, "infantry_weapons");
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done"); this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done");
this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused"); 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() { test_research_costs_a_hundred_times_the_catalogue_price() {
const state = smallState(); const state = smallState();
const index = this.index(state, "scientific_method"); const index = this.index(state, "quantum_computing");
const proto = TECHNOLOGIES[index]; const proto = TECHNOLOGIES[index];
this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100); this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100);
} }
+51 -47
View File
@@ -451,25 +451,24 @@ export class NationModalTest extends TestCase {
}; };
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); 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.$("#government-list .row-card"), GOVERNMENTS.length);
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length);
const governmentRows = env.$("#government-list .row-card"); const governmentRows = env.$("#government-list .row-card");
this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked"); this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked");
governmentRows.eq(1).find(".actions button").click(); governmentRows.eq(1).find(".actions button").click();
this.assertEqual(government, 1); this.assertEqual(government, 1);
// A prerequisite-gated technology starts locked. // A gated technology starts locked; researching its prerequisite unlocks
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); // it, then selecting it offers a Focus button in the strip.
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation"); const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
const techRows = env.$("#technology-list .building-tile"); const litho = TECHNOLOGIES.findIndex((t) => t.id === "advanced_lithography");
this.assertEqual(techRows.eq(automation).find(".bt-upgrade").text(), "Locked"); 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([quantum]), 1.0e9, 1.0e15, 1.0e6);
modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6); this.assertFalse(card(litho).hasClass("locked"), "the prerequisite unlocks it");
this.assertTrue(techRows.eq(method).find(".bt-upgrade").text().startsWith("Researched")); card(litho).click();
this.assertTrue(techRows.eq(automation).find(".bt-upgrade").text().startsWith("Focus")); env.$("#technology-research .tech-research-focus").click();
techRows.eq(automation).find(".bt-upgrade").click(); this.assertEqual(technology, litho);
this.assertEqual(technology, automation);
env.$("#modal-nation .tab[data-tab=technology]").click(); env.$("#modal-nation .tab[data-tab=technology]").click();
this.assertTrue(env.$("#tab-technology").hasClass("active")); 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(); const env = await setupDom();
try { try {
const modal = new NationModal(); const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
const $themes = env.$("#technology-themes .subtab"); this.assertSize(env.$("#technology-list .tech-card"), TECHNOLOGIES.length, "one card per technology");
this.assertGreater($themes.length, 1, "the technologies are split into themes"); this.assertSize(env.$("#technology-list .tech-pan"), 1, "the tree is draggable");
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); 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 automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const rows = env.$("#technology-list .building-tile"); const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme"); this.assertTrue(card(quantum).length > 0 && card(automation).length > 0, "every theme shares the graph");
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");
modal.close(); modal.close();
} finally { } finally {
teardownDom(env); teardownDom(env);
@@ -507,23 +510,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom(); const env = await setupDom();
try { try {
const modal = new NationModal(); const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
focus: -1, focus: -1,
progress: 0, progress: 0,
repeatCounts: { infantry_weapons: 3 }, repeatCounts: { infantry_weapons: 3 },
}); });
const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
const card = env.$("#technology-list .building-tile").eq(index); const card = env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card.find(".bt-level").text().includes("Level 3"), "the level is shown"); this.assertTrue(card.find(".bt-level").text().includes("Level III"), "the level is Roman");
const $meta = card.find(".bt-meta"); const $foot = card.find(".bt-foot");
this.assertEqual( this.assertEqual(
$meta.find("img[src$='icon_research.svg']").length, $foot.find("img[src$='icon_research.svg']").length,
1, 1,
"the erlenmeyer icon stands in for the research unit" "the erlenmeyer icon stands in for the research unit"
); );
this.assertTrue($meta.text().length > 0, `the next level is priced: ${$meta.text()}`); this.assertTrue($foot.text().length > 0, `the next level is priced: ${$foot.text()}`);
this.assertFalse($meta.text().includes("RP"), "no text 'RP' unit is left"); this.assertFalse($foot.text().includes("RP"), "no text 'RP' unit is left");
modal.close(); modal.close();
} finally { } finally {
teardownDom(env); teardownDom(env);
@@ -618,21 +620,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom(); const env = await setupDom();
try { try {
const modal = new NationModal(); 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, { modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, {
focus: method, focus: quantum,
progress: 10, progress: 6000,
progressByTech: { [quantum]: 6000 },
repeatCounts: {}, repeatCounts: {},
}); });
const card = env.$("#technology-list .building-tile").eq(method); const $strip = env.$("#technology-research");
this.assertTrue(card.find(".bt-upgrade .bt-progress-fill").length > 0, "the button is the bar"); this.assertTrue($strip.find(".tech-research-fill").length > 0, "the strip carries the bar");
this.assertNotEqual(card.find(".bt-upgrade .bt-progress-fill").css("width"), "0%", "the bar is filled"); this.assertNotEqual($strip.find(".tech-research-fill").css("width"), "0%", "the bar is filled");
this.assertEqual( this.assertTrue(
card.find(".bt-meta").text(), $strip.find(".tech-research-count").text().includes(`${groupDigits(6000)} / ${groupDigits(technologyCost(TECHNOLOGIES[quantum]))}`),
`10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`, "the committed and total points are shown beside it"
"X / Y 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(); modal.close();
} finally { } finally {
teardownDom(env); teardownDom(env);
@@ -645,7 +648,7 @@ export class NationModalTest extends TestCase {
const modal = new NationModal(); const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5);
const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); 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( this.assertTrue(
env.$(".card-tooltip").text().includes("+1.0% infantry attack"), env.$(".card-tooltip").text().includes("+1.0% infantry attack"),
"the effect is in the hover detail" "the effect is in the hover detail"
@@ -685,15 +688,16 @@ export class NationModalTest extends TestCase {
technology = index; technology = index;
}; };
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
const firstCount = env.$("#technology-list .tech-card").length;
modal.close(); modal.close();
// Reopen with enough research to afford the first technology. // Reopen with enough research to afford the first technology.
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); 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.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments");
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length, "no stacked technologies"); this.assertSize(env.$("#technology-list .tech-card"), firstCount, "no stacked technologies");
const button = env.$("#technology-list .building-tile").first().find(".bt-upgrade"); const first = env.$("#technology-list .tech-card").first();
this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget"); first.click();
button.click(); env.$("#technology-research .tech-research-focus").click();
this.assertEqual(technology, 0); this.assertEqual(technology, Number(first.attr("data-index")));
} finally { } finally {
teardownDom(env); teardownDom(env);
} }
+1 -1
View File
@@ -365,7 +365,7 @@ export class ResourceModelTest extends TestCase {
state.budgets.set(0, 1e15); state.budgets.set(0, 1e15);
const city = cityOf(state, 0); const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab"); 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)); this.assertTrue(state.requestFocusTechnology(0, tech));
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0; const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
// With a store of high-tech the lab's one point an hour comes through. // With a store of high-tech the lab's one point an hour comes through.
+5 -5
View File
@@ -80,21 +80,21 @@ export class RulesTest extends TestCase {
} }
test_prerequisites_met() { test_prerequisites_met() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method"); const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(method.prerequisites); this.assertEmpty(root.prerequisites);
this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES), "a root tech needs nothing"); this.assertTrue(prerequisitesMet(root, new Set(), TECHNOLOGIES), "a root tech needs nothing");
const space = TECHNOLOGIES.find((t) => t.id === "space_program"); const space = TECHNOLOGIES.find((t) => t.id === "space_program");
const index = (id) => TECHNOLOGIES.findIndex((t) => t.id === id); 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(), TECHNOLOGIES), "missing all prerequisites");
this.assertFalse( this.assertFalse(
prerequisitesMet(space, new Set([index("scientific_method"), index("industrial_automation")]), TECHNOLOGIES), prerequisitesMet(space, new Set([index("industrial_automation")]), TECHNOLOGIES),
"still missing one" "still missing one"
); );
this.assertTrue( this.assertTrue(
prerequisitesMet( prerequisitesMet(
space, space,
new Set([index("scientific_method"), index("industrial_automation"), index("quantum_computing")]), new Set([index("industrial_automation"), index("quantum_computing")]),
TECHNOLOGIES TECHNOLOGIES
), ),
"all prerequisites met" "all prerequisites met"
+187
View File
@@ -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")));
}
}
+1 -1
View File
@@ -263,7 +263,7 @@ export class TreatyTest extends TestCase {
test_joint_research_shares_points_but_not_military_ones() { test_joint_research_shares_points_but_not_military_ones() {
const state = smallState(); const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH)); 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"); const military = state.technologies.findIndex((t) => t.id === "body_armour");
state.focusTechnology.set(0, civil); state.focusTechnology.set(0, civil);
state.focusTechnology.set(1, civil); state.focusTechnology.set(1, civil);
-1
View File
@@ -69,7 +69,6 @@ export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() { test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState(); const state = smallState();
const index = techIndex(state, "nuclear_weapons"); const index = techIndex(state, "nuclear_weapons");
research(state, 0, "scientific_method");
this.assertFalse( this.assertFalse(
state.canResearchTechnology(0, index), state.canResearchTechnology(0, index),
"the bomb is locked without a uranium enrichment centre" "the bomb is locked without a uranium enrichment centre"
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<!--
Development preview for the technology tree.
It mounts the very same TechnologyTreeView the game uses, against the real
catalogue, so a technology added or rewired in shared/data/technologies.js
shows up here exactly as it will in the nation panel.
Serve the repository root (e.g. the game server, or
`python3 -m http.server` from the repo root) and open /tools/tech-tree.html.
Relative paths assume the page stays under client/ and shared/.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Technology tree — preview</title>
<style>html, body { height: 100%; margin: 0; }</style>
<link rel="stylesheet" href="../client/css/style.css">
</head>
<body>
<div id="modal-nation" class="modal">
<div class="modal-card">
<div class="modal-head">
<span>Technology tree — preview</span>
</div>
<div class="tab-page active" id="tab-technology">
<div id="technology-sub" class="modal-sub">
<label style="cursor:pointer">
<input type="checkbox" id="tech-preview-testing"> Testing game (Unlock now buttons)
</label>
</div>
<div id="technology-research" class="tech-research"></div>
<div id="technology-list" class="modal-body tech-tree-body"></div>
</div>
</div>
</div>
<script src="../client/vendor/jquery.min.js"></script>
<script type="module">
import { TechnologyTreeView } from "../client/js/ui/tech_tree_view.js";
import { effectNodes } from "../client/js/modals/format.js";
import { TECHNOLOGIES } from "../shared/data.js";
const indexOf = (id) => TECHNOLOGIES.findIndex((t) => t.id === id);
// A sample nation so every card state is visible: a few technologies held, one
// being researched, and repeatable ones at various levels.
const researched = new Set(
["renewable_energy", "intelligence_agencies", "industrial_automation", "infantry_weapons", "construction_technology"].map(indexOf)
);
const focus = indexOf("quantum_computing");
const techInfo = {
focus,
progress: 6000,
progressByTech: { [focus]: 6000 },
repeatCounts: {
industrial_automation: 4,
infantry_weapons: 2,
construction_technology: 10,
advanced_metallurgy: 1,
},
buildings: new Set(),
testing: false,
};
const tipFor = (index) => {
const proto = TECHNOLOGIES[index];
const repeat = 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 (${repeat + 1})` : "Effect",
cls: "tip-produce",
sign: "+",
suffix: "",
items: [{ node: effectNodes(proto.effects, level) }],
});
return sections;
};
const view = new TechnologyTreeView({
graph: $("#technology-list"),
strip: $("#technology-research"),
assetBase: "../client/assets/",
tipFor,
onFocusTechnology: (index) => {
techInfo.focus = index;
techInfo.progress = techInfo.progressByTech[index] || 0;
view.refresh({ researched, techInfo });
},
onUnlockTechnology: (index) => {
researched.add(index);
view.refresh({ researched, techInfo });
},
});
const draw = () => view.render({ technologies: TECHNOLOGIES, researched, techInfo });
draw();
document.getElementById("tech-preview-testing").addEventListener("change", (event) => {
techInfo.testing = event.target.checked;
draw();
});
</script>
</body>
</html>