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-line { font-size: 12px; color: var(--muted); margin-top: 2px; }
/* Technology cards are the same building cards as the city tab, tiled across
the tab so many read at once, grouped by the theme sub-tabs. */
#technology-list {
display: grid;
grid-template-columns: repeat(auto-fill, 178px);
gap: 8px;
align-content: start;
/* The technology tree: one theme at a time, cards placed on a canvas with the
dependency links drawn into an SVG layer beneath them. */
.tech-research {
display: flex;
align-items: center;
gap: 14px;
margin: 4px 18px 8px;
padding: 9px 14px;
border-radius: 9px;
border: 1px solid var(--panel-border);
background: linear-gradient(160deg, rgba(40, 52, 70, 0.55), rgba(18, 24, 34, 0.7));
}
.tech-research.idle { color: var(--muted); }
.tech-research .tech-research-head { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; }
.tech-research .tech-research-icon { width: 32px; height: 32px; flex: 0 0 auto; opacity: 0.95; }
.tech-research .tech-research-text { flex: 0 0 auto; min-width: 190px; }
.tech-research .tech-research-name { font-weight: 600; font-size: 13.5px; }
.tech-research .tech-research-sub { font-size: 11px; color: var(--muted); }
.tech-research .tech-research-cost { flex: 1 1 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.tech-research .tech-research-cost .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; }
.tech-research .tech-research-focus { flex: 0 0 auto; min-width: 96px; padding: 6px 16px; }
.tech-research .tech-research-bar {
position: relative;
flex: 1 1 auto;
height: 26px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(160, 190, 230, 0.25);
}
.tech-research .tech-research-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, rgba(79, 140, 255, 0.75), rgba(127, 209, 255, 0.75));
}
.tech-research .tech-research-count {
position: relative;
display: block;
padding: 5px 10px;
text-align: right;
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.tech-research .tech-research-count .res img { width: 12px; height: 12px; vertical-align: -2px; margin-right: 3px; }
#technology-list.tech-tree-body { overflow: hidden; }
.tech-pan { position: relative; width: 100%; height: 100%; overflow: hidden; cursor: grab; }
.tech-pan.dragging { cursor: grabbing; }
.tech-canvas { position: absolute; top: 0; left: 0; transform: translate(0, 0); }
.tech-links { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
.tech-links path { fill: none; stroke: rgba(150, 190, 240, 0.5); stroke-width: 2; }
.tech-links circle { fill: #0a1220; stroke: rgba(150, 190, 240, 0.55); stroke-width: 2; }
/* A faint fade and chevron sit on every edge the tree still extends past, so
the panel reads as draggable without the clutter of a scrollbar. */
.tech-hint {
position: absolute;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 220ms ease;
}
.tech-hint.show { opacity: 1; }
.tech-hint .tech-hint-arrow {
position: absolute;
display: block;
padding: 3px;
border: solid rgba(185, 205, 232, 0.75);
border-width: 0 2px 2px 0;
}
.tech-hint-top {
top: 0;
left: 0;
right: 0;
height: 64px;
background: linear-gradient(to bottom, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-top .tech-hint-arrow {
top: 10px;
left: 50%;
margin-left: -5px;
transform: rotate(-135deg);
animation: tech-hint-bob-up 1.7s ease-in-out infinite;
}
.tech-hint-bottom {
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: linear-gradient(to top, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-bottom .tech-hint-arrow {
bottom: 10px;
left: 50%;
margin-left: -5px;
transform: rotate(45deg);
animation: tech-hint-bob-down 1.7s ease-in-out infinite;
}
.tech-hint-left {
top: 0;
bottom: 0;
left: 0;
width: 64px;
background: linear-gradient(to right, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-left .tech-hint-arrow {
left: 10px;
top: 50%;
margin-top: -5px;
transform: rotate(135deg);
animation: tech-hint-bob-left 1.7s ease-in-out infinite;
}
.tech-hint-right {
top: 0;
bottom: 0;
right: 0;
width: 64px;
background: linear-gradient(to left, rgba(10, 15, 22, 0.92), rgba(10, 15, 22, 0));
}
.tech-hint-right .tech-hint-arrow {
right: 10px;
top: 50%;
margin-top: -5px;
transform: rotate(-45deg);
animation: tech-hint-bob-right 1.7s ease-in-out infinite;
}
@keyframes tech-hint-bob-up { 50% { translate: 0 -5px; } }
@keyframes tech-hint-bob-down { 50% { translate: 0 5px; } }
@keyframes tech-hint-bob-left { 50% { translate: -5px 0; } }
@keyframes tech-hint-bob-right { 50% { translate: 5px 0; } }
.tech-card { position: absolute; padding: 0; overflow: hidden; border-left-width: 4px; }
.tech-card.theme-science { border-left-color: #5aa9ff; }
.tech-card.theme-economy { border-left-color: #e0b050; }
.tech-card.theme-society { border-left-color: #9a7bff; }
.tech-card.theme-military { border-left-color: #ff6b5e; }
/* The category key above the tree. */
.tech-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
margin: 2px 18px 6px;
font-size: 11px;
color: var(--muted);
}
.tech-legend .tech-legend-item { display: flex; align-items: center; gap: 6px; }
.tech-legend .tech-legend-swatch {
width: 4px;
height: 14px;
border-radius: 2px;
}
.tech-card .card-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: linear-gradient(90deg, rgba(79, 140, 255, 0.5), rgba(127, 209, 255, 0.4));
}
.tech-card.built .card-fill {
background: linear-gradient(90deg, rgba(74, 160, 110, 0.5), rgba(143, 227, 168, 0.4));
}
.tech-card .card-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 4px;
height: 100%;
padding: 9px;
}
.tech-card .bt-level.good { color: #8fe3a8; }
.tech-card .bt-foot {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
font-size: 10.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.tech-card .bt-foot img { width: 11px; height: 11px; vertical-align: -1px; }
.tech-card.clickable { cursor: pointer; }
.tech-card.locked::after { z-index: 2; }
.tech-card .free-button { width: 100%; font-size: 10.5px; padding: 3px 6px; }
/* Tracing an unavailable technology's outstanding requirements. */
.tech-card.warn.locked { opacity: 0.95; }
.tech-card.warn { border-color: #ffb56b; }
.tech-card.warn-self { border-color: #ff7a5c; box-shadow: 0 0 0 2px rgba(255, 122, 92, 0.55); }
.tech-links path.warn { stroke: #ffb56b; stroke-width: 2.6; stroke-dasharray: none; }
.tech-links circle.warn { stroke: #ffb56b; }
/* A focused technology turns its action button into the progress bar: the
committed share fills behind, while the "Focused" label and the points sit on
top. The disabled dimming is lifted so the bar stays legible. */
+2 -2
View File
@@ -373,8 +373,8 @@
</div>
<div class="tab-page" id="tab-technology">
<div id="technology-sub" class="modal-sub"></div>
<div id="technology-themes" class="subtabs"></div>
<div id="technology-list" class="modal-body"></div>
<div id="technology-research" class="tech-research"></div>
<div id="technology-list" class="modal-body tech-tree-body"></div>
</div>
<div class="tab-page" id="tab-diplomacy">
<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.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "3c737e9",
"date": "2026-09-28",
"subject": "Derived every description figure from the rule it describes",
"body": ""
},
{
"hash": "c85f79f",
"date": "2026-09-27",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24",
"subject": "Cut the migration sweep's repeated lookups and population churn",
"body": ""
},
{
"hash": "a219cf6",
"date": "2026-09-24",
"subject": "Flew aircraft and missiles straight and let strikes target fogged cities",
"body": ""
}
];
+23 -161
View File
@@ -9,7 +9,6 @@ import {
POLICIES,
PROPAGANDA,
policyById,
TECH_THEMES,
technologyCost,
RESOURCES,
RESOURCE_IDS,
@@ -25,7 +24,8 @@ import { renderMonthlyBudget } from "./budget.js";
import { effectNodes, money, quotedMoney } from "./format.js";
import { bindTabs } from "./tabs.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { buildCard, researchNode, setCardMeta } from "../ui/card.js";
import { researchNode } from "../ui/card.js";
import { TechnologyTreeView } from "../ui/tech_tree_view.js";
import { resourceNode, valueNode } from "../ui/value.js";
import { lineChart } from "../ui/line_chart.js";
import { trendArrow } from "../ui/trend.js";
@@ -33,21 +33,13 @@ import { currencySymbol, currencyFor, rateToNational, formatMoney } from "../cur
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js";
// Each research theme borrows the building-card icon of its field, so a
// technology card reads with the same visual grammar as a building.
const TECH_THEME_ICONS = {
science: "icon_university.svg",
economy: "icon_bank.svg",
society: "icon_museum.svg",
military: "icon_fortifications.svg",
};
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyStrip = $("#technology-research");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetMonths = $("#budget-months");
@@ -101,9 +93,8 @@ export class NationModal {
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
// Focused research: which theme tab is open and the current focus figures.
this.activeTheme = TECH_THEMES[0].id;
// The technology tree is drawn by a self-contained view, shared with the
// stand-alone catalogue preview; the modal only feeds it the snapshot.
this.techInfo = { focus: -1, progress: 0, repeatCounts: {} };
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
@@ -148,6 +139,13 @@ export class NationModal {
this.onConfirm = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
bindTabs(this.$modal, "tab-");
this.techTree = new TechnologyTreeView({
graph: this.$technologyList,
strip: this.$technologyStrip,
tipFor: (index) => this._technologyTip(index),
onFocusTechnology: (index) => this.onFocusTechnology(index),
onUnlockTechnology: (index) => this.onUnlockTechnology(index),
});
}
isShowing() {
@@ -195,11 +193,9 @@ export class NationModal {
// second set of buttons on top of the previous one, or the stale rows keep
// their old labels and disabled state and appear unresponsive.
this.$governmentList.empty();
this.$technologyList.empty();
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index));
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
this._buildTechnologyThemes();
this.techTree.render({ technologies: this.technologies, researched: this.researched, techInfo: this.techInfo });
this._buildResourceSubtabs();
this._buildIntelligenceSubtabs();
// Reopening the panel shows the server's rates, not an unsent edit left
@@ -215,31 +211,6 @@ export class NationModal {
this.$modal.removeClass("hidden");
}
// The technology tab's theme sub-tabs, built once per open. Switching a tab
// only shows and hides the rows that are already there.
_buildTechnologyThemes() {
const $themes = $("#technology-themes");
if (!$themes.length) return;
$themes.empty();
for (const theme of TECH_THEMES) {
const $button = $("<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
// world ranking of each commodity's major exporters and importers. Both views
// are rendered together; switching only shows and hides them. The simple
@@ -1494,82 +1465,26 @@ export class NationModal {
return { proto, index, kind, $name, $effects, $cost, $action, $card };
}
// One technology card in the shared building-card idiom: the theme icon and
// name up top with the research state below, the description and effect in
// the hover tooltip, and a Focus button carrying the research cost. A focused
// technology fills the card's progress bar instead.
_makeTechnologyCard(proto, index) {
const row = { proto, index, repeat: 0 };
const card = buildCard({
icon: TECH_THEME_ICONS[proto.theme] || "icon_university.svg",
name: proto.name,
status: "",
action: { label: "Focus" },
progress: true,
tip: () => this._technologyTip(row),
onAction: () => this.onFocusTechnology(index),
});
row.card = card;
row.$card = card.$card;
row.$name = card.$card.find(".bt-name");
row.$status = card.$status;
row.$action = card.$action;
row.$label = card.$label;
row.$meta = card.$meta;
row.$fill = card.$progressFill;
// A local testing game gains a second button that grants the technology at
// once, ignoring its cost and prerequisites.
if (this.techInfo && this.techInfo.testing) {
const $free = $("<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;
// The hover detail for a technology card: its description and what its next
// level yields. Outstanding requirements are traced on the tree itself rather
// than listed here.
_technologyTip(index) {
const proto = this.technologies[index];
// A repeatable's next level is what is being weighed, so its diminishing
// gain is shown rather than the level already held.
const level = proto.repeatable ? row.repeat + 1 : 1;
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const level = proto.repeatable ? repeat + 1 : 1;
const sections = [];
if (proto.description) {
sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] });
}
sections.push({
label: proto.repeatable ? `Next level (${row.repeat + 1})` : "Effect",
label: proto.repeatable ? `Next level (${repeat + 1})` : "Effect",
cls: "tip-produce",
sign: "+",
suffix: "",
items: [{ node: effectNodes(proto.effects, level) }],
});
if (
row.$action &&
row.$action.prop("disabled") &&
!this.researched.has(row.index) &&
!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))
) {
const names = (proto.prerequisites || [])
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
sections.push({ label: "Requires", cls: "tip-cost", sign: "−", suffix: "", items: [{ text: names }] });
}
return sections;
}
@@ -1628,60 +1543,7 @@ export class NationModal {
}
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const row of this.technologyRows) {
const proto = row.proto;
const repeat = repeatCounts[proto.id] || 0;
const focused = this.techInfo.focus === row.index;
row.repeat = repeat;
row.$name.text(proto.name);
row.$fill.css("width", "0%");
row.$action.removeClass("progressing");
row.$card.removeClass("built locked");
row.$status.removeClass("built");
row.$action.prop("disabled", false);
if (this.researched.has(row.index) && !proto.repeatable) {
row.$status.text("Researched").addClass("built");
row.$card.addClass("built");
row.$label.text("Researched");
row.$action.prop("disabled", true);
setCardMeta(row.$meta, {});
} else if (!prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasTechBuilding(id))) {
row.$status.text("Locked");
row.$card.addClass("locked");
row.$label.text("Locked");
row.$action.prop("disabled", true);
setCardMeta(row.$meta, {});
} else if (focused) {
// A focused technology fills the card's progress bar, with the committed
// and total points on the button.
const cost = technologyCost(proto, repeat);
const progress = this.techInfo.progress || 0;
const pct = cost > 0 ? Math.max(0, Math.min(100, (progress / cost) * 100)) : 0;
row.$status.text(repeat > 0 ? `Level ${repeat} · Researching` : "Researching").addClass("built");
row.$card.addClass("built");
row.$label.text("Focused");
row.$action.prop("disabled", true).addClass("progressing");
row.$fill.css("width", `${pct}%`);
setCardMeta(row.$meta, {});
row.$meta
.append(researchNode(progress))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`));
} else {
const cost = technologyCost(proto, repeat);
row.$status.text(repeat > 0 ? `Level ${repeat}` : "Not researched");
row.$label.text("Focus");
setCardMeta(row.$meta, {});
row.$meta.append(researchNode(cost));
}
// The testing unlock disappears once a one-off technology is held; a
// repeatable keeps it, to grant another level.
if (row.$free) {
row.$free.toggleClass("hidden", this.researched.has(row.index) && !proto.repeatable);
}
}
this._applyTechnologyFilter();
this.techTree.refresh({ researched: this.researched, techInfo: this.techInfo });
this._renderDiplomacy();
this._renderConflicts();
+1
View File
@@ -95,6 +95,7 @@ export function hideCardTooltip() {
export function showCardTooltip($anchor, sections) {
hideCardTooltip();
if (!$anchor || !$anchor.length) return;
if (!sections || !sections.length) return;
$tooltip = $("<div class='card-tooltip'></div>");
for (const section of sections) {
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,
} from "./effects.js";
// The tabs the technology panel groups research under.
export const TECH_THEMES = [
{ id: "science", name: "Science" },
{ id: "economy", name: "Economy" },
{ id: "society", name: "Society" },
{ id: "military", name: "Military" },
];
// The lowest tier: every technology below is a root, available from the start.
// Research is focused, so nothing is unlocked by a single gateway technology.
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
theme: "science",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_PRODUCTION, 0.02, 1.0)],
},
{
id: "industrial_automation",
name: "Industrial Automation",
@@ -50,7 +35,7 @@ export const TECHNOLOGIES = [
theme: "economy",
repeatable: true,
cost: 60,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.08, 1.0)],
},
{
@@ -59,7 +44,7 @@ export const TECHNOLOGIES = [
description: "Clean generation cuts waste and lifts national production.",
theme: "economy",
cost: 80,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_PRODUCTION, 0.03, 1.0)],
},
{
@@ -68,7 +53,7 @@ export const TECHNOLOGIES = [
description: "Machines that reason in superposition. A step change in the pace of discovery.",
theme: "science",
cost: 120,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
},
{
@@ -97,7 +82,7 @@ export const TECHNOLOGIES = [
theme: "military",
// 300,000 research points: a project a whole economy bends toward.
cost: 3_000,
prerequisites: ["scientific_method"],
prerequisites: [],
// The technology is unlocked by the nation's first uranium enrichment
// centre; without one it cannot be focused at all.
requiresBuilding: "uranium_enrichment_center",
@@ -109,7 +94,7 @@ export const TECHNOLOGIES = [
description: "Professional services for espionage and covert action. Unlocks the spy and its operations abroad.",
theme: "society",
cost: 250,
prerequisites: ["scientific_method"],
prerequisites: [],
// Unlocks the spy unit rather than carrying a numeric effect of its own.
unlocks: true,
effects: [],
@@ -136,7 +121,7 @@ export const TECHNOLOGIES = [
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_INFANTRY_ATTACK, 0.01, 1.0)],
},
{
@@ -147,7 +132,7 @@ export const TECHNOLOGIES = [
cost: 40,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_INFANTRY_DEFENSE, 0.01, 1.0)],
},
{
@@ -158,7 +143,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_ARTILLERY_ATTACK, 0.01, 1.0)],
},
{
@@ -169,7 +154,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_CONSTRUCTION_SPEED, 0.01, 1.0)],
},
{
@@ -180,7 +165,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.02,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_RAIL_SPEED, 0.01, 1.0)],
},
@@ -217,7 +202,7 @@ export const TECHNOLOGIES = [
cost: 50,
repeatable: true,
repeatCostGrowth: 0.03,
prerequisites: ["scientific_method"],
prerequisites: [],
effects: [effect(EFFECT_FOOD_EFFICIENCY, 0.05, 1.0)],
},
{
+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(buildingById("barracks"), BUILDINGS.find((b) => b.id === "barracks"));
this.assertEqual(governmentById("republic"), GOVERNMENTS[0]);
this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]);
this.assertEqual(technologyById("industrial_automation"), TECHNOLOGIES[0]);
this.assertNull(protoUnitById("nope"));
this.assertNull(buildingById("nope"));
this.assertNull(governmentById("nope"));
+10 -8
View File
@@ -147,7 +147,11 @@ export class GameScreenTest extends TestCase {
env.$("#civ-flag").click();
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
this.assertSize(env.$("#government-list .row-card"), 3);
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length);
this.assertSize(
env.$("#technology-list .tech-card"),
TECHNOLOGIES.length,
"the whole technology graph is drawn"
);
screen.leave();
} finally {
teardownDom(env);
@@ -959,7 +963,7 @@ export class GameScreenTest extends TestCase {
screen.enter();
const state = smallState();
state.budgets.set(0, 1.0e15);
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
state.requestFocusTechnology(0, method);
state._addResearch(0, state.getTechnologyCost(0, method));
screen.onState(state.snapshot(0));
@@ -1389,14 +1393,12 @@ export class GameScreenTest extends TestCase {
screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=technology]").click();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
env.$("#technology-list .building-tile")
.eq(method)
.find(".bt-upgrade")
.click();
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
env.$(`#technology-list .tech-card[data-index="${quantum}"]`).click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(network.orders[0], {
type: "focus_technology",
technology: method,
technology: quantum,
});
screen.leave();
} finally {
+1 -1
View File
@@ -343,7 +343,7 @@ export class GameStateBuildingsTest extends TestCase {
const index = this.addBuilding(state, building);
this.assertTrue(state.requestBuild(cityOf(state, 0).id, index));
// Research only accumulates into a focused technology, so focus one.
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
+1 -1
View File
@@ -55,7 +55,7 @@ export class GameStateGovernmentTest extends TestCase {
state.budgets.set(0, BIG_BUDGET);
const technocracy = state.governments.findIndex((g) => g.id === "technocracy");
this.assertTrue(state.requestSetGovernment(0, technocracy));
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
const method = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, method));
this.assertEqual(state.getTechnologyProgress(0), 0);
state.advanceHour();
+30 -25
View File
@@ -1,6 +1,12 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, stripPorts } from "./framework/helpers.js";
import { TECHNOLOGIES, EFFECT_INFANTRY_ATTACK, EFFECT_CONSTRUCTION_SPEED } from "../shared/data.js";
import {
TECHNOLOGIES,
EFFECT_INFANTRY_ATTACK,
EFFECT_CONSTRUCTION_SPEED,
EFFECT_PRODUCTION,
EFFECT_RESEARCH,
} from "../shared/data.js";
import { NEWS_TECHNOLOGY } from "../shared/data/relations.js";
export class GameStateTechnologyTest extends TestCase {
@@ -18,11 +24,11 @@ export class GameStateTechnologyTest extends TestCase {
}
test_catalogue_links_prerequisites() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
this.assertGreater(method.cost, 0);
const automation = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEqual(automation.prerequisites, ["scientific_method"]);
const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(root.prerequisites, "the lowest tier needs nothing");
this.assertGreater(root.cost, 0);
const space = TECHNOLOGIES.find((t) => t.id === "space_program");
this.assertEqual(space.prerequisites, ["industrial_automation", "quantum_computing"]);
}
test_every_civilisation_starts_with_nothing_researched() {
@@ -34,7 +40,7 @@ export class GameStateTechnologyTest extends TestCase {
test_research_points_fill_the_focused_technology() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted");
const cost = state.getTechnologyCost(0, index);
// Points flow in as they are created; just short of the cost leaves it in
@@ -52,7 +58,7 @@ export class GameStateTechnologyTest extends TestCase {
// A treasury deep in the red does not stop a nation focusing or accruing
// research: the shortfall is central-bank debt, not a spending limit.
state.budgets.set(0, -1_000_000);
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt");
state._addResearch(0, 5);
this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue");
@@ -61,7 +67,6 @@ export class GameStateTechnologyTest extends TestCase {
test_switching_focus_keeps_committed_progress() {
const state = smallState();
this.complete(state, "scientific_method");
const weapons = this.index(state, "infantry_weapons");
const armour = this.index(state, "body_armour");
this.assertTrue(state.requestFocusTechnology(0, weapons));
@@ -82,7 +87,7 @@ export class GameStateTechnologyTest extends TestCase {
test_nothing_is_researched_without_a_focus() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
// Points created with nothing focused are simply not stored anywhere.
state._addResearch(0, state.getTechnologyCost(0, index) * 10);
this.assertFalse(state.isResearched(0, index));
@@ -104,38 +109,41 @@ export class GameStateTechnologyTest extends TestCase {
test_prerequisites_unlock_the_tree() {
const state = smallState();
const space = this.index(state, "space_program");
this.assertFalse(state.requestFocusTechnology(0, space), "locked before its prerequisites");
const automation = this.index(state, "industrial_automation");
this.assertFalse(state.requestFocusTechnology(0, automation), "locked before its prerequisite");
this.complete(state, "scientific_method");
this.assertFalse(state.isResearched(0, automation), "a prerequisite is not auto-granted");
this.complete(state, "industrial_automation");
this.assertTrue(state.isResearched(0, automation));
this.assertFalse(state.canResearchTechnology(0, space), "one prerequisite is not enough");
this.complete(state, "quantum_computing");
this.assertTrue(state.requestFocusTechnology(0, this.index(state, "space_program")));
this.assertTrue(state.requestFocusTechnology(0, space), "both prerequisites met");
}
test_technology_effects_are_folded_into_gdp() {
const state = smallState();
stripPorts(state, 0);
const index = this.index(state, "renewable_energy");
const added = TECHNOLOGIES[index].effects.find((e) => e.stat === EFFECT_PRODUCTION).baseValue;
const base = state.getNationalModifiers(0)[EFFECT_PRODUCTION] || 0;
const before = state.getPlayerGdp(0);
this.complete(state, "scientific_method");
this.assertApprox(state.getPlayerGdp(0), (before * 1.07) / 1.05, 0.01);
this.complete(state, "renewable_energy");
this.assertApprox(state.getPlayerGdp(0), (before * (1 + base + added)) / (1 + base), 0.01);
}
test_researched_technology_adds_its_research_rate() {
const withTech = smallState();
const without = smallState();
this.complete(withTech, "scientific_method");
this.complete(withTech, "quantum_computing");
this.assertApprox(
withTech.viewerStats(0).researchRate - without.viewerStats(0).researchRate,
1.0,
4.0,
1e-9
);
}
test_snapshot_exposes_technologies_and_progress() {
const state = smallState();
const index = this.complete(state, "scientific_method");
const index = this.complete(state, "quantum_computing");
const snapshot = state.snapshot(0);
this.assertSize(snapshot.technologies, state.technologies.length);
this.assertSize(snapshot.researched, state.civilisations.length);
@@ -145,7 +153,6 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_research_costs_two_percent_more_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.index(state, "infantry_weapons");
const base = state.getTechnologyCost(0, index);
this.assertTrue(state.requestFocusTechnology(0, index), "first level");
@@ -165,7 +172,6 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_effects_stack_with_each_level() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const weapons = this.index(state, "infantry_weapons");
const construction = this.index(state, "construction_technology");
for (let level = 0; level < 2; level++) {
@@ -182,10 +188,10 @@ export class GameStateTechnologyTest extends TestCase {
test_repeatable_research_notifies_without_news() {
const state = smallState();
this.complete(state, "scientific_method");
this.complete(state, "quantum_computing");
this.complete(state, "body_armour");
const notices = state.news.filter((entry) => entry.type === NEWS_TECHNOLOGY);
const normalNotice = notices.find((entry) => entry.technology === "scientific_method");
const normalNotice = notices.find((entry) => entry.technology === "quantum_computing");
const repeatNotice = notices.find((entry) => entry.technology === "body_armour");
this.assertNotNull(normalNotice);
this.assertFalse(!!normalNotice.repeatable, "a discovery is a real news event");
@@ -204,7 +210,6 @@ export class GameStateTechnologyTest extends TestCase {
test_completing_a_repeatable_keeps_it_focused() {
const state = smallState();
state.researched.get(0).add(this.index(state, "scientific_method"));
const index = this.complete(state, "infantry_weapons");
this.assertEqual(state.getTechnologyRepeatCount(0, index), 1, "one level done");
this.assertEqual(state.getFocusedTechnology(0), index, "it stays focused");
@@ -216,7 +221,7 @@ export class GameStateTechnologyTest extends TestCase {
test_research_costs_a_hundred_times_the_catalogue_price() {
const state = smallState();
const index = this.index(state, "scientific_method");
const index = this.index(state, "quantum_computing");
const proto = TECHNOLOGIES[index];
this.assertEqual(state.getTechnologyCost(0, index), proto.cost * 100);
}
+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);
this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length);
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length);
const governmentRows = env.$("#government-list .row-card");
this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked");
governmentRows.eq(1).find(".actions button").click();
this.assertEqual(government, 1);
// A prerequisite-gated technology starts locked.
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const techRows = env.$("#technology-list .building-tile");
this.assertEqual(techRows.eq(automation).find(".bt-upgrade").text(), "Locked");
// A gated technology starts locked; researching its prerequisite unlocks
// it, then selecting it offers a Focus button in the strip.
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
const litho = TECHNOLOGIES.findIndex((t) => t.id === "advanced_lithography");
const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card(litho).hasClass("locked"), "a gated technology starts locked");
// Researching its prerequisite unlocks it.
modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6);
this.assertTrue(techRows.eq(method).find(".bt-upgrade").text().startsWith("Researched"));
this.assertTrue(techRows.eq(automation).find(".bt-upgrade").text().startsWith("Focus"));
techRows.eq(automation).find(".bt-upgrade").click();
this.assertEqual(technology, automation);
modal.refresh(0, new Set([quantum]), 1.0e9, 1.0e15, 1.0e6);
this.assertFalse(card(litho).hasClass("locked"), "the prerequisite unlocks it");
card(litho).click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(technology, litho);
env.$("#modal-nation .tab[data-tab=technology]").click();
this.assertTrue(env.$("#tab-technology").hasClass("active"));
@@ -482,21 +481,25 @@ export class NationModalTest extends TestCase {
}
}
async test_technology_theme_tabs_filter_the_list() {
async test_the_technology_tree_draws_every_technology() {
const env = await setupDom();
try {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
const $themes = env.$("#technology-themes .subtab");
this.assertGreater($themes.length, 1, "the technologies are split into themes");
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
this.assertSize(env.$("#technology-list .tech-card"), TECHNOLOGIES.length, "one card per technology");
this.assertSize(env.$("#technology-list .tech-pan"), 1, "the tree is draggable");
for (const side of ["top", "bottom", "left", "right"]) {
this.assertSize(env.$(`#technology-list .tech-hint-${side} .tech-hint-arrow`), 1, `a ${side} pan clue is present`);
}
const themed = env.$("#technology-list .tech-card").filter((_, el) =>
el.classList.contains(`theme-${TECHNOLOGIES[Number(el.dataset.index)].theme}`)
);
this.assertSize(themed, TECHNOLOGIES.length, "every card carries its theme's colour");
this.assertSize(env.$(".tech-legend .tech-legend-item"), 4, "the category legend keys every theme");
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation");
const rows = env.$("#technology-list .building-tile");
this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme");
this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden");
$themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click");
this.assertEqual(rows.eq(method).css("display"), "none", "science is hidden");
this.assertNotEqual(rows.eq(automation).css("display"), "none", "the economy theme shows");
const card = (index) => env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card(quantum).length > 0 && card(automation).length > 0, "every theme shares the graph");
modal.close();
} finally {
teardownDom(env);
@@ -507,23 +510,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set([method]), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, [], null, {
focus: -1,
progress: 0,
repeatCounts: { infantry_weapons: 3 },
});
const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
const card = env.$("#technology-list .building-tile").eq(index);
this.assertTrue(card.find(".bt-level").text().includes("Level 3"), "the level is shown");
const $meta = card.find(".bt-meta");
const card = env.$(`#technology-list .tech-card[data-index="${index}"]`);
this.assertTrue(card.find(".bt-level").text().includes("Level III"), "the level is Roman");
const $foot = card.find(".bt-foot");
this.assertEqual(
$meta.find("img[src$='icon_research.svg']").length,
$foot.find("img[src$='icon_research.svg']").length,
1,
"the erlenmeyer icon stands in for the research unit"
);
this.assertTrue($meta.text().length > 0, `the next level is priced: ${$meta.text()}`);
this.assertFalse($meta.text().includes("RP"), "no text 'RP' unit is left");
this.assertTrue($foot.text().length > 0, `the next level is priced: ${$foot.text()}`);
this.assertFalse($foot.text().includes("RP"), "no text 'RP' unit is left");
modal.close();
} finally {
teardownDom(env);
@@ -618,21 +620,22 @@ export class NationModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NationModal();
const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method");
const quantum = TECHNOLOGIES.findIndex((t) => t.id === "quantum_computing");
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5, {}, null, [], null, {
focus: method,
progress: 10,
focus: quantum,
progress: 6000,
progressByTech: { [quantum]: 6000 },
repeatCounts: {},
});
const card = env.$("#technology-list .building-tile").eq(method);
this.assertTrue(card.find(".bt-upgrade .bt-progress-fill").length > 0, "the button is the bar");
this.assertNotEqual(card.find(".bt-upgrade .bt-progress-fill").css("width"), "0%", "the bar is filled");
this.assertEqual(
card.find(".bt-meta").text(),
`10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`,
"X / Y beside it"
const $strip = env.$("#technology-research");
this.assertTrue($strip.find(".tech-research-fill").length > 0, "the strip carries the bar");
this.assertNotEqual($strip.find(".tech-research-fill").css("width"), "0%", "the bar is filled");
this.assertTrue(
$strip.find(".tech-research-count").text().includes(`${groupDigits(6000)} / ${groupDigits(technologyCost(TECHNOLOGIES[quantum]))}`),
"the committed and total points are shown beside it"
);
this.assertEqual(card.find(".bt-up-label").text(), "Focused", "no cost on the button");
const card = env.$(`#technology-list .tech-card[data-index="${quantum}"]`);
this.assertTrue(card.hasClass("researching"), "the focused card is marked");
modal.close();
} finally {
teardownDom(env);
@@ -645,7 +648,7 @@ export class NationModalTest extends TestCase {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5);
const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons");
env.$("#technology-list .building-tile").eq(infantry).find(".bt-upgrade").trigger("mouseenter");
env.$(`#technology-list .tech-card[data-index="${infantry}"]`).trigger("mouseenter");
this.assertTrue(
env.$(".card-tooltip").text().includes("+1.0% infantry attack"),
"the effect is in the hover detail"
@@ -685,15 +688,16 @@ export class NationModalTest extends TestCase {
technology = index;
};
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 0);
const firstCount = env.$("#technology-list .tech-card").length;
modal.close();
// Reopen with enough research to afford the first technology.
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6);
this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments");
this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length, "no stacked technologies");
const button = env.$("#technology-list .building-tile").first().find(".bt-upgrade");
this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget");
button.click();
this.assertEqual(technology, 0);
this.assertSize(env.$("#technology-list .tech-card"), firstCount, "no stacked technologies");
const first = env.$("#technology-list .tech-card").first();
first.click();
env.$("#technology-research .tech-research-focus").click();
this.assertEqual(technology, Number(first.attr("data-index")));
} finally {
teardownDom(env);
}
+1 -1
View File
@@ -365,7 +365,7 @@ export class ResourceModelTest extends TestCase {
state.budgets.set(0, 1e15);
const city = cityOf(state, 0);
grantBuilding(state, city, "research_lab");
const tech = state.technologies.findIndex((t) => t.id === "scientific_method");
const tech = state.technologies.findIndex((t) => t.id === "renewable_energy");
this.assertTrue(state.requestFocusTechnology(0, tech));
const governmentRate = state.getCivGovernmentModifiers(0)[EFFECT_RESEARCH] || 0;
// With a store of high-tech the lab's one point an hour comes through.
+5 -5
View File
@@ -80,21 +80,21 @@ export class RulesTest extends TestCase {
}
test_prerequisites_met() {
const method = TECHNOLOGIES.find((t) => t.id === "scientific_method");
this.assertEmpty(method.prerequisites);
this.assertTrue(prerequisitesMet(method, new Set(), TECHNOLOGIES), "a root tech needs nothing");
const root = TECHNOLOGIES.find((t) => t.id === "industrial_automation");
this.assertEmpty(root.prerequisites);
this.assertTrue(prerequisitesMet(root, new Set(), TECHNOLOGIES), "a root tech needs nothing");
const space = TECHNOLOGIES.find((t) => t.id === "space_program");
const index = (id) => TECHNOLOGIES.findIndex((t) => t.id === id);
this.assertFalse(prerequisitesMet(space, new Set(), TECHNOLOGIES), "missing all prerequisites");
this.assertFalse(
prerequisitesMet(space, new Set([index("scientific_method"), index("industrial_automation")]), TECHNOLOGIES),
prerequisitesMet(space, new Set([index("industrial_automation")]), TECHNOLOGIES),
"still missing one"
);
this.assertTrue(
prerequisitesMet(
space,
new Set([index("scientific_method"), index("industrial_automation"), index("quantum_computing")]),
new Set([index("industrial_automation"), index("quantum_computing")]),
TECHNOLOGIES
),
"all prerequisites met"
+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() {
const state = smallState();
this.assertTrue(sign(state, 0, 1, TREATY_JOINT_RESEARCH));
const civil = state.technologies.findIndex((t) => t.id === "scientific_method");
const civil = state.technologies.findIndex((t) => t.id === "renewable_energy");
const military = state.technologies.findIndex((t) => t.id === "body_armour");
state.focusTechnology.set(0, civil);
state.focusTechnology.set(1, civil);
-1
View File
@@ -69,7 +69,6 @@ export class WmdTest extends TestCase {
test_nuclear_weapons_research_needs_an_enrichment_centre() {
const state = smallState();
const index = techIndex(state, "nuclear_weapons");
research(state, 0, "scientific_method");
this.assertFalse(
state.canResearchTechnology(0, index),
"the bomb is locked without a uranium enrichment centre"
+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>