From 0933175ce1706b8a6df8d631e756ed07d6b25dbb Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Thu, 4 Sep 2025 15:23:28 +0900 Subject: [PATCH] test --- resources/lang/en.json | 71 +++++++ src/client/LangSelector.ts | 1 + src/client/Main.ts | 13 ++ src/client/PlayerInfoModal.ts | 138 ++++++++++++ .../baseComponents/stats/DiscordUserHeader.ts | 80 +++++++ .../baseComponents/stats/GameList.ts | 150 ++++++++++++++ .../baseComponents/stats/PlaterStatsTree.ts | 195 +++++++++++++++++ .../baseComponents/stats/PlayerStatsGrid.ts | 51 +++++ .../baseComponents/stats/PlayerStatsTable.ts | 196 ++++++++++++++++++ src/client/index.html | 8 + src/client/jwt.ts | 41 ++++ src/core/ApiSchemas.ts | 40 +++- src/core/StatsSchemas.ts | 32 ++- src/core/game/Game.ts | 18 ++ 14 files changed, 1016 insertions(+), 18 deletions(-) create mode 100644 src/client/PlayerInfoModal.ts create mode 100644 src/client/components/baseComponents/stats/DiscordUserHeader.ts create mode 100644 src/client/components/baseComponents/stats/GameList.ts create mode 100644 src/client/components/baseComponents/stats/PlaterStatsTree.ts create mode 100644 src/client/components/baseComponents/stats/PlayerStatsGrid.ts create mode 100644 src/client/components/baseComponents/stats/PlayerStatsTable.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 395cee574..ccc6e4309 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -639,5 +639,76 @@ "radial_menu": { "delete_unit_title": "Delete Unit", "delete_unit_description": "Click to delete the nearest unit" + }, + "player_modal": { + "title": "Player Info", + "public": "Public", + "private": "Private", + "singleplayer": "Single Player", + "stats_wins": "Wins", + "stats_losses": "Losses", + "stats_wlr": "Win:Loss Ratio", + "stats_games_played": "Games Played", + "stats_play_time": "Play Time", + "stats_last_active": "Last Active", + "recent_games": "Recent Games", + "game_id": "Game ID", + "mode": "Mode", + "mode_ffa": "Free-for-All", + "mode_team": "Team", + "player_team_color": "Player Team Color", + "view": "View", + "details": "Details", + "started": "Started", + "map": "Map", + "difficulty": "Difficulty", + "type": "Type", + "not_applicable": "N/A", + "flag_alt": "Flag", + "avatar_alt": "Avatar", + "no_data": "No data available for this selection", + "error": { + "load": "Failed to load player data.", + "validate": "Failed to validate player data from server." + } + }, + "player_stats_table": { + "building_stats": "Building Statistics", + "ship_arrivals": "Ship Arrivals", + "nuke_stats": "Nuke Statistics", + "player_metrics": "Player Metrics", + "building": "Building", + "ship_type": "Ship Type", + "weapon": "Weapon", + "built": "Built", + "destroyed": "Destroyed", + "captured": "Captured", + "lost": "Lost", + "hits": "Hits", + "sent": "Sent", + "arrived": "Arrived", + "attack": "Attack", + "received": "Received", + "cancelled": "Cancelled", + "count": "Count", + "gold": "Gold", + "workers": "Workers", + "war": "War", + "trade": "Trade", + "steal": "Steal", + "unit": { + "city": "City", + "port": "Port", + "defp": "Defense Post", + "saml": "SAM Launcher", + "silo": "Missile Silo", + "wshp": "Warship", + "fact": "Factory", + "trade": "Trade Ship", + "trans": "Transport Ship", + "abomb": "Atom Bomb", + "hbomb": "Hydrogen Bomb", + "mirv": "MIRV" + } } } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 61a51e3c5..a51d88a29 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -221,6 +221,7 @@ export class LangSelector extends LitElement { "o-modal", "o-button", "territory-patterns-modal", + "player-stats-table", ]; document.title = this.translateText("main.title") ?? document.title; diff --git a/src/client/Main.ts b/src/client/Main.ts index 046becf7c..f026167f6 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -22,6 +22,7 @@ import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; import { NewsModal } from "./NewsModal"; +import { PlayerInfoModal } from "./PlayerInfoModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; import { SinglePlayerModal } from "./SinglePlayerModal"; @@ -216,6 +217,16 @@ class Client { this.patternsModal.open(); }); + const piModal = document.querySelector( + "player-info-modal", + ) as PlayerInfoModal; + piModal instanceof PlayerInfoModal; + document + .getElementById("player-info-button") + ?.addEventListener("click", () => { + piModal.open(); + }); + this.tokenLoginModal = document.querySelector( "token-login", ) as TokenLoginModal; @@ -308,6 +319,7 @@ class Client { } else if (userMeResponse === false) { // Not logged in this.patternsModal.onUserMe(null); + piModal.onUserMe(null); } else { // Authorized console.log( @@ -315,6 +327,7 @@ class Client { "Sharing this ID will allow others to view your game history and stats.", ); this.patternsModal.onUserMe(userMeResponse); + piModal.onUserMe(userMeResponse); } }; diff --git a/src/client/PlayerInfoModal.ts b/src/client/PlayerInfoModal.ts new file mode 100644 index 000000000..c1e446179 --- /dev/null +++ b/src/client/PlayerInfoModal.ts @@ -0,0 +1,138 @@ +import { LitElement, html } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { + PlayerGame, + PlayerStatsTree, + UserMeResponse, +} from "../core/ApiSchemas"; +import "./components/baseComponents/stats/DiscordUserHeader"; +import "./components/baseComponents/stats/GameList"; +import "./components/baseComponents/stats/PlayerStatsTable"; +import "./components/baseComponents/stats/PlayerStatsTree"; +import { fetchPlayerById } from "./jwt"; +import { translateText } from "./Utils"; + +@customElement("player-info-modal") +export class PlayerInfoModal extends LitElement { + @query("o-modal") private readonly modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + @state() private userMeResponse: UserMeResponse | null = null; + @state() private loadError: string | null = null; + @state() private warningMessage: string | null = null; + + private statsTree: PlayerStatsTree | null = null; + private recentGames: PlayerGame[] = []; + + private viewGame(gameId: string): void { + this.close(); + const path = location.pathname; + const { search } = location; + const hash = `#join=${encodeURIComponent(gameId)}`; + const newUrl = `${path}${search}${hash}`; + + history.pushState({ join: gameId }, "", newUrl); + window.dispatchEvent(new HashChangeEvent("hashchange")); + } + + createRenderRoot() { + return this; + } + + render() { + return html` + +
+ ${this.loadError + ? html` +
+ ${translateText(this.loadError)} +
+ ` + : null} + ${this.warningMessage + ? html` +
+ ${translateText(this.warningMessage)} +
+ ` + : null} +
+ + +
+ this.viewGame(id)} + > +
+
+ `; + } + + public open() { + this.loadError = null; + this.requestUpdate(); + this.modalEl?.open(); + } + + public close() { + this.modalEl?.close(); + } + + onUserMe(userMeResponse: UserMeResponse | null) { + this.userMeResponse = userMeResponse; + const playerId = userMeResponse?.player?.publicId; + if (playerId) { + this.loadFromApi(playerId); + } else { + this.statsTree = null; + this.recentGames = []; + this.warningMessage = null; + this.loadError = null; + } + this.requestUpdate(); + } + + private async loadFromApi(playerId: string): Promise { + try { + this.loadError = null; + + const data = await fetchPlayerById(playerId); + if (!data) { + this.loadError = "player_modal.error.load"; + this.requestUpdate(); + return; + } + + this.recentGames = data.games; + this.statsTree = data.stats; + + this.requestUpdate(); + } catch (err) { + console.warn("Failed to load player data:", err); + this.loadError = "player_modal.error.load"; + this.requestUpdate(); + } + } +} diff --git a/src/client/components/baseComponents/stats/DiscordUserHeader.ts b/src/client/components/baseComponents/stats/DiscordUserHeader.ts new file mode 100644 index 000000000..01919fe5b --- /dev/null +++ b/src/client/components/baseComponents/stats/DiscordUserHeader.ts @@ -0,0 +1,80 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { DiscordUser } from "../../../../core/ApiSchemas"; +import { translateText } from "../../../Utils"; + +@customElement("discord-user-header") +export class DiscordUserHeader extends LitElement { + static styles = css` + .wrap { + display: flex; + align-items: center; + gap: 0.5rem; + } + .avatarFrame { + padding: 3px; + border-radius: 9999px; + background: #6b7280; /* bg-gray-500 */ + } + .avatar { + width: 48px; + height: 48px; + border-radius: 9999px; + display: block; + } + .name { + font-weight: 600; + color: white; + } + `; + + @state() private _data: DiscordUser | null = null; + + @property({ attribute: false }) + get data(): DiscordUser | null { + return this._data; + } + set data(v: DiscordUser | null) { + this._data = v; + this.requestUpdate(); + } + + private get avatarUrl(): string | null { + const u = this._data; + if (!u) return null; + if (u.avatar) { + const ext = u.avatar.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.${ext}`; + } + if (u.discriminator !== undefined) { + const idx = Number(u.discriminator) % 5; + return `https://cdn.discordapp.com/embed/avatars/${idx}.png`; + } + return null; + } + + private get discordDisplayName(): string { + const u = this._data; + if (!u) return ""; + return u.username ?? ""; + } + + render() { + return html` +
+ ${this.avatarUrl + ? html` +
+ ${translateText( +
+ ` + : null} + ${this.discordDisplayName} +
+ `; + } +} diff --git a/src/client/components/baseComponents/stats/GameList.ts b/src/client/components/baseComponents/stats/GameList.ts new file mode 100644 index 000000000..d39f3ffeb --- /dev/null +++ b/src/client/components/baseComponents/stats/GameList.ts @@ -0,0 +1,150 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { PlayerGame } from "../../../../core/ApiSchemas"; +import { GameMode } from "../../../../core/game/Game"; +import { translateText } from "../../../Utils"; + +@customElement("game-list") +export class GameList extends LitElement { + static styles = css` + .section-title { + color: #888; + font-size: 1rem; + font-weight: bold; + margin-bottom: 0.5rem; + } + .card { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.5rem; + overflow: hidden; + transition: all 0.3s ease; + } + .row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + } + .title { + font-size: 0.875rem; + font-weight: 600; + color: white; + } + .subtle { + font-size: 0.75rem; + color: #9ca3af; + } + .btn { + font-size: 0.875rem; + color: #d1d5db; + background: #374151; + padding: 0.25rem 0.75rem; + border-radius: 0.25rem; + } + .btn.secondary { + background: #4b5563; + } + .details { + padding: 0 1rem 0.5rem 1rem; + font-size: 0.75rem; + color: #d1d5db; + transition: all 0.3s ease; + } + `; + + @property({ type: Array }) games: PlayerGame[] = []; + @property({ attribute: false }) onViewGame?: (id: string) => void; + + @state() private expandedGameId: string | null = null; + + private toggle(gameId: string) { + this.expandedGameId = this.expandedGameId === gameId ? null : gameId; + } + + render() { + return html`
+
+
+ 🎮 ${translateText("player_modal.recent_games")} +
+
+ ${this.games.map( + (game) => html` +
+
+
+
+ ${translateText("player_modal.game_id")}: ${game.gameId} +
+
+ ${translateText("player_modal.mode")}: + ${game.mode === GameMode.FFA + ? translateText("player_modal.mode_ffa") + : html`${translateText("player_modal.mode_team")}`} +
+
+
+ + +
+
+
+
+ ${translateText("player_modal.started")}: + ${new Date(game.start).toLocaleString()} +
+
+ ${translateText("player_modal.mode")}: + ${game.mode === GameMode.FFA + ? translateText("player_modal.mode_ffa") + : translateText("player_modal.mode_team")} +
+
+ ${translateText("player_modal.map")}: + ${game.map} +
+
+ ${translateText("player_modal.difficulty")}: + ${game.difficulty} +
+
+ ${translateText("player_modal.type")}: + ${game.type} +
+
+
+ `, + )} +
+
+
`; + } +} diff --git a/src/client/components/baseComponents/stats/PlaterStatsTree.ts b/src/client/components/baseComponents/stats/PlaterStatsTree.ts new file mode 100644 index 000000000..6ec834c88 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlaterStatsTree.ts @@ -0,0 +1,195 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; +import { Difficulty, GameMode, GameType } from "../../../../core/game/Game"; +import { PlayerStats } from "../../../../core/StatsSchemas"; +import { renderNumber, translateText } from "../../../Utils"; +import "./PlayerStatsGrid"; +import "./PlayerStatsTable"; + +@customElement("player-stats-tree-view") +export class PlayerStatsTreeView extends LitElement { + @property({ type: Object }) statsTree?: PlayerStatsTree; + @state() selectedType: GameType = GameType.Public; + @state() selectedMode: GameMode = GameMode.FFA; + @state() selectedDifficulty: Difficulty = Difficulty.Medium; + + private get availableTypes(): GameType[] { + if (!this.statsTree) return []; + return Object.keys(this.statsTree) as GameType[]; + } + + private get availableModes(): GameMode[] { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return []; + return Object.keys(typeNode) as GameMode[]; + } + + private get availableDifficulties(): Difficulty[] { + const typeNode = this.statsTree?.[this.selectedType]; + const modeNode = typeNode?.[this.selectedMode]; + if (!modeNode) return []; + return Object.keys(modeNode) as Difficulty[]; + } + + private labelForMode(m: GameMode) { + return m === GameMode.FFA + ? translateText("player_modal.mode_ffa") + : translateText("player_modal.mode_team"); + } + + createRenderRoot() { + return this; + } + + private getSelectedLeaf(): PlayerStatsLeaf | null { + const typeNode = this.statsTree?.[this.selectedType]; + if (!typeNode) return null; + const modeNode = typeNode[this.selectedMode]; + if (!modeNode) return null; + const diffNode = modeNode[this.selectedDifficulty]; + if (!diffNode) return null; + return diffNode; + } + + private getDisplayedStats(): PlayerStats | null { + const leaf = this.getSelectedLeaf(); + if (!leaf || !leaf.stats) return null; + return leaf.stats; + } + + private setGameType(t: GameType) { + if (this.selectedType === t) return; + this.selectedType = t; + const modes = this.availableModes; + if (!modes.includes(this.selectedMode)) { + this.selectedMode = modes[0] ?? this.selectedMode; + } + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setMode(m: GameMode) { + if (this.selectedMode === m) return; + this.selectedMode = m; + const diffs = this.availableDifficulties; + if (!diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty; + } + this.requestUpdate(); + } + + private setDifficulty(d: Difficulty) { + if (this.selectedDifficulty === d) return; + this.selectedDifficulty = d; + this.requestUpdate(); + } + + render() { + const types = this.availableTypes; + if (types.length && !types.includes(this.selectedType)) { + this.selectedType = types[0]; + } + const modes = this.availableModes; + if (modes.length && !modes.includes(this.selectedMode)) { + this.selectedMode = modes[0]; + } + const diffs = this.availableDifficulties; + if (diffs.length && !diffs.includes(this.selectedDifficulty)) { + this.selectedDifficulty = diffs[0]; + } + + const leaf = this.getSelectedLeaf(); + const wlr = leaf + ? leaf.losses === 0n + ? Number(leaf.wins) + : Number(leaf.wins) / Number(leaf.losses) + : 0; + + return html` + +
+ ${types.map( + (t) => html` + + `, + )} +
+ + ${modes.length + ? html`
+ ${modes.map( + (m) => html` + + `, + )} +
` + : html``} + + ${diffs.length + ? html`
+ ${diffs.map( + (d) => + html` `, + )} +
` + : html``} + ${leaf + ? html` +
+ +
+ + ` + : html``} + `; + } +} diff --git a/src/client/components/baseComponents/stats/PlayerStatsGrid.ts b/src/client/components/baseComponents/stats/PlayerStatsGrid.ts new file mode 100644 index 000000000..499e4a2fe --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerStatsGrid.ts @@ -0,0 +1,51 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("player-stats-grid") +export class PlayerStatsGrid extends LitElement { + static styles = css` + .grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + @media (min-width: 640px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } + } + .stat { + text-align: center; + color: white; + font-size: 1rem; + } + .stat-title { + color: #bbb; + font-size: 0.9rem; + } + .stat-value { + font-size: 1.25rem; + font-weight: bold; + } + `; + + @property({ type: Array }) titles: string[] = []; + @property({ type: Array }) values: Array = []; + + render() { + return html` +
+ ${Array(4) + .fill(0) + .map( + (_, i) => html` +
+
${this.values[i] ?? ""}
+
${this.titles[i] ?? ""}
+
+ `, + )} +
+ `; + } +} diff --git a/src/client/components/baseComponents/stats/PlayerStatsTable.ts b/src/client/components/baseComponents/stats/PlayerStatsTable.ts new file mode 100644 index 000000000..156139538 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerStatsTable.ts @@ -0,0 +1,196 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + PlayerStats, + boatUnits, + bombUnits, + otherUnits, +} from "../../../../core/StatsSchemas"; +import { renderNumber, translateText } from "../../../Utils"; + +@customElement("player-stats-table") +export class PlayerStatsTable extends LitElement { + static styles = css` + .table-container { + margin-top: 1rem; + width: 100%; + max-width: 28rem; + } + table { + width: 100%; + font-size: 0.95rem; + color: #ccc; + border-collapse: collapse; + } + th, + td { + padding: 0.25rem 0.5rem; + text-align: center; + } + th { + color: #bbb; + font-weight: 600; + } + .section-title { + color: #888; + font-size: 1rem; + font-weight: bold; + margin-bottom: 0.5rem; + } + `; + + @property({ type: Object }) stats: PlayerStats; + + render() { + return html` +
+
+ ${translateText("player_stats_table.building_stats")} +
+ + + + + + + + + + + + ${otherUnits.map((key) => { + const built = this.stats?.units?.[key]?.[0] ?? 0n; + const destroyed = this.stats?.units?.[key]?.[1] ?? 0n; + const captured = this.stats?.units?.[key]?.[2] ?? 0n; + const lost = this.stats?.units?.[key]?.[3] ?? 0n; + return html` + + + + + + + + `; + })} + +
+ ${translateText("player_stats_table.building")} + ${translateText("player_stats_table.built")}${translateText("player_stats_table.destroyed")}${translateText("player_stats_table.captured")}${translateText("player_stats_table.lost")}
${translateText(`player_stats_table.unit.${key}`)}${renderNumber(built)}${renderNumber(destroyed)}${renderNumber(captured)}${renderNumber(lost)}
+
+
+
+ ${translateText("player_stats_table.ship_arrivals")} +
+ + + + + + + + + + + ${boatUnits.map((key) => { + const sent = this.stats?.boats?.[key]?.[0] ?? 0n; + const arrived = this.stats?.boats?.[key]?.[1] ?? 0n; + const destroyed = this.stats?.boats?.[key]?.[3] ?? 0n; + return html` + + + + + + + `; + })} + +
+ ${translateText("player_stats_table.ship_type")} + ${translateText("player_stats_table.sent")}${translateText("player_stats_table.destroyed")}${translateText("player_stats_table.arrived")}
${translateText(`player_stats_table.unit.${key}`)}${renderNumber(sent)}${renderNumber(destroyed)}${renderNumber(arrived)}
+
+
+
+ ${translateText("player_stats_table.nuke_stats")} +
+ + + + + + + + + + + ${bombUnits.map((bomb) => { + const launched = this.stats?.bombs?.[bomb]?.[0] ?? 0n; + const landed = this.stats?.bombs?.[bomb]?.[1] ?? 0n; + const intercepted = this.stats?.bombs?.[bomb]?.[2] ?? 0n; + return html` + + + + + + + `; + })} + +
+ ${translateText("player_stats_table.weapon")} + + ${translateText("player_stats_table.built")} + + ${translateText("player_stats_table.destroyed")} + + ${translateText("player_stats_table.hits")} +
${translateText(`player_stats_table.unit.${bomb}`)}${renderNumber(launched)}${renderNumber(landed)}${renderNumber(intercepted)}
+
+
+
+ ${translateText("player_stats_table.player_metrics")} +
+ + + + + + + + + + + + + + + + + +
${translateText("player_stats_table.attack")}${translateText("player_stats_table.sent")}${translateText("player_stats_table.received")}${translateText("player_stats_table.cancelled")}
${translateText("player_stats_table.count")}${renderNumber(this.stats?.attacks?.[0] ?? 0n)}${renderNumber(this.stats?.attacks?.[1] ?? 0n)}${renderNumber(this.stats?.attacks?.[2] ?? 0n)}
+ + + + + + + + + + + + + + + + + + + +
${translateText("player_stats_table.gold")}${translateText("player_stats_table.workers")}${translateText("player_stats_table.war")}${translateText("player_stats_table.trade")}${translateText("player_stats_table.steal")}
${translateText("player_stats_table.count")}${renderNumber(this.stats?.gold?.[0] ?? 0n)}${renderNumber(this.stats?.gold?.[1] ?? 0n)}${renderNumber(this.stats?.gold?.[2] ?? 0n)}${renderNumber(this.stats?.gold?.[3] ?? 0n)}
+
+ `; + } +} diff --git a/src/client/index.html b/src/client/index.html index 5cc19cd5c..087640d0b 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -207,6 +207,13 @@ > + @@ -404,6 +411,7 @@ +