diff --git a/AGENTS.md b/AGENTS.md index 4a33bfd..c41a529 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ everything else (transport included) is hand-written. every `tests/*_test.js` and runs each in its own worker process, prints a summary and exits non-zero on failure). `--jobs N` caps the pool and `--file ` runs a single suite in-process; the worker marker is internal. +- Never run the full test suite: the commit hook runs it anyway. During + development run only the single affected suite with `--file `. - Coverage report: `npm run coverage` (c8, writes `coverage/`). - Start the game server and client locally: `node server/server.js --port 27015 --bind 127.0.0.1`. Then open diff --git a/client/assets/icon_research.svg b/client/assets/icon_research.svg new file mode 100644 index 0000000..f537274 --- /dev/null +++ b/client/assets/icon_research.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/css/style.css b/client/css/style.css index e90bd7f..d606beb 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -939,6 +939,25 @@ input:focus, select:focus { border-color: var(--accent); } .building-tile .bt-name { font-weight: 600; font-size: 12.5px; line-height: 1.15; } .building-tile .bt-level { font-size: 10.5px; color: var(--muted); } .building-tile .bt-level.built { color: #8fe3a8; } +.building-tile .bt-note { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + font-size: 10.5px; + color: var(--muted); +} +.building-tile .bt-bill { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; +} +/* The upkeep reads as a cost, in the same tan the tooltips use. */ +.building-tile .bt-note .res, +.building-tile .bt-note .bt-bill-suffix { color: #e2c08a; } +.building-tile .bt-note .res { display: inline; white-space: nowrap; } +.building-tile .bt-note .res img { width: 12px; height: 12px; opacity: 0.9; vertical-align: -2px; margin-right: 2px; } .building-tile .bt-upgrade { display: flex; justify-content: space-between; @@ -960,6 +979,9 @@ input:focus, select:focus { border-color: var(--accent); } } /* The money is an estimate, so it is italic and marked with a tilde. */ .building-tile .bt-price { font-style: italic; } +/* A research figure on the button carries the erlenmeyer icon in the meta line. */ +.building-tile .bt-meta .res { display: inline; white-space: nowrap; } +.building-tile .bt-meta .res img { width: 11px; height: 11px; vertical-align: -2px; margin-right: 3px; } .building-tile .bt-free { width: 100%; font-size: 10.5px; padding: 3px 6px; } .building-tile .bt-downgrade { position: absolute; @@ -1024,8 +1046,8 @@ input:focus, select:focus { border-color: var(--accent); } .card-tooltip .tip-consume, .card-tooltip .tip-cost { color: #e2c08a; } .card-tooltip .tip-none { color: var(--muted); } -.card-tooltip .res { display: inline-flex; align-items: center; gap: 3px; } -.card-tooltip .res img { width: 13px; height: 13px; } +.card-tooltip .res { display: inline; white-space: nowrap; } +.card-tooltip .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; } /* The tile panel leads with what the tile actually produces. */ #tile-production { margin: 4px 0 6px; display: flex; flex-direction: column; gap: 2px; } @@ -1310,6 +1332,8 @@ input:focus, select:focus { border-color: var(--accent); } border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .modal-sub { padding: 10px 18px; color: var(--muted); } +.modal-sub .res { display: inline; white-space: nowrap; } +.modal-sub .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; } .modal-body { padding: 12px 18px 18px; overflow-y: auto; } .modal-close { padding: 4px 11px; } @@ -1467,6 +1491,8 @@ input:focus, select:focus { border-color: var(--accent); } .row-card .name { font-weight: 600; } .row-card .desc { color: var(--muted); font-size: 12px; margin: 3px 0; } .row-card .effects { font-size: 12px; color: #bcd4ff; } +.row-card .effects .res { display: inline; white-space: nowrap; } +.row-card .effects .res img { width: 13px; height: 13px; vertical-align: -2px; margin-right: 3px; } .row-card .consume { font-size: 12px; color: #e2c08a; margin-top: 2px; } .row-card .cost { font-size: 12px; color: var(--muted); margin-top: 3px; } .row-card .cost .cost-money { color: var(--text); } @@ -1484,42 +1510,32 @@ input:focus, select:focus { border-color: var(--accent); } .row-card .budget-detail { margin-top: 4px; } .row-card .budget-line { font-size: 12px; color: var(--muted); margin-top: 2px; } -/* Technology cards are wide, short 3:1 tiles in a grid, so the tab shows many - more of them at once than a single stacked list would. */ +/* Technology cards are the same building cards as the city tab, tiled across + the tab so many read at once, grouped by the theme sub-tabs. */ #technology-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + grid-template-columns: repeat(auto-fill, 178px); gap: 8px; align-content: start; } -#technology-list .row-card { - margin-bottom: 0; - aspect-ratio: 3 / 1; - overflow: hidden; - padding: 8px 10px; - gap: 10px; -} -#technology-list .row-card .desc, -#technology-list .row-card .effects { - margin: 2px 0; - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} -#technology-list .row-card .actions { justify-content: flex-start; align-items: flex-start; } -#technology-list .row-card .actions .aero { min-width: 0; padding: 3px 12px; } -/* A focused technology fills a bar with the points committed beside it. */ -#technology-list .cost { display: flex; align-items: center; gap: 8px; } -.tech-progress-track { - flex: 1 1 auto; - height: 8px; - background: rgba(255, 255, 255, 0.14); - border-radius: 4px; +/* A focused technology turns its action button into the progress bar: the + committed share fills behind, while the "Focused" label and the points sit on + top. The disabled dimming is lifted so the bar stays legible. */ +.building-tile .bt-upgrade { + position: relative; overflow: hidden; } -.tech-progress-fill { height: 100%; background: linear-gradient(90deg, #4f8cff, #7fd1ff); } -.tech-progress-text { white-space: nowrap; font-variant-numeric: tabular-nums; } +.building-tile .bt-upgrade .bt-progress-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: linear-gradient(90deg, rgba(79, 140, 255, 0.7), rgba(127, 209, 255, 0.7)); + pointer-events: none; + z-index: 0; +} +.building-tile .bt-upgrade .bt-up-label, +.building-tile .bt-upgrade .bt-meta { position: relative; z-index: 1; } +.building-tile .bt-upgrade.progressing:disabled { opacity: 1; } .row-card.conflict-card { display: block; cursor: pointer; } .row-card.conflict-card:hover { background: rgba(255, 255, 255, 0.09); } @@ -1690,23 +1706,10 @@ input:focus, select:focus { border-color: var(--accent); } #modal-city #city-train-list { flex: 0 0 auto; width: 100%; - max-height: 168px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 8px; } -#modal-city #city-train-list .train-button { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 10px; - text-align: left; -} -#city-train-list .train-name { font-weight: 600; } -#city-train-list .train-resources { display: inline-flex; align-items: center; gap: 8px; } -#city-train-list .train-price { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } -#city-train-list .train-time { color: var(--muted); font-size: 12px; margin-left: auto; font-variant-numeric: tabular-nums; } /* Local test game: the free, instant twin of an order button. */ .free-button { border: 1px dashed rgba(130, 225, 170, 0.75) !important; diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index fd6554b..53ce268 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -1,6 +1,12 @@ // Generated by scripts/generate-devlog.js from `git log`; do not edit. // The pre-commit hook refreshes it so the main menu shows the latest commits. export const DEVLOG = [ + { + "hash": "15d0c38", + "date": "2026-09-23", + "subject": "Gave figures a unified signed display and templated the resource table", + "body": "" + }, { "hash": "35c46ab", "date": "2026-09-23", @@ -54,11 +60,5 @@ export const DEVLOG = [ "date": "2026-09-22", "subject": "Added Bulgaria and Northumbria as playable nations", "body": "Bulgaria (Sofia, Plovdiv, Varna...) and Northumbria (Newcastle upon Tyne, Washington, Gateshead...) join the roster with their own colours, currencies, city-name pools and 128x128 flag art. France and Britain also lead with their capitals now, Paris and London." - }, - { - "hash": "c2f131d", - "date": "2026-09-22", - "subject": "Turned tile improvements into construction sites with their own budgets", - "body": "Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.\n\nThat reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.\n\nBundles the in-progress chart sizing for the central bank panel." } ]; diff --git a/client/js/modals/city.js b/client/js/modals/city.js index 762edb6..e7c1291 100644 --- a/client/js/modals/city.js +++ b/client/js/modals/city.js @@ -4,11 +4,11 @@ import { groupDigits, hours } from "../../../shared/text_format.js"; import { ECONOMY, BUILDINGS, resourceById, RESOURCE_IDS } from "../../../shared/data.js"; -import { constructionResourceCost, formatResourceAmount, protoUpkeep } from "../../../shared/resources.js"; +import { constructionResourceCost, formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../../../shared/resources.js"; import { buildingBuildCost, } from "../../../shared/rules.js"; -import { buildingEffectText, signedMoney, quotedBuildCost } from "./format.js"; +import { buildingEffectNodes, buildingEffectText, signedMoney, quotedBuildCost } from "./format.js"; import { renderBudget } from "./budget.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; @@ -16,6 +16,7 @@ import { buildCard, setCardMeta, tooltipSections, + resourceBill, CARD_RESOURCES, hideCardTooltip, } from "../ui/card.js"; @@ -356,65 +357,101 @@ export class CityModal { return quotedBuildCost(cost, constructionResourceCost(proto, level, cost), this.prices); } - // The materials a construction spends, each with its icon, so a unit's cost - // reads in resources first and the money figure stays secondary. - _resourceCostEl(resources) { - const $wrap = $(""); - for (const id of ["steel", "energy", "hightech"]) { - const amount = resources[id] || 0; - if (!(amount > 0)) continue; - const proto = resourceById(id); - $wrap.append( - $("").append( - $("") - .attr("src", `assets/${proto.icon}`) - .attr("alt", proto.name) - .attr("title", proto.name), - $("").text(formatResourceAmount(id, amount)) - ) - ); - } - return $wrap; - } - _renderTrainList(gdp, trainableUnits) { this.protoNames = new Map(); this.$trainList.empty(); + const $grid = $("
"); + this.$trainList.append($grid); const signature = []; for (let index = 0; index < trainableUnits.length; index++) { const proto = trainableUnits[index]; if (!proto.cost && proto.cost !== 0) continue; - const button = $(""); - if (proto.icon) { - button.append( - $("").attr({ src: `assets/${proto.icon}`, alt: proto.name }) - ); - } - const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor(); - button.append( - $("").text(proto.name), - this._resourceCostEl(constructionResourceCost(proto, 0, proto.cost)), - $("").text( - `${currencySymbol()}${groupDigits(this._quotedCost(proto.cost, proto, 0))}` - ), - $("").text(hours(duration)) - ); const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index; + const duration = ECONOMY.trainingHours(gdp, proto.cost) * this._constructionTimeFactor(); signature.push(serverIndex); this.protoNames.set(serverIndex, proto.name); - button.on("click", () => this.onTrain(this.cityId, serverIndex)); - this.$trainList.append(button); + const card = buildCard({ + icon: proto.icon, + name: proto.name, + status: this._unitStatus(proto), + note: this._unitNote(proto), + action: { + label: "Train", + money: this._quotedCost(proto.cost, proto, 0), + hours: hours(duration), + }, + tip: () => this._unitTip(proto), + onAction: () => this.onTrain(this.cityId, serverIndex), + }); + // In a local test game, a second button trains the unit for free. if (this.testing) { - const free = $("").text( - `Free & instant — ${proto.name}` - ); - free.on("click", () => this.onFreeTrain(this.cityId, serverIndex)); - this.$trainList.append(free); + const $free = $("") + .text(`Free · ${proto.name}`) + .on("click", () => this.onFreeTrain(this.cityId, serverIndex)); + card.$card.append($free); } + $grid.append(card.$card); } this._trainableSignature = signature.join(","); } + // The unit's one-line summary on its card: its fighting figures for a combat + // unit, what it carries or sees otherwise. + _unitStatus(proto) { + if (proto.military) return `Attack ${proto.attack} · Defense ${proto.defense}`; + if (proto.transportCapacity) return `Carries ${proto.transportCapacity} ${proto.transportKind}`; + if (proto.vision) return `Vision ${proto.vision}`; + return "Civilian"; + } + + // The materials and food a unit wears out standing each day. Energy is left + // out: it is fed to the grid through the city it stands in, not billed to the + // unit. Aircraft and ships eat nothing, so only their materials show. + _unitUpkeepItems(proto) { + const upkeep = protoUpkeep(proto, proto.cost); + return [ + { id: "steel", amount: upkeep.steel }, + { id: "food", amount: unitFoodPerDay(proto) }, + { id: "hightech", amount: upkeep.hightech }, + ].filter((item) => item.amount > 0); + } + + // The unit's daily maintenance, paid in materials and food rather than money, + // drawn as the same icon bill a building's consumes uses. + _unitNote(proto) { + const items = this._unitUpkeepItems(proto); + if (items.length === 0) return ""; + return resourceBill(items, { sign: "−", suffix: "/day" }); + } + + // The hover detail for a unit card: what it is worth in a fight, what it + // wears out each day and the materials training it spends. + _unitTip(proto) { + const stats = []; + if (proto.military) { + stats.push(`Attack ${proto.attack}`, `Defense ${proto.defense}`); + if (proto.range > 1) stats.push(`Range ${proto.range}`); + } + if (proto.vision) stats.push(`Vision ${proto.vision}`); + if (proto.transportCapacity) { + stats.push(`Carries ${proto.transportCapacity} ${proto.transportKind}`); + } + if (proto.air) stats.push(`Endurance ${proto.enduranceHours}h`); + if (stats.length === 0) stats.push("Civilian"); + const sections = [ + { label: "Unit", cls: "tip-produce", sign: "+", suffix: "", items: stats.map((text) => ({ text })) }, + ]; + const consume = this._unitUpkeepItems(proto); + if (consume.length > 0) { + sections.push({ label: "Upkeep", cls: "tip-consume", sign: "−", suffix: "/day", items: consume }); + } + const cost = this._resourceItems(constructionResourceCost(proto, 0, proto.cost)); + if (cost.length > 0) { + sections.push({ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost }); + } + return sections; + } + // Rebuilds the trainable list only when the set changes, so a building that // finishes while the panel is open (an airport, say) unlocks its unit without // reopening the city and without discarding the list's scroll position. @@ -540,7 +577,7 @@ export class CityModal { const cost = buildingBuildCost(row.proto, row.level + row.pending); const materials = constructionResourceCost(row.proto, row.level + row.pending, cost); return tooltipSections({ - produce: effect && effect !== "No effect" ? [{ text: effect }] : [], + produce: effect && effect !== "No effect" ? [{ node: buildingEffectNodes(row.proto, shownLevel) }] : [], consume: this._resourceItems(upkeep), consumeSuffix: "/day", cost: this._resourceItems(materials), diff --git a/client/js/modals/format.js b/client/js/modals/format.js index fc76d6d..a5f80f3 100644 --- a/client/js/modals/format.js +++ b/client/js/modals/format.js @@ -4,6 +4,7 @@ import { groupDigits } from "../../../shared/text_format.js"; import { policyById, resourceById } from "../../../shared/data.js"; import { marketPriceIndex, resourceMarketValue } from "../../../shared/resources.js"; import { formatMoneySigned } from "../currency.js"; +import { researchNode } from "../ui/card.js"; // The world market's current prices, from the snapshot's market listing. export function marketPricesFrom(snapshot) { @@ -95,6 +96,30 @@ export function buildingEffectText(proto, level) { } } +// The effect list as DOM, so a research effect can carry the erlenmeyer icon in +// place of the word "research". Every other effect reads as its plain text. +export function effectNodes(effects, level) { + const $frag = $(""); + (effects || []).forEach((effect, index) => { + if (index > 0) $frag.append(document.createTextNode(" · ")); + if (effect.stat === "research") { + const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent); + $frag.append(researchNode(value, { sign: value >= 0 ? "+" : "", decimals: 2 })); + $frag.append(document.createTextNode("/h")); + } else { + $frag.append(document.createTextNode(formatEffect(effect, level))); + } + }); + return $frag; +} + +// The effect line for a building as DOM: a stats building shows its effects with +// the research icon, a utility building its rule text. +export function buildingEffectNodes(proto, level) { + if (proto.effects && proto.effects.length > 0) return effectNodes(proto.effects, level); + return document.createTextNode(buildingEffectText(proto, level)); +} + export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" }; export function signedMoney(value) { diff --git a/client/js/modals/nation.js b/client/js/modals/nation.js index 07ca2c0..4bc9bc0 100644 --- a/client/js/modals/nation.js +++ b/client/js/modals/nation.js @@ -17,9 +17,10 @@ import { } from "../../../shared/data.js"; import { formatResourceAmount } from "../../../shared/resources.js"; import { renderMonthlyBudget } from "./budget.js"; -import { effectsText, money, quotedMoney } from "./format.js"; +import { effectNodes, money, quotedMoney } from "./format.js"; import { bindTabs } from "./tabs.js"; import { ethnicityChart } from "../ui/ethnicity.js"; +import { buildCard, researchNode, setCardMeta } from "../ui/card.js"; import { resourceNode, valueNode } from "../ui/value.js"; import { lineChart } from "../ui/line_chart.js"; import { trendArrow } from "../ui/trend.js"; @@ -31,6 +32,15 @@ import { MONTH_NAMES } from "../../../shared/game_clock.js"; // monthly commodity index stays 24 points instead of growing with the game. const PRICE_INDEX_WINDOW_MONTHS = 24; +// Each research theme borrows the building-card icon of its field, so a +// technology card reads with the same visual grammar as a building. +const TECH_THEME_ICONS = { + science: "icon_university.svg", + economy: "icon_bank.svg", + society: "icon_museum.svg", + military: "icon_fortifications.svg", +}; + export class NationModal { constructor() { this.$modal = $("#modal-nation"); @@ -154,7 +164,8 @@ export class NationModal { this.$governmentList.empty(); this.$technologyList.empty(); this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government")); - this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology")); + this.technologyRows = technologies.map((proto, index) => this._makeTechnologyCard(proto, index)); + for (const row of this.technologyRows) this.$technologyList.append(row.card.$card); this._buildTechnologyThemes(); this._buildResourceSubtabs(); this._buildEconomySubtabs(); @@ -1197,6 +1208,67 @@ export class NationModal { return { proto, index, kind, $name, $effects, $cost, $action, $card }; } + // One technology card in the shared building-card idiom: the theme icon and + // name up top with the research state below, the description and effect in + // the hover tooltip, and a Focus button carrying the research cost. A focused + // technology fills the card's progress bar instead. + _makeTechnologyCard(proto, index) { + const row = { proto, index, repeat: 0 }; + const card = buildCard({ + icon: TECH_THEME_ICONS[proto.theme] || "icon_university.svg", + name: proto.name, + status: "", + action: { label: "Focus" }, + progress: true, + tip: () => this._technologyTip(row), + onAction: () => this.onFocusTechnology(index), + }); + row.card = card; + row.$card = card.$card; + row.$name = card.$card.find(".bt-name"); + row.$status = card.$status; + row.$action = card.$action; + row.$label = card.$label; + row.$meta = card.$meta; + row.$fill = card.$progressFill; + return row; + } + + // 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 + // gain is shown rather than the level already held. + const level = proto.repeatable ? row.repeat + 1 : 1; + const sections = []; + if (proto.description) { + sections.push({ label: "Research", cls: "tip-produce", sign: "+", suffix: "", items: [{ text: proto.description }] }); + } + sections.push({ + label: proto.repeatable ? `Next level (${row.repeat + 1})` : "Effect", + cls: "tip-produce", + sign: "+", + suffix: "", + items: [{ node: effectNodes(proto.effects, level) }], + }); + if ( + row.$action && + row.$action.prop("disabled") && + !this.researched.has(row.index) && + !prerequisitesMet(proto, this.researched, this.technologies) + ) { + 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; + } + refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) { this.currentGovernment = currentGovernment; if (techInfo) this.techInfo = techInfo; @@ -1209,8 +1281,13 @@ export class NationModal { `Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: ${currencySymbol()}${groupDigits(Math.round(budget))}` ); // Research has no pool: every point flows into the focused technology, so - // the panel shows the rate instead of a stockpile. - this.$technologySub.text(`Research: +${(researchRate || 0).toFixed(1)}/h`); + // the panel shows the rate instead of a stockpile, with the erlenmeyer icon + // standing in for the word "research". + this.$technologySub + .empty() + .append(document.createTextNode("Research: ")) + .append(researchNode(researchRate || 0, { sign: "+", decimals: 1 })) + .append(document.createTextNode("/h")); const fin = finances || {}; this._treasury = budget; @@ -1224,7 +1301,7 @@ export class NationModal { for (const row of this.governmentRows) { const proto = row.proto; row.$name.text(proto.name); - row.$effects.text(effectsText(proto.effects, 1)); + row.$effects.empty().append(effectNodes(proto.effects, 1)); if (row.index === currentGovernment) { row.$cost.text("Currently in power"); row.$action.text("Current government").prop("disabled", true); @@ -1242,41 +1319,47 @@ export class NationModal { const proto = row.proto; const repeat = repeatCounts[proto.id] || 0; const focused = this.techInfo.focus === row.index; - row.$name.text(repeat > 0 ? `${proto.name} — Level ${repeat}` : proto.name); - // Show the effect of the next level, so a repeatable's diminishing gain - // is clear before it is chosen. - row.$effects.text(effectsText(proto.effects, repeat > 0 ? repeat : 1)); + 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.$cost.text("Researched"); - row.$action.text("Researched").prop("disabled", true); + 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)) { - const names = proto.prerequisites - .map((id) => { - const found = this.technologies.find((t) => t.id === id); - return found ? found.name : id; - }) - .join(", "); - row.$cost.text(`Requires: ${names}`); - row.$action.text("Locked").prop("disabled", true); + 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 a progress bar, with the committed and - // total points to its right. + // 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.$cost.empty().append( - $("
").append( - $("
").css("width", `${pct}%`) - ), - $("").text( - `${groupDigits(Math.round(progress))} / ${groupDigits(Math.round(cost))}` - ) - ); - row.$action.text("Focused").prop("disabled", true); + 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.$cost.text(`Cost ${groupDigits(Math.round(cost))} research points`); - row.$action.text("Focus").prop("disabled", false); + row.$status.text(repeat > 0 ? `Level ${repeat}` : "Not researched"); + row.$label.text("Focus"); + setCardMeta(row.$meta, {}); + row.$meta.append(researchNode(cost)); } } this._applyTechnologyFilter(); diff --git a/client/js/ui/card.js b/client/js/ui/card.js index 47d851a..dec0110 100644 --- a/client/js/ui/card.js +++ b/client/js/ui/card.js @@ -6,6 +6,7 @@ import { resourceById } from "../../../shared/data.js"; import { formatResourceAmount } from "../../../shared/resources.js"; +import { groupDigits } from "../../../shared/text_format.js"; import { formatMoney } from "../currency.js"; // The materials a construction bills in, in reading order. @@ -23,6 +24,32 @@ function resourceLine(id, amount, sign) { return $span; } +// A research-point figure with its erlenmeyer icon. The icon stands in for the +// word "research", so no unit text follows: the science buildings, the +// technology cards and the research rate all read the same way. +export function researchNode(amount, { sign = "", decimals = 0 } = {}) { + const proto = resourceById("research"); + const $span = $(""); + if (proto) { + $span.append($("").attr("src", `assets/${proto.icon}`).attr("alt", proto.name)); + } + const value = Number(amount) || 0; + const text = decimals > 0 ? value.toFixed(decimals) : groupDigits(Math.round(value)); + $span.append(document.createTextNode(`${sign}${text}`)); + return $span; +} + +// An inline resource bill for a card: a muted label, then one icon-and-amount +// per material, then an optional suffix. Used for a unit's daily upkeep, which +// is paid in materials rather than money. +export function resourceBill(items, { label = "", sign = "−", suffix = "" } = {}) { + const $bill = $(""); + if (label) $bill.append($("").text(label)); + for (const item of items) $bill.append(resourceLine(item.id, item.amount, sign)); + if (suffix) $bill.append($("").text(suffix)); + return $bill; +} + // The three tooltip sections from a work's flows. Each entry is a resource // { id, amount } or free text { text }; free text is shown exactly as written. export function tooltipSections({ @@ -65,7 +92,8 @@ export function showCardTooltip($anchor, sections) { } else { for (const item of section.items) { const $line = $("
").addClass(`tip-line ${section.cls}`); - if (item.id) $line.append(resourceLine(item.id, item.amount, section.sign)); + if (item.node) $line.append(item.node); + else if (item.id) $line.append(resourceLine(item.id, item.amount, section.sign)); else $line.text(item.text); if (section.suffix) $line.append(document.createTextNode(section.suffix)); $section.append($line); @@ -85,19 +113,26 @@ export function showCardTooltip($anchor, sections) { } } -// Puts the action button's money and time in its meta line. The money is an +// Puts the action button's price and time in its meta line. The money is an // estimate, so it is italic and prefixed with a tilde; nothing is shown when -// the work has no price (locked or already standing). -export function setCardMeta($meta, { money = null, hours = "" } = {}) { +// the work has no price (locked or already standing). Callers whose cost is not +// money pass free `text` instead, shown verbatim. +export function setCardMeta($meta, { money = null, hours = "", text = "" } = {}) { $meta.empty(); - if (money == null) return; - $meta.append($("").text(`~${formatMoney(money)}`)); - if (hours) $meta.append(document.createTextNode(` · ${hours}`)); + if (money != null) { + $meta.append($("").text(`~${formatMoney(money)}`)); + if (hours) $meta.append(document.createTextNode(` · ${hours}`)); + } else if (text) { + $meta.text(text); + } } // Builds a card. `spec` carries: // icon, name, status, statusBuilt, built, locked -// action { label, money, hours, disabled } +// note (a muted line under the header, e.g. a unit's upkeep) +// action { label, money, hours, text, disabled } +// progress (the action button carries a fill the caller sets, +// e.g. focused research; label and meta sit above it) // remove { label, title, enabled } (omit for no remove control) // tip () => tooltip sections // onAction, onRemove @@ -122,8 +157,23 @@ export function buildCard(spec) { $top.append($id); $card.append($top); + let $note = null; + if (spec.note) { + $note = $("
"); + if (typeof spec.note === "string") $note.text(spec.note); + else $note.append(spec.note); + $card.append($note); + } + const action = spec.action || {}; const $action = $("").prop("disabled", !!action.disabled); + // A progress fill lives behind the button's own label and meta, so the + // button itself reads as the bar. + let $progressFill = null; + if (spec.progress) { + $progressFill = $(""); + $action.append($progressFill); + } $action.append($("").text(action.label || "")); const $meta = $(""); setCardMeta($meta, action); @@ -145,5 +195,14 @@ export function buildCard(spec) { $card.append($remove); } - return { $card, $status, $action, $label: $action.find(".bt-up-label"), $meta, $remove }; + return { + $card, + $status, + $action, + $label: $action.find(".bt-up-label"), + $meta, + $remove, + $note, + $progressFill, + }; } diff --git a/shared/data/resources.js b/shared/data/resources.js index 81e2fbf..81eca64 100644 --- a/shared/data/resources.js +++ b/shared/data/resources.js @@ -92,7 +92,23 @@ export const RESOURCE_EFFICIENCY_EFFECT = { hightech: EFFECT_HIGHTECH_EFFICIENCY, }; +// Research is the nation's stock of knowledge: produced by its science +// buildings and a few governments, and spent on the focused technology. It is a +// special kind of resource -- a unit of it cannot be bought, sold, stored or +// transported -- so it is deliberately kept out of the tradeable catalogue +// above and never appears in the market, the stockpiles or a delivery. Only +// science production and the technology tree ever show it, always as this +// erlenmeyer icon standing in for the word "research". +export const RESEARCH = { + id: "research", + name: "Research", + unit: "", + icon: "icon_research.svg", + colour: "#a142b6", +}; + export function resourceById(id) { + if (id === RESEARCH.id) return RESEARCH; return RESOURCES[RESOURCE_INDEX[id]] || null; } diff --git a/shared/data/units.js b/shared/data/units.js index 3c70e18..fa4c022 100644 --- a/shared/data/units.js +++ b/shared/data/units.js @@ -11,7 +11,6 @@ export const PROTO_UNITS = [ name: "Modern infantry", maxHp: 500, cost: 100_000_000, - upkeep: 5000, moveable: true, speed: 0.5, military: true, @@ -29,7 +28,6 @@ export const PROTO_UNITS = [ name: "Worker", maxHp: 100, cost: 0, - upkeep: 0, moveable: true, speed: 0.5, military: false, @@ -45,7 +43,6 @@ export const PROTO_UNITS = [ name: "Artillery", maxHp: 400, cost: 300_000_000, - upkeep: 12_000, moveable: true, speed: 0.25, military: true, @@ -66,7 +63,6 @@ export const PROTO_UNITS = [ name: "Jet fighter", maxHp: 300, cost: 400_000_000, - upkeep: 20_000, moveable: true, speed: 5, military: true, @@ -92,7 +88,6 @@ export const PROTO_UNITS = [ name: "Bomber", maxHp: 350, cost: 500_000_000, - upkeep: 25_000, moveable: true, speed: 5, military: true, @@ -116,7 +111,6 @@ export const PROTO_UNITS = [ name: "Battleship", maxHp: 800, cost: 600_000_000, - upkeep: 30_000, moveable: true, speed: 0.5, military: true, @@ -134,7 +128,6 @@ export const PROTO_UNITS = [ name: "Mobile radar", maxHp: 200, cost: 50_000_000, - upkeep: 5000, moveable: true, speed: 0.5, military: false, @@ -154,7 +147,6 @@ export const PROTO_UNITS = [ name: "Launch", maxHp: 150, cost: 20_000_000, - upkeep: 2000, moveable: true, speed: 0.5, military: false, diff --git a/shared/game_state/resources.js b/shared/game_state/resources.js index 31d1a6d..8fd59ac 100644 --- a/shared/game_state/resources.js +++ b/shared/game_state/resources.js @@ -26,6 +26,7 @@ import { import { cityEnergyPerDay, foodNeedPerDay, + unitFoodPerDay, tileFoodOutput, steelNeedPerDay, luxuryNeedPerDay, @@ -2563,8 +2564,7 @@ export const resourceMethods = { for (const unit of this.units) { const proto = this.unitProto(unit); if (!proto || !this._isLandUnitProto(proto)) continue; - const soldiers = proto.population || RESOURCE_RULES.unitPopulation; - const ration = foodNeedPerDay(soldiers); + const ration = unitFoodPerDay(proto); const here = this.cityAt(unit.coords); const resting = !!(here && here.civ === unit.civ && unit.hp < unit.maxHp); const multiplier = resting diff --git a/shared/resources.js b/shared/resources.js index b434e48..72b46d8 100644 --- a/shared/resources.js +++ b/shared/resources.js @@ -56,6 +56,16 @@ export function foodNeedPerDay(population) { return Math.max(0, population) * RESOURCE_RULES.foodPerPersonPerDay; } +// The food a unit eats in a day from its carried stores. Aircraft and ships do +// not: they live on endurance and range instead. Every land unit fields a +// company of `population` (or the rule's default), fed like the civilians of +// that size. Mirrors GameState._tickUnitSupply so the card and the tick agree. +export function unitFoodPerDay(proto) { + if (!proto || proto.air || proto.naval) return 0; + if (!(proto.traversableTerrains || []).includes("Land")) return 0; + return foodNeedPerDay(proto.population || RESOURCE_RULES.unitPopulation); +} + export function steelNeedPerDay(population) { return Math.max(0, population) * RESOURCE_RULES.steelPerPersonPerDay; } diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js index a939b17..3e687ab 100644 --- a/tests/game_screen_test.js +++ b/tests/game_screen_test.js @@ -116,7 +116,7 @@ export class GameScreenTest extends TestCase { env.$("#civ-flag").click(); this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertSize(env.$("#government-list .row-card"), 3); - this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length); + this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length); screen.leave(); } finally { teardownDom(env); @@ -1112,9 +1112,9 @@ export class GameScreenTest extends TestCase { env.$("#civ-flag").click(); env.$("#modal-nation .tab[data-tab=technology]").click(); const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); - env.$("#technology-list .row-card") + env.$("#technology-list .building-tile") .eq(method) - .find(".actions button") + .find(".bt-upgrade") .click(); this.assertEqual(network.orders[0], { type: "focus_technology", diff --git a/tests/modals_test.js b/tests/modals_test.js index e7b14c3..7ca04bc 100644 --- a/tests/modals_test.js +++ b/tests/modals_test.js @@ -5,6 +5,7 @@ import { buildingEffectText, describeNews } from "../client/js/modals/format.js" import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS, technologyCost } from "../shared/data.js"; import { groupDigits } from "../shared/text_format.js"; import { setCurrency, setCurrencyBook } from "../client/js/currency.js"; +import { formatResourceAmount, protoUpkeep, unitFoodPerDay } from "../shared/resources.js"; export class CityModalTest extends TestCase { test_utility_buildings_describe_their_mechanic() { @@ -18,6 +19,27 @@ export class CityModalTest extends TestCase { const airport = BUILDINGS.find((b) => b.id === "airport"); this.assertTrue(buildingEffectText(airport, 1).includes("production"), "stats keep their effect text"); } + + async test_research_buildings_hover_with_the_erlenmeyer_icon() { + const env = await setupDom(); + try { + const modal = new CityModal(); + modal.show({ id: 1, name: "Lyon" }, { budget: 1.0e15, buildings: BUILDINGS, levels: {} }); + const index = BUILDINGS.findIndex((b) => b.id === "research_lab"); + const card = env.$("#city-buildings-list .building-tile").eq(index); + card.find(".bt-upgrade").trigger("mouseenter"); + const $tip = env.$(".card-tooltip"); + this.assertEqual( + $tip.find("img[src$='icon_research.svg']").length, + 1, + "the lab's science output carries the erlenmeyer icon" + ); + this.assertFalse($tip.text().includes("research"), "the unit word is replaced by the icon"); + modal.close(); + } finally { + teardownDom(env); + } + } showTrain(modal, queue = []) { modal.show( { id: 1, name: "Lyon" }, @@ -43,7 +65,23 @@ export class CityModalTest extends TestCase { this.showTrain(modal); this.assertEqual(env.$("#city-title").text(), "Lyon — City"); this.assertSize(env.$("#city-train-list button"), 2, "infantry and artillery can be trained"); - this.assertTrue(env.$("#city-train-list button").first().text().includes("Modern infantry")); + this.assertTrue( + env.$("#city-train-list .bt-name").first().text().includes("Modern infantry"), + "the unit is named on its card" + ); + const upkeep = protoUpkeep(PROTO_UNITS[0], PROTO_UNITS[0].cost); + const note = env.$("#city-train-list .bt-note").first().text(); + this.assertFalse(note.includes("Upkeep"), "the note just shows the resources"); + this.assertTrue( + note.includes(formatResourceAmount("steel", upkeep.steel)), + "the unit's steel upkeep is on its card" + ); + this.assertTrue( + note.includes(formatResourceAmount("food", unitFoodPerDay(PROTO_UNITS[0]))), + "the unit's food upkeep is on its card" + ); + this.assertFalse(note.includes("MWh"), "energy is not billed to the unit"); + this.assertFalse(note.includes("€") || note.includes("¤"), "no money upkeep is shown"); env.$("#city-train-list button").first().click(); this.assertEqual(requested, [1, 0]); modal.close(); @@ -373,7 +411,7 @@ export class NationModalTest extends TestCase { }; modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length); - this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length); + this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length); const governmentRows = env.$("#government-list .row-card"); this.assertTrue(governmentRows.eq(0).find(".actions button").prop("disabled"), "current government is locked"); @@ -383,14 +421,14 @@ export class NationModalTest extends TestCase { // A prerequisite-gated technology starts locked. const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation"); - const techRows = env.$("#technology-list .row-card"); - this.assertEqual(techRows.eq(automation).find(".actions button").text(), "Locked"); + const techRows = env.$("#technology-list .building-tile"); + this.assertEqual(techRows.eq(automation).find(".bt-upgrade").text(), "Locked"); // Researching its prerequisite unlocks it. modal.refresh(0, new Set([method]), 1.0e9, 1.0e15, 1.0e6); - this.assertTrue(techRows.eq(method).find(".actions button").text().startsWith("Researched")); - this.assertTrue(techRows.eq(automation).find(".actions button").text().startsWith("Focus")); - techRows.eq(automation).find(".actions button").click(); + this.assertTrue(techRows.eq(method).find(".bt-upgrade").text().startsWith("Researched")); + this.assertTrue(techRows.eq(automation).find(".bt-upgrade").text().startsWith("Focus")); + techRows.eq(automation).find(".bt-upgrade").click(); this.assertEqual(technology, automation); env.$("#modal-nation .tab[data-tab=technology]").click(); @@ -413,7 +451,7 @@ export class NationModalTest extends TestCase { this.assertGreater($themes.length, 1, "the technologies are split into themes"); const method = TECHNOLOGIES.findIndex((t) => t.id === "scientific_method"); const automation = TECHNOLOGIES.findIndex((t) => t.id === "industrial_automation"); - const rows = env.$("#technology-list .row-card"); + const rows = env.$("#technology-list .building-tile"); this.assertNotEqual(rows.eq(method).css("display"), "none", "science is the opening theme"); this.assertEqual(rows.eq(automation).css("display"), "none", "other themes start hidden"); $themes.filter((_index, element) => env.$(element).text() === "Economy").trigger("click"); @@ -436,10 +474,16 @@ export class NationModalTest extends TestCase { repeatCounts: { infantry_weapons: 3 }, }); const index = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); - const name = env.$("#technology-list .row-card").eq(index).find(".name").text(); - this.assertTrue(name.includes("Level 3"), `the level is shown: ${name}`); - const text = env.$("#technology-list .row-card").eq(index).find(".cost").text(); - this.assertTrue(text.startsWith("Cost"), "the next level is priced"); + const card = env.$("#technology-list .building-tile").eq(index); + this.assertTrue(card.find(".bt-level").text().includes("Level 3"), "the level is shown"); + const $meta = card.find(".bt-meta"); + this.assertEqual( + $meta.find("img[src$='icon_research.svg']").length, + 1, + "the erlenmeyer icon stands in for the research unit" + ); + this.assertTrue($meta.text().length > 0, `the next level is priced: ${$meta.text()}`); + this.assertFalse($meta.text().includes("RP"), "no text 'RP' unit is left"); modal.close(); } finally { teardownDom(env); @@ -540,14 +584,15 @@ export class NationModalTest extends TestCase { progress: 10, repeatCounts: {}, }); - const card = env.$("#technology-list .row-card").eq(method); - this.assertTrue(card.find(".tech-progress-track").length > 0, "the focus has a bar"); + const card = env.$("#technology-list .building-tile").eq(method); + this.assertTrue(card.find(".bt-upgrade .bt-progress-fill").length > 0, "the button is the bar"); + this.assertNotEqual(card.find(".bt-upgrade .bt-progress-fill").css("width"), "0%", "the bar is filled"); this.assertEqual( - card.find(".tech-progress-text").text(), + card.find(".bt-meta").text(), `10 / ${groupDigits(technologyCost(TECHNOLOGIES[method]))}`, "X / Y beside it" ); - this.assertEqual(card.find(".actions button").text(), "Focused", "no cost on the button"); + this.assertEqual(card.find(".bt-up-label").text(), "Focused", "no cost on the button"); modal.close(); } finally { teardownDom(env); @@ -560,9 +605,10 @@ export class NationModalTest extends TestCase { const modal = new NationModal(); modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 5); const infantry = TECHNOLOGIES.findIndex((t) => t.id === "infantry_weapons"); - this.assertEqual( - env.$("#technology-list .row-card").eq(infantry).find(".effects").text(), - "+1.0% infantry attack" + env.$("#technology-list .building-tile").eq(infantry).find(".bt-upgrade").trigger("mouseenter"); + this.assertTrue( + env.$(".card-tooltip").text().includes("+1.0% infantry attack"), + "the effect is in the hover detail" ); modal.close(); } finally { @@ -570,6 +616,25 @@ export class NationModalTest extends TestCase { } } + async test_the_research_rate_reads_with_the_erlenmeyer_icon() { + const env = await setupDom(); + try { + const modal = new NationModal(); + modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 2.5); + const $sub = env.$("#technology-sub"); + this.assertEqual( + $sub.find("img[src$='icon_research.svg']").length, + 1, + "the rate carries the erlenmeyer icon" + ); + this.assertTrue($sub.text().includes("2.5"), "the rate is shown"); + this.assertFalse($sub.text().includes("RP"), "no text 'RP' unit is left"); + modal.close(); + } finally { + teardownDom(env); + } + } + async test_reopening_rebuilds_rows_instead_of_stacking_them() { const env = await setupDom(); try { @@ -583,8 +648,8 @@ export class NationModalTest extends TestCase { // Reopen with enough research to afford the first technology. modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6); this.assertSize(env.$("#government-list .row-card"), GOVERNMENTS.length, "no stacked governments"); - this.assertSize(env.$("#technology-list .row-card"), TECHNOLOGIES.length, "no stacked technologies"); - const button = env.$("#technology-list .row-card").first().find(".actions button"); + this.assertSize(env.$("#technology-list .building-tile"), TECHNOLOGIES.length, "no stacked technologies"); + const button = env.$("#technology-list .building-tile").first().find(".bt-upgrade"); this.assertFalse(button.prop("disabled"), "the rebuilt button reflects the new budget"); button.click(); this.assertEqual(technology, 0);