diff --git a/proprietary/images/Statisticsicon.png b/proprietary/images/Statisticsicon.png new file mode 100644 index 000000000..f24384920 Binary files /dev/null and b/proprietary/images/Statisticsicon.png differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c554eae87..4edb3ed3e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -11,7 +11,7 @@ import { import { createGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; -import { PlayerActions, UnitType } from "../core/game/Game"; +import { PlayerActions, PlayerType, UnitType } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { ErrorUpdate, @@ -43,6 +43,8 @@ import { } from "./Transport"; import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; +import { AVAILABLE_STATS, computeStatValue } from "./stats/StatDefinitions"; +import statsStore from "./stats/StatsStore"; export interface LobbyConfig { serverConfig: ServerConfig; @@ -238,6 +240,24 @@ export class ClientGameRunner { public start() { console.log("starting client game"); + // Initialize stats collection + for (const metric of AVAILABLE_STATS) { + statsStore.start( + metric, + () => + this.gameView + .players() + .filter((p) => + [PlayerType.Human, PlayerType.FakeHuman].includes( + p.type() as PlayerType, + ), + ), + (m, p) => computeStatValue(this.gameView, m, p).sortValue, + (p) => p.isAlive(), + () => this.gameView.ticks(), + ); + } + this.isActive = true; this.lastMessageTime = Date.now(); setTimeout(() => { @@ -289,6 +309,7 @@ export class ClientGameRunner { }); this.gameView.update(gu); this.renderer.tick(); + statsStore.onTick(gu.tick); if (gu.updates[GameUpdateType.Win].length > 0) { this.saveGame(gu.updates[GameUpdateType.Win][0]); diff --git a/src/client/StatisticsModal.ts b/src/client/StatisticsModal.ts new file mode 100644 index 000000000..b3b0886ee --- /dev/null +++ b/src/client/StatisticsModal.ts @@ -0,0 +1,1261 @@ +import { LitElement, PropertyValues, html, svg } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { PlayerType, UnitType } from "../core/game/Game"; +import { GameView, PlayerView } from "../core/game/GameView"; +import { getTechNodes, type Category } from "../core/tech/ResearchTree"; +import "./components/baseComponents/Modal"; +import { AVAILABLE_STATS, computeStatValue } from "./stats/StatDefinitions"; +import statsStore from "./stats/StatsStore"; + +@customElement("statistics-modal") +export class StatisticsModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + isModalOpen: boolean; + }; + + @state() private _tick = 0; // drives periodic re-render + private _intervalId: any = null; + + public open() { + this.updateComplete.then(() => { + this.modalEl?.open(); + this._startAutoRefresh(); + }); + } + + private _startAutoRefresh() { + if (this._intervalId) return; + this._intervalId = setInterval(() => { + if (!this.modalEl?.isModalOpen) { + this._stopAutoRefresh(); + return; + } + this._tick++; + if (this.activeTab === "Graph" && !this._graphPaused) + this._ensureGraphSampling(); + }, 1000); + } + + private _stopAutoRefresh() { + if (this._intervalId) { + clearInterval(this._intervalId); + this._intervalId = null; + } + // Do NOT stop sampling metrics here; we want them to continue in background + this._stopGraphRenderLoop(); + } + + @state() private activeTab: "Overview" | "List" | "Graph" = "Overview"; + @property({ type: Object }) game: GameView | null = null; + @state() private selectedPlayerId: string | null = null; + // List tab state + @state() private _listRefreshTick = 0; + @state() private _listSelectedStats: [ + string, + string, + string, + string, + string, + ] = [ + "Industrial Production", + "Population", + "Productivity %", + "Road Quality %", + "Researched Techs", + ]; + @state() private _listSortIndex: number | null = null; // 0..3 + @state() private _listSortDir: "asc" | "desc" = "desc"; + // Graph tab state + @state() private _graphMetric: string | null = null; + @state() private _graphSelected: Set = new Set(); + private _graphActiveMetric: string | null = null; + @state() private _graphPaused: boolean = false; + @state() private _graphRenderTick: number = 0; + private _graphRenderIntervalId: any = null; + + private _playersForDropdown(): PlayerView[] { + if (!this.game) return []; + return this.game + .players() + .filter((p) => + [PlayerType.Human, PlayerType.FakeHuman].includes( + p.type() as PlayerType, + ), + ) + .sort((a, b) => a.displayName().localeCompare(b.displayName())); + } + + private _ensureSelection(): void { + if (this.selectedPlayerId) return; + const me = this.game?.myPlayer(); + if ( + me && + [PlayerType.Human, PlayerType.FakeHuman].includes(me.type() as PlayerType) + ) { + this.selectedPlayerId = me.id(); + return; + } + const first = this._playersForDropdown()[0]; + if (first) this.selectedPlayerId = first.id(); + } + + private _selectedPlayer(): PlayerView | null { + if (!this.game || !this.selectedPlayerId) return null; + return ( + this.game.players().find((p) => p.id() === this.selectedPlayerId) || null + ); + } + + private _changeTab(tab: "Overview" | "List" | "Graph") { + this.activeTab = tab; + // Stop auto-refresh on List tab; resume elsewhere + if (tab === "List") { + this._stopAutoRefresh(); + } else if (!this._intervalId) { + this._startAutoRefresh(); + } + } + + private _renderTabs() { + const tabs: Array<{ key: typeof this.activeTab; label: string }> = [ + { key: "Overview", label: "Overview" }, + { key: "List", label: "List" }, + { key: "Graph", label: "Graph" }, + ]; + return html`
+ ${tabs.map( + (t) => + html``, + )} +
`; + } + + private _renderContent() { + switch (this.activeTab) { + case "Overview": { + this._ensureSelection(); + const players = this._playersForDropdown(); + const sel = this._selectedPlayer(); + const economy = sel + ? (() => { + const gross = this.game?.config().grossGoldAdditionRate(sel) ?? 0; + const prodRate = sel.investmentRate(); + const roadRate = + (sel as any).roadInvestmentRate?.() ?? + sel.roadInvestmentRate?.() ?? + (sel as any).data?.roadInvestmentRate ?? + 0; + const researchRate = + (sel as any).researchInvestmentRate?.() ?? + sel.researchInvestmentRate?.() ?? + (sel as any).data?.researchInvestmentRate ?? + 0; + const perSecond = 10; // engine ~10 ticks per second + const prodAmt = gross * prodRate * perSecond; + const roadAmt = gross * roadRate * perSecond; + const researchAmt = gross * researchRate * perSecond; + const roadQuality = + sel.roadNetworkQuality?.() ?? + sel.roadNetworkQuality?.() ?? + (sel as any).roadNetworkQuality ?? + 100; + const roadCompletion = + sel.roadNetworkCompletion?.() ?? + sel.roadNetworkCompletion?.() ?? + (sel as any).roadNetworkCompletion ?? + 100; + return [ + ["Gold", sel.gold().toString()], + [ + "Industrial Production", + (sel as any).industrialProduction?.() ?? + (sel as any).industrialProduction ?? + "—", + ], + ["Population", sel.population().toString()], + ["Workers", sel.workers().toString()], + ["Troops", sel.troops().toString()], + ["Productivity", (sel.productivity() * 100).toFixed(1) + "%"], + [ + "Productivity Growth / min", + (sel.productivityGrowthPerMinute() * 100).toFixed(1) + "%", + ], + [ + "Investment – Production", + `${(prodRate * 100).toFixed(0)}% (${prodAmt.toFixed(2)})`, + ], + [ + "Investment – Roads", + `${(roadRate * 100).toFixed(0)}% (${roadAmt.toFixed(2)})`, + ], + [ + "Investment – Research", + `${(researchRate * 100).toFixed(0)}% (${researchAmt.toFixed(2)})`, + ], + ["Road Quality", `${Math.round(roadQuality)}%`], + ["Road Completion", `${Math.round(roadCompletion)}%`], + ] as Array<[string, string]>; + })() + : []; + // Structures list and counting logic identical to PlayerInfoOverlay ordering & semantics + const structureTypes: UnitType[] = [ + UnitType.City, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.Port, + UnitType.Warship, + UnitType.MissileSilo, + UnitType.SAMLauncher, + UnitType.Airfield, + UnitType.FighterJet, + UnitType.DefensePost, + ]; + const upgradeOwned: UnitType[] = [ + UnitType.City, + UnitType.Hospital, + UnitType.Academy, + UnitType.ResearchLab, + UnitType.Factory, + UnitType.Port, + ]; + const structures = sel + ? structureTypes.map((t) => { + const count = upgradeOwned.includes(t) + ? sel.unitsOwned(t) + : sel.units(t).length; + return [String(t), count.toString()]; + }) + : []; + const techsHighLevel: Array<[string, string]> = sel + ? [ + [ + "Researched Techs", + ((sel as any).data?.researchTreeTechs?.length ?? 0).toString(), + ], + ["Research Level", (sel as any).researchTechLevel?.() ?? "—"], + ["Priority Tech", sel.researchPriorityTech() ?? "None"], + ] + : []; + + const categories: Category[] = [ + "Land", + "Sea", + "Air", + "Nuclear", + "Economy", + ]; + const nodes = getTechNodes(); + const techsByCategory: Array<[string, string]> = sel + ? categories.map((cat) => { + const total = nodes.filter((n) => n.category === cat).length; + let researched = 0; + for (const n of nodes) { + if (n.category === cat && sel.hasResearchedTech(n.id)) { + researched++; + } + } + return [`${cat} Techs`, `${researched}/${total}`] as [ + string, + string, + ]; + }) + : []; + return html`
+
+ + +
+
+
+

Economy

+
    + ${economy.map( + ([k, v]) => + html`
  • + ${k}${v} +
  • `, + )} +
+
+
+

Structures

+
    + ${structures.map( + ([k, v]) => + html`
  • + ${k}${v} +
  • `, + )} +
+
+
+

Tech

+
    + ${techsHighLevel.map( + ([k, v]) => + html`
  • + ${k}${v} +
  • `, + )} +
+
    + ${techsByCategory.map( + ([k, v]) => + html`
  • + ${k}${v} +
  • `, + )} +
+
+
+
`; + } + case "List": { + const allPlayers = (this.game?.players?.() ?? []).filter((p) => + [PlayerType.Human, PlayerType.FakeHuman].includes( + p.type() as PlayerType, + ), + ); + const opts = this._availableListStats(); + const rows = allPlayers.map((p) => { + const values = this._listSelectedStats.map((key) => + this._computeStatValue(key, p), + ); + return { player: p, values }; + }); + // Sorting + const sorted = rows.slice(); + if (this._listSortIndex !== null) { + const idx = this._listSortIndex; + const dir = this._listSortDir; + sorted.sort((a, b) => { + // Special case: sort by player name when idx === -1 + if (idx === -1) { + const an = a.player.displayName?.() ?? a.player.name?.() ?? ""; + const bn = b.player.displayName?.() ?? b.player.name?.() ?? ""; + const cmp = an.localeCompare(bn); + return dir === "asc" ? cmp : -cmp; + } + const at = a.values[idx]?.sortText; + const bt = b.values[idx]?.sortText; + if (typeof at === "string" || typeof bt === "string") { + const as = (at ?? "").toString().toLowerCase(); + const bs = (bt ?? "").toString().toLowerCase(); + const cmp = as.localeCompare(bs); + return dir === "asc" ? cmp : -cmp; + } + const av = a.values[idx]?.sortValue ?? 0; + const bv = b.values[idx]?.sortValue ?? 0; + return dir === "asc" ? av - bv : bv - av; + }); + } + + const headerCell = (label: string, i: number) => { + const isActive = this._listSortIndex === i; + const dir = isActive ? this._listSortDir : null; + return html``; + }; + + return html`
+
+ ${[0, 1, 2, 3, 4].map((i) => { + const current = this._listSelectedStats[i]; + return html``; + })} + +
+
+
+ + ${this._listSelectedStats.map((l, i) => headerCell(l, i))} +
+
+ ${sorted.map( + (r) => + html`
+
+ ${r.player.displayName?.() ?? + r.player.name?.() ?? + "Player"} +
+ ${r.values.map( + (v) => + html`
+
${v.displayPrimary}
+ ${v.displaySecondary + ? html`
+ ${v.displaySecondary} +
` + : html``} +
`, + )} +
`, + )} +
+
+
`; + } + case "Graph": + return html`${this._renderGraphTab()}`; + } + } + + private _playersAll(): PlayerView[] { + if (!this.game) return []; + return this.game + .players() + .filter((p) => + [PlayerType.Human, PlayerType.FakeHuman].includes( + p.type() as PlayerType, + ), + ) + .slice() + .sort((a, b) => a.displayName().localeCompare(b.displayName())); + } + + private _ensureGraphDefaults() { + if (!this._graphMetric) this._graphMetric = this._availableListStats()[0]; + if (this._graphSelected.size === 0) { + const me = this.game?.myPlayer(); + if (me) this._graphSelected.add(me.id()); + } + } + + private _ensureGraphSampling() { + this._ensureGraphDefaults(); + // Sampling is now initialized in ClientGameRunner.ts + this._graphActiveMetric = this._graphMetric; + } + private _startGraphRenderLoop() { + if (this._graphRenderIntervalId) return; + const tick = () => { + if (this._graphPaused) return; + this._ensureGraphSampling(); + this._graphRenderTick++; + }; + this._graphRenderIntervalId = setInterval(tick, 10000); + tick(); + } + + private _stopGraphRenderLoop() { + if (this._graphRenderIntervalId) { + clearInterval(this._graphRenderIntervalId); + this._graphRenderIntervalId = null; + } + } + + private _toggleGraphPause() { + this._graphPaused = !this._graphPaused; + if (this._graphPaused) { + // Do NOT stop sampling (statsStore.stop) so data collects in background + this._stopGraphRenderLoop(); + } else { + this._ensureGraphSampling(); + this._startGraphRenderLoop(); + } + } + + private _toggleGraphPlayer(pid: string) { + const next = new Set(this._graphSelected); + if (next.has(pid)) next.delete(pid); + else next.add(pid); + this._graphSelected = next; + if (this.activeTab === "Graph") this._ensureGraphSampling(); + } + + private _renderGraphTab() { + this._ensureGraphDefaults(); + const opts = this._availableListStats(); + const metric = this._graphMetric!; + const players = this._playersAll(); + const selectedIds = this._graphSelected; + const series = statsStore + .getSeries(metric) + .filter((s) => selectedIds.has(s.playerId)); + + const width = 760; + const height = 300; + const padding = { l: 40, r: 10, t: 10, b: 28 }; // extra bottom for x labels + const clipped = series.map((s) => ({ + name: s.name, + aliveUntil: s.aliveUntil, + pts: s.samples.filter((pt) => + s.aliveUntil ? pt.t <= s.aliveUntil : true, + ), + })); + const allPts = clipped.flatMap((s) => s.pts); + const times = allPts.map((d) => d.t); + const values = allPts.map((d) => d.v); + const currentTick = this.game?.ticks() ?? 0; + const minT = times.length + ? Math.min(...times) + : Math.max(0, currentTick - 600); // default 60s window (600 ticks) + const maxT = times.length ? Math.max(...times) : currentTick; + const minV = values.length ? Math.min(...values) : 0; + const maxV = values.length ? Math.max(...values) : 1; + const spanT = Math.max(1, maxT - minT); + const spanV = Math.max(1e-9, maxV - minV); + const xScale = (t: number) => + padding.l + ((t - minT) / spanT) * (width - padding.l - padding.r); + const yScale = (v: number) => + height - + padding.b - + ((v - minV) / spanV) * (height - padding.t - padding.b); + const palette = [ + "#60a5fa", + "#34d399", + "#f472b6", + "#f59e0b", + "#a78bfa", + "#f87171", + "#22d3ee", + "#84cc16", + ]; + const pathFor = (pts: { t: number; v: number }[]) => + pts.length === 0 + ? "" + : pts + .map((p, i) => `${i ? "L" : "M"}${xScale(p.t)},${yScale(p.v)}`) + .join(" "); + const anySeries = clipped.some((s) => s.pts.length > 0); + // axis ticks + const yTicks = 5; + const yTickVals: number[] = Array.from( + { length: yTicks + 1 }, + (_, i) => minV + (spanV * i) / yTicks, + ); + // choose up to 6 time ticks (including endpoints) + const xTicksTarget = 5; + const xTickVals: number[] = Array.from( + { length: xTicksTarget + 1 }, + (_, i) => minT + (spanT * i) / xTicksTarget, + ); + const formatValue = (v: number) => { + if (Math.abs(v) >= 1000000) return (v / 1000000).toFixed(1) + "M"; + if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1) + "K"; + if (spanV < 2) return v.toFixed(2); + return Math.round(v).toString(); + }; + const formatTime = (t: number) => { + // t is in ticks. 10 ticks = 1 second. + const spawnDuration = this.game?.config().numSpawnPhaseTurns() ?? 0; + const adjustedT = t - spawnDuration; + const sign = adjustedT < 0 ? "-" : ""; + const absT = Math.abs(adjustedT); + const seconds = Math.floor(absT / 10); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${sign}${m}:${String(s).padStart(2, "0")}`; + }; + if (!this._graphPaused) this._startGraphRenderLoop(); + return html`
+
+ + + +
+
+
+
+
+ ${Array.from({ length: 6 }).map( + () => html`
`, + )} +
+ ${selectedIds.size === 0 + ? html`
+ Select players on right +
` + : html``} + + + + + ${yTickVals.map( + (v) => + svg` + + ${formatValue(v)} + `, + )} + + + + ${xTickVals.map( + (t) => + svg` + + ${formatTime(t)} + `, + )} + + + ${clipped.map((s, i) => { + const d = pathFor(s.pts); + const color = palette[i % palette.length]; + return svg` + + ${s.pts.map( + (pt) => + svg``, + )} + `; + })} + ${!anySeries && selectedIds.size > 0 + ? svg`No data yet (wait for next 10s sample)` + : html``} + +
+
+
+
Players
+
+ ${players.map( + (p) => + html``, + )} +
+
+
+
+ ${clipped.map((s, i) => { + const color = palette[i % palette.length]; + return html` + + ${s.name}${s.aliveUntil ? " (dead)" : ""} + `; + })} +
+
`; + } + + render() { + return html` + + + ${this._renderTabs()} + ${this._renderContent()}${this.activeTab === "List" + ? html`${this._listRefreshTick}` + : this.activeTab === "Graph" + ? html`${this._graphRenderTick}` + : html`${this._tick}`} + + `; + } + + createRenderRoot() { + return this; + } + + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener("modal-close", () => this._stopAutoRefresh()); + } + + protected updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + if (changedProperties.has("game") && this.game) { + this._ensureGraphSampling(); + } + } + + private _refreshList() { + this._listRefreshTick++; + } + private _updateListStat(idx: number, value: string) { + const next = [...this._listSelectedStats] as [ + string, + string, + string, + string, + string, + ]; + next[idx] = value; + this._listSelectedStats = next; + this._refreshList(); + } + private _toggleSort(idx: number) { + if (this._listSortIndex !== idx) { + this._listSortIndex = idx; + this._listSortDir = "desc"; + } else { + this._listSortDir = this._listSortDir === "desc" ? "asc" : "desc"; + } + this._refreshList(); + } + + private _availableListStats(): string[] { + return AVAILABLE_STATS; + } + + private _computeStatValue( + label: string, + p: PlayerView, + ): { + sortValue: number; + sortText?: string; + displayPrimary: string; + displaySecondary?: string; + } { + return computeStatValue(this.game, label, p); + } +} + +declare global { + interface HTMLElementTagNameMap { + "statistics-modal": StatisticsModal; + } +} diff --git a/src/client/graphics/layers/ControlPanel2.ts b/src/client/graphics/layers/ControlPanel2.ts index 4e06e6937..85cd117c9 100644 --- a/src/client/graphics/layers/ControlPanel2.ts +++ b/src/client/graphics/layers/ControlPanel2.ts @@ -24,6 +24,7 @@ import { import { PlayerListChangedEvent } from "../../events/PlayerListChangedEvent"; import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { AttackRatioEvent } from "../../InputHandler"; +import "../../StatisticsModal"; // ensure statistics modal is registered import { SendBomberIntentEvent, SendSetAutoBombingEvent, @@ -965,6 +966,37 @@ export class ControlPanel2 extends LitElement implements Layer { } } + private _openStatistics() { + const modal = + (document.querySelector("statistics-modal") as any) || + this._ensureStatisticsModal(); + if (!modal) { + console.warn("StatisticsModal element not found or failed to create"); + return; + } + const openFn = modal.open; + if (typeof openFn === "function") { + // Pass current GameView so modal can populate player dropdown + try { + if (this.game) { + modal.game = this.game; // property defined on statistics-modal + } + } catch (_) { + /* non-fatal */ + } + openFn.call(modal); + } + } + + private _ensureStatisticsModal(): HTMLElement | null { + let el = document.querySelector("statistics-modal") as HTMLElement | null; + if (!el) { + el = document.createElement("statistics-modal"); + document.body.appendChild(el); + } + return el; + } + render() { if (!this.game) { return html``; @@ -1147,7 +1179,7 @@ export class ControlPanel2 extends LitElement implements Layer { @contextmenu=${(e: MouseEvent) => e.preventDefault()} >
@@ -1200,6 +1232,20 @@ export class ControlPanel2 extends LitElement implements Layer { ` : ""} +
+ +
diff --git a/src/client/index.html b/src/client/index.html index 19ffe7bb6..6b1e1453a 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -262,7 +262,7 @@ style="pointer-events: auto" >
@@ -272,6 +272,7 @@ pointer-events: auto; --ui-scale-base: 0.9; --ui-scale-origin: bottom right; + margin-left: -120px; " > diff --git a/src/client/stats/StatDefinitions.ts b/src/client/stats/StatDefinitions.ts new file mode 100644 index 000000000..c26514782 --- /dev/null +++ b/src/client/stats/StatDefinitions.ts @@ -0,0 +1,210 @@ +import { UnitType } from "../../core/game/Game"; +import { GameView, PlayerView } from "../../core/game/GameView"; +import { getTechNodes, type Category } from "../../core/tech/ResearchTree"; + +export const AVAILABLE_STATS = [ + "Gold", + "Industrial Production", + "Population", + "Workers", + "Troops", + "Productivity %", + "Productivity Growth %", + "Investment – Production %", + "Investment – Production Amount/s", + "Investment – Roads %", + "Investment – Roads Amount/s", + "Investment – Research %", + "Investment – Research Amount/s", + "Road Quality %", + "Road Completion %", + // Structures (match PlayerInfoOverlay ordering) + "City", + "Hospital", + "Academy", + "Research Lab", + "Factory", + "Port", + "Warship", + "Missile Silo", + "SAM Launcher", + "Air Field", + "Fighter Jet", + "Defense Post", + // Tech overview + "Researched Techs", + "Research Level", + // Tech by category (researched/total; sort by researched) + "Land Techs", + "Sea Techs", + "Air Techs", + "Nuclear Techs", + "Economy Techs", +]; + +export interface StatValue { + sortValue: number; + sortText?: string; + displayPrimary: string; + displaySecondary?: string; +} + +export function computeStatValue( + game: GameView | null, + label: string, + p: PlayerView, +): StatValue { + const gross = game?.config().grossGoldAdditionRate(p) ?? 0; + const perSecond = 10; + const inv = p.investmentRate?.() ?? (p as any).data?.investmentRate ?? 0; + const roadRate = + (p as any).roadInvestmentRate?.() ?? + p.roadInvestmentRate?.() ?? + (p as any).data?.roadInvestmentRate ?? + 0; + const researchRate = + (p as any).researchInvestmentRate?.() ?? + p.researchInvestmentRate?.() ?? + (p as any).data?.researchInvestmentRate ?? + 0; + const prodAmt = gross * inv * perSecond; + const roadAmt = gross * roadRate * perSecond; + const researchAmt = gross * researchRate * perSecond; + const ip = + (p as any).industrialProduction?.() ?? + (p as any).industrialProduction ?? + (p as any).data?.industrialProduction ?? + 0; + switch (label) { + case "Gold": + return { + sortValue: Number(p.gold?.() ?? 0), + displayPrimary: String(p.gold?.() ?? 0), + }; + case "Industrial Production": + return { sortValue: Number(ip ?? 0), displayPrimary: String(ip ?? 0) }; + case "Population": + return { + sortValue: p.population(), + displayPrimary: String(p.population()), + }; + case "Workers": + return { sortValue: p.workers(), displayPrimary: String(p.workers()) }; + case "Troops": + return { sortValue: p.troops(), displayPrimary: String(p.troops()) }; + case "Productivity %": { + const val = (p.productivity?.() ?? 0) * 100; + return { sortValue: val, displayPrimary: `${val.toFixed(1)}%` }; + } + case "Productivity Growth %": { + const val = (p.productivityGrowthPerMinute?.() ?? 0) * 100; + return { sortValue: val, displayPrimary: `${val.toFixed(1)}%` }; + } + case "Investment – Production %": { + const val = (inv ?? 0) * 100; + return { sortValue: val, displayPrimary: `${val.toFixed(0)}%` }; + } + case "Investment – Production Amount/s": + return { sortValue: prodAmt, displayPrimary: prodAmt.toFixed(2) }; + case "Investment – Roads %": { + const val = (roadRate ?? 0) * 100; + return { sortValue: val, displayPrimary: `${val.toFixed(0)}%` }; + } + case "Investment – Roads Amount/s": + return { sortValue: roadAmt, displayPrimary: roadAmt.toFixed(2) }; + case "Investment – Research %": { + const val = (researchRate ?? 0) * 100; + return { sortValue: val, displayPrimary: `${val.toFixed(0)}%` }; + } + case "Investment – Research Amount/s": + return { + sortValue: researchAmt, + displayPrimary: researchAmt.toFixed(2), + }; + case "Road Quality %": { + const val = (p.roadNetworkQuality?.() ?? + (p as any).data?.roadNetworkQuality ?? + 100) as number; + return { sortValue: val, displayPrimary: `${Math.round(val)}%` }; + } + case "Road Completion %": { + const val = (p.roadNetworkCompletion?.() ?? + (p as any).data?.roadNetworkCompletion ?? + 100) as number; + return { sortValue: val, displayPrimary: `${Math.round(val)}%` }; + } + // Structures (upgradeOwned use unitsOwned, others use units().length) + case "City": + case "Hospital": + case "Academy": + case "Research Lab": + case "Factory": + case "Port": { + const map: Record = { + City: UnitType.City, + Hospital: UnitType.Hospital, + Academy: UnitType.Academy, + "Research Lab": UnitType.ResearchLab, + Factory: UnitType.Factory, + Port: UnitType.Port, + }; + const t = map[label]; + const count = p.unitsOwned(t); + return { sortValue: count, displayPrimary: String(count) }; + } + case "Warship": + case "Missile Silo": + case "SAM Launcher": + case "Air Field": + case "Fighter Jet": + case "Defense Post": { + const map: Record = { + Warship: UnitType.Warship, + "Missile Silo": UnitType.MissileSilo, + "SAM Launcher": UnitType.SAMLauncher, + "Air Field": UnitType.Airfield, + "Fighter Jet": UnitType.FighterJet, + "Defense Post": UnitType.DefensePost, + }; + const t = map[label]; + const count = p.units(t).length; + return { sortValue: count, displayPrimary: String(count) }; + } + // Tech overview + case "Researched Techs": { + const n = (p as any).data?.researchTreeTechs?.length ?? 0; + return { sortValue: n, displayPrimary: String(n) }; + } + case "Research Level": { + const lvl = Number(p.researchTechLevel()) || 0; + return { sortValue: lvl, displayPrimary: String(lvl) }; + } + + // Tech by category (researched/total; sort by researched) + case "Land Techs": + case "Sea Techs": + case "Air Techs": + case "Nuclear Techs": + case "Economy Techs": { + const labelToCat: Record = { + "Land Techs": "Land", + "Sea Techs": "Sea", + "Air Techs": "Air", + "Nuclear Techs": "Nuclear", + "Economy Techs": "Economy", + }; + const cat = labelToCat[label]; + const nodes = getTechNodes(); + const total = nodes.filter((n) => n.category === cat).length; + let researched = 0; + for (const n of nodes) { + if (n.category === cat && p.hasResearchedTech(n.id)) researched++; + } + return { + sortValue: researched, + displayPrimary: `${researched}/${total}`, + }; + } + } + return { sortValue: 0, displayPrimary: "—" }; +} diff --git a/src/client/stats/StatsStore.ts b/src/client/stats/StatsStore.ts new file mode 100644 index 000000000..8ce1df1d3 --- /dev/null +++ b/src/client/stats/StatsStore.ts @@ -0,0 +1,112 @@ +import type { PlayerView } from "../../core/game/GameView"; + +export type Sample = { t: number; v: number }; +export type PlayerSeries = { + playerId: string; + name: string; + samples: Sample[]; + aliveUntil?: number; // time when death detected +}; + +class StatsStore { + // metric -> playerId -> series + private series: Map> = new Map(); + + private activeMetrics: Map< + string, + { + getPlayers: () => PlayerView[]; + sampler: (metric: string, p: PlayerView) => number; + isAlive: (p: PlayerView) => boolean; + lastSampledTick: number; + } + > = new Map(); + + ensureSeries(metric: string, players: PlayerView[]): void { + let m = this.series.get(metric); + if (!m) { + m = new Map(); + this.series.set(metric, m); + } + for (const p of players) { + if (!m.has(p.id())) { + m.set(p.id(), { + playerId: p.id(), + name: p.displayName?.() ?? p.name?.() ?? "Player", + samples: [], + }); + } else { + // keep name fresh (in case of rename) + const s = m.get(p.id())!; + s.name = p.displayName?.() ?? p.name?.() ?? s.name; + } + } + } + + start( + metric: string, + getPlayers: () => PlayerView[], + sampler: (metric: string, p: PlayerView) => number, + isAlive: (p: PlayerView) => boolean, + getTick: () => number, // kept for API compatibility but unused in favor of onTick + ): void { + if (this.activeMetrics.has(metric)) return; + + this.activeMetrics.set(metric, { + getPlayers, + sampler, + isAlive, + lastSampledTick: -1, + }); + + // Seed immediately + this.onTick(getTick()); + } + + stop(metric: string): void { + this.activeMetrics.delete(metric); + } + + onTick(now: number): void { + for (const [metric, config] of this.activeMetrics.entries()) { + // Only sample if we haven't yet, or if 100 ticks have passed + if (config.lastSampledTick !== -1 && now < config.lastSampledTick + 100) { + continue; + } + + const players = config.getPlayers(); + this.ensureSeries(metric, players); + const m = this.series.get(metric)!; + + let anySampled = false; + for (const p of players) { + const s = m.get(p.id()); + if (!s) continue; + if (!config.isAlive(p)) { + if (s.aliveUntil === undefined) s.aliveUntil = now; + continue; + } + const v = config.sampler(metric, p); + + if (s.samples.length > 0 && s.samples[s.samples.length - 1].t === now) { + s.samples[s.samples.length - 1].v = v; + } else { + s.samples.push({ t: now, v }); + } + anySampled = true; + } + + if (anySampled || config.lastSampledTick === -1) { + config.lastSampledTick = now; + } + } + } + getSeries(metric: string): PlayerSeries[] { + const m = this.series.get(metric); + if (!m) return []; + return [...m.values()]; + } +} + +export const statsStore = new StatsStore(); +export default statsStore; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 176257698..3aa59d325 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -125,8 +125,8 @@ export interface Config { proximityBonusPortsNb(totalPorts: number): number; proximityBonusAirfieldsNumber(totalAirfields: number): number; maxPopulation(player: Player | PlayerView): number; - // Multiplier used to compute a player's GDP as: gdpFactor * maxPopulation(player) - gdpFactor(): number; + // Multiplier used to compute a player's Industrial Production as: industrialProductionFactor * maxPopulation(player) + industrialProductionFactor(): number; cityPopulationIncrease(): number; boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number; shellLifetime(): number; @@ -153,7 +153,7 @@ export interface Config { tradeShipGold(dist: number): Gold; tradeShipSpawnRate(numberOfPorts: number): number; // Trade rework: gravity-based demand and port-supplied ships - tradeGravityK(): number; // Coefficient K in K * gdp_i * gdp_j / distance / world_gdp + tradeGravityK(): number; // Coefficient K in K * ip_i * ip_j / distance / world_industrial_production tradeDemandTickInterval(): number; // Ticks between gravity accumulation (default 10) tradeShipPerPortSupply(): number; // Number of trade ships each port supplies (default 1) tradeIncomeFixed(): Gold; // Fixed income per completed trade (default 10k) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index d1268852c..a26371f23 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1136,8 +1136,8 @@ export class DefaultConfig implements Config { } } - // Multiplier for computing GDP relative to max population - gdpFactor(): number { + // Multiplier for computing Industrial Production relative to max population (formerly GDP) + industrialProductionFactor(): number { return 1.0; } diff --git a/src/core/execution/TradeManagerExecution.ts b/src/core/execution/TradeManagerExecution.ts index a3d7b921c..970b61a10 100644 --- a/src/core/execution/TradeManagerExecution.ts +++ b/src/core/execution/TradeManagerExecution.ts @@ -133,11 +133,11 @@ export class TradeManagerExecution implements Execution { private accumulateDemand(): void { const K = this.mg.config().tradeGravityK(); - // World GDP = sum of all alive players' GDPs (bots and humans) - const worldGDP = this.mg + // World Industrial Production = sum of all alive players' industrialProduction values (bots and humans) + const worldIndustrialProduction = this.mg .players() .filter((p) => p.isAlive()) - .reduce((sum, p) => sum + p.gdp(), 0); + .reduce((sum, p) => sum + p.industrialProduction(), 0); const players = this.playersForTrade(); for (let i = 0; i < players.length; i++) { for (let j = 0; j < players.length; j++) { @@ -157,10 +157,14 @@ export class TradeManagerExecution implements Execution { const dist = this.capitalDistance(capA, capB); if (dist <= 0) continue; // New gravity model scaling: - // demand += K * gdp_i * gdp_j / distance / world_gdp - // Safeguard zero world GDP + // demand += K * ip_i * ip_j / distance / world_industrial_production + // Safeguard zero world industrial production const demandDelta = - worldGDP > 0 ? (K * a.gdp() * b.gdp()) / dist / worldGDP : 0; + worldIndustrialProduction > 0 + ? (K * a.industrialProduction() * b.industrialProduction()) / + dist / + worldIndustrialProduction + : 0; const k = this.key(a, b); // Initialize with a uniform random fractional remainder in [0,1) once per pair let prev = this.demand.get(k); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 57832d173..cbc769dd7 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -610,8 +610,8 @@ export interface Player { removeProductivity(amount: number): void; investmentRate(): number; // Returns the investment rate (0 to 1) setInvestmentRate(rate: number): void; - // Economic: Gross Domestic Product proxy - gdp(): number; // Computed as config.gdpFactor() * maxPopulation(this) + // Economic: Industrial Production proxy (formerly GDP) + industrialProduction(): number; // Computed as config.industrialProductionFactor() * maxPopulation(this) // Roads: investment ratio (0..1) of per-tick income allocated to roads roadInvestmentRate(): number; setRoadInvestmentRate(rate: number): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 42b701235..cc34e56de 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -176,8 +176,8 @@ export interface PlayerUpdate { capital?: MapPos; tilesOwned: number; gold: Gold; - // Economic: GDP proxy = config.gdpFactor() * maxPopulation(player) - gdp: number; + // Economic: Industrial Production proxy (formerly GDP) = config.industrialProductionFactor() * maxPopulation(player) + industrialProduction: number; population: number; totalPopulation: number; hospitalReturns: number; @@ -185,6 +185,9 @@ export interface PlayerUpdate { productivity: number; productivityGrowthPerMinute: number; investmentRate: number; + // Investment sliders (fractions 0..1) + roadInvestmentRate?: number; + researchInvestmentRate?: number; // Trade: current global demand queue length (for UI indicators) tradeDemandQueueLength?: number; // Road KPIs (percent values 0..100) diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 39d202b1f..4b9689365 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -366,8 +366,8 @@ export class PlayerView { gold(): Gold { return this.data.gold; } - gdp(): number { - return this.data.gdp; + industrialProduction(): number { + return (this.data as any).industrialProduction; } population(): number { return this.data.population; @@ -393,6 +393,12 @@ export class PlayerView { investmentRate(): number { return this.data.investmentRate; } + roadInvestmentRate(): number { + return (this.data as any).roadInvestmentRate ?? 0; + } + researchInvestmentRate(): number { + return (this.data as any).researchInvestmentRate ?? 0; + } // Road KPIs (optional on wire; default to 100% quality and 100% completion if absent) roadNetworkQuality(): number { return this.data.roadNetworkQuality ?? 100; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 46d3b3c74..408d3d25f 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -178,7 +178,7 @@ export class PlayerImpl implements Player { : undefined, tilesOwned: this.numTilesOwned(), gold: this._gold, - gdp: this.gdp(), + industrialProduction: this.industrialProduction(), population: this.population(), totalPopulation: this.totalPopulation(), hospitalReturns: this.hospitalReturns(), @@ -196,6 +196,8 @@ export class PlayerImpl implements Player { productivity: this.productivity(), productivityGrowthPerMinute: this.productivityGrowthPerMinute(), investmentRate: this.investmentRate(), + roadInvestmentRate: this.roadInvestmentRate(), + researchInvestmentRate: this.researchInvestmentRate(), allies: this.alliances().map((a) => a.other(this).smallID()), wars: Array.from(this._wars).map((pid) => this.mg.player(pid).smallID()), embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())), @@ -274,9 +276,9 @@ export class PlayerImpl implements Player { return this.playerInfo.playerType; } - // Economic: GDP proxy as parameter * max population - gdp(): number { - const factor = this.mg.config().gdpFactor(); + // Economic: Industrial Production proxy (formerly GDP) as parameter * max population + industrialProduction(): number { + const factor = this.mg.config().industrialProductionFactor(); const maxPop = this.mg.config().maxPopulation(this); const g = factor * maxPop; // Ensure finite, non-negative number