Rebuilt the technology panel as one lane-routed draggable graph
This commit is contained in:
+191
-7
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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"));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user