This commit is contained in:
Aotumuri
2025-09-04 15:23:28 +09:00
parent 1fa2685495
commit 0933175ce1
14 changed files with 1016 additions and 18 deletions
+71
View File
@@ -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"
}
}
}
+1
View File
@@ -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;
+13
View File
@@ -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);
}
};
+138
View File
@@ -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`
<o-modal
id="playerInfoModal"
title="${translateText("player_modal.title")}"
alwaysMaximized
>
<div class="flex flex-col items-center mt-2 mb-4">
${this.loadError
? html`
<div
class="w-full max-w-md mb-3 px-3 py-2 rounded border text-sm text-center"
style="
background: rgba(220,38,38,0.15);
border-color: rgba(248,113,113,0.6);
color: rgb(254,202,202);
"
>
${translateText(this.loadError)}
</div>
`
: null}
${this.warningMessage
? html`
<div
class="w-full max-w-md mb-3 px-3 py-2 rounded border text-sm text-center"
style="background: rgba(202,138,4,0.15); border-color: rgba(253,224,71,0.6); color: rgb(253,224,71);"
>
${translateText(this.warningMessage)}
</div>
`
: null}
<br />
<discord-user-header
.data=${this.userMeResponse?.user ?? null}
></discord-user-header>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
<hr class="w-2/3 border-gray-600 my-2" />
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => this.viewGame(id)}
></game-list>
</div>
</o-modal>
`;
}
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<void> {
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();
}
}
}
@@ -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`
<div class="wrap">
${this.avatarUrl
? html`
<div class="avatarFrame">
<img
class="avatar"
src="${this.avatarUrl}"
alt="${translateText("player_modal.avatar_alt")}"
/>
</div>
`
: null}
<span class="name">${this.discordDisplayName}</span>
</div>
`;
}
}
@@ -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` <div class="mt-4 w-full max-w-md">
<div class="text-sm text-gray-400 font-semibold mb-1">
<div class="section-title">
🎮 ${translateText("player_modal.recent_games")}
</div>
<div class="flex flex-col gap-2">
${this.games.map(
(game) => html`
<div class="card">
<div class="row">
<div>
<div class="title">
${translateText("player_modal.game_id")}: ${game.gameId}
</div>
<div class="subtle">
${translateText("player_modal.mode")}:
${game.mode === GameMode.FFA
? translateText("player_modal.mode_ffa")
: html`${translateText("player_modal.mode_team")}`}
</div>
</div>
<div class="flex gap-2">
<button
class="btn"
@click=${() => this.onViewGame?.(game.gameId)}
>
${translateText("player_modal.view")}
</button>
<button
class="btn secondary"
@click=${() => this.toggle(game.gameId)}
>
${translateText("player_modal.details")}
</button>
</div>
</div>
<div
class="details"
style="max-height:${this.expandedGameId === game.gameId
? "200px"
: "0"}; ${this.expandedGameId === game.gameId
? ""
: "padding-top:0; padding-bottom:0;"}"
>
<div>
<span class="title" style="font-size:0.75rem;"
>${translateText("player_modal.started")}:</span
>
${new Date(game.start).toLocaleString()}
</div>
<div>
<span class="title" style="font-size:0.75rem;"
>${translateText("player_modal.mode")}:</span
>
${game.mode === GameMode.FFA
? translateText("player_modal.mode_ffa")
: translateText("player_modal.mode_team")}
</div>
<div>
<span class="title" style="font-size:0.75rem;"
>${translateText("player_modal.map")}:</span
>
${game.map}
</div>
<div>
<span class="title" style="font-size:0.75rem;"
>${translateText("player_modal.difficulty")}:</span
>
${game.difficulty}
</div>
<div>
<span class="title" style="font-size:0.75rem;"
>${translateText("player_modal.type")}:</span
>
${game.type}
</div>
</div>
</div>
`,
)}
</div>
</div>
</div>`;
}
}
@@ -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`
<!-- Type selector -->
<div class="flex gap-2 mt-2 justify-center">
${types.map(
(t) => html`
<button
class="text-xs px-2 py-0.5 rounded border ${this.selectedType ===
t
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setGameType(t)}
>
${t === GameType.Public
? translateText("player_modal.public")
: t === GameType.Private
? translateText("player_modal.private")
: translateText("player_modal.singleplayer")}
</button>
`,
)}
</div>
<!-- Mode selector -->
${modes.length
? html`<div class="flex gap-2 mt-2 justify-center">
${modes.map(
(m) => html`
<button
class="text-xs px-2 py-0.5 rounded border ${this
.selectedMode === m
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setMode(m)}
title=${translateText("player_modal.mode")}
>
${this.labelForMode(m)}
</button>
`,
)}
</div>`
: html``}
<!-- Difficulty selector -->
${diffs.length
? html`<div class="flex gap-2 mt-2 justify-center">
${diffs.map(
(d) =>
html` <button
class="text-xs px-2 py-0.5 rounded border ${this
.selectedDifficulty === d
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setDifficulty(d)}
title=${translateText("player_modal.difficulty")}
>
${translateText(`difficulty.${d}`)}
</button>`,
)}
</div>`
: html``}
${leaf
? html`
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-grid
.titles=${[
translateText("player_modal.stats_wins"),
translateText("player_modal.stats_losses"),
translateText("player_modal.stats_wlr"),
translateText("player_modal.stats_games_played"),
]}
.values=${[
renderNumber(leaf.wins),
renderNumber(leaf.losses),
wlr.toFixed(2),
renderNumber(leaf.total),
]}
></player-stats-grid>
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-table
.stats=${this.getDisplayedStats()}
></player-stats-table>
`
: html``}
`;
}
}
@@ -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<string | number> = [];
render() {
return html`
<div class="grid mb-2">
${Array(4)
.fill(0)
.map(
(_, i) => html`
<div class="stat">
<div class="stat-value">${this.values[i] ?? ""}</div>
<div class="stat-title">${this.titles[i] ?? ""}</div>
</div>
`,
)}
</div>
`;
}
}
@@ -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`
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.building_stats")}
</div>
<table>
<thead>
<tr>
<th class="text-left">
${translateText("player_stats_table.building")}
</th>
<th>${translateText("player_stats_table.built")}</th>
<th>${translateText("player_stats_table.destroyed")}</th>
<th>${translateText("player_stats_table.captured")}</th>
<th>${translateText("player_stats_table.lost")}</th>
</tr>
</thead>
<tbody>
${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`
<tr>
<td>${translateText(`player_stats_table.unit.${key}`)}</td>
<td>${renderNumber(built)}</td>
<td>${renderNumber(destroyed)}</td>
<td>${renderNumber(captured)}</td>
<td>${renderNumber(lost)}</td>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.ship_arrivals")}
</div>
<table>
<thead>
<tr>
<th class="text-left">
${translateText("player_stats_table.ship_type")}
</th>
<th>${translateText("player_stats_table.sent")}</th>
<th>${translateText("player_stats_table.destroyed")}</th>
<th>${translateText("player_stats_table.arrived")}</th>
</tr>
</thead>
<tbody>
${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`
<tr>
<td>${translateText(`player_stats_table.unit.${key}`)}</td>
<td>${renderNumber(sent)}</td>
<td>${renderNumber(destroyed)}</td>
<td>${renderNumber(arrived)}</td>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.nuke_stats")}
</div>
<table>
<thead>
<tr>
<th class="text-left" style="width:40%">
${translateText("player_stats_table.weapon")}
</th>
<th class="text-center" style="width:20%">
${translateText("player_stats_table.built")}
</th>
<th class="text-center" style="width:20%">
${translateText("player_stats_table.destroyed")}
</th>
<th class="text-center" style="width:20%">
${translateText("player_stats_table.hits")}
</th>
</tr>
</thead>
<tbody>
${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`
<tr>
<td>${translateText(`player_stats_table.unit.${bomb}`)}</td>
<td class="text-center">${renderNumber(launched)}</td>
<td class="text-center">${renderNumber(landed)}</td>
<td class="text-center">${renderNumber(intercepted)}</td>
</tr>
`;
})}
</tbody>
</table>
</div>
<div class="table-container">
<div class="section-title">
${translateText("player_stats_table.player_metrics")}
</div>
<table>
<thead>
<tr>
<th>${translateText("player_stats_table.attack")}</th>
<th>${translateText("player_stats_table.sent")}</th>
<th>${translateText("player_stats_table.received")}</th>
<th>${translateText("player_stats_table.cancelled")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>${translateText("player_stats_table.count")}</td>
<td>${renderNumber(this.stats?.attacks?.[0] ?? 0n)}</td>
<td>${renderNumber(this.stats?.attacks?.[1] ?? 0n)}</td>
<td>${renderNumber(this.stats?.attacks?.[2] ?? 0n)}</td>
</tr>
</tbody>
</table>
<table style="margin-top: 0.75rem;">
<thead>
<tr>
<th>${translateText("player_stats_table.gold")}</th>
<th>${translateText("player_stats_table.workers")}</th>
<th>${translateText("player_stats_table.war")}</th>
<th>${translateText("player_stats_table.trade")}</th>
<th>${translateText("player_stats_table.steal")}</th>
</tr>
</thead>
<tbody>
<tr>
<td>${translateText("player_stats_table.count")}</td>
<td>${renderNumber(this.stats?.gold?.[0] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[1] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[2] ?? 0n)}</td>
<td>${renderNumber(this.stats?.gold?.[3] ?? 0n)}</td>
</tr>
</tbody>
</table>
</div>
`;
}
}
+8
View File
@@ -207,6 +207,13 @@
></button>
</territory-patterns-modal>
<username-input class="relative w-full"></username-input>
<button
class="w-10 h-10 min-w-[2.5rem] min-h-[2.5rem] p-0 bg-[#007bff] hover:bg-[#0056b3] text-white rounded-lg border-none cursor-pointer transition-colors duration-300 flex items-center justify-center"
title="player-info"
id="player-info-button"
>
⚙️
</button>
<news-button
class="w-[20%] md:w-[15%] component-hideable"
></news-button>
@@ -404,6 +411,7 @@
<spawn-ad></spawn-ad>
<flag-input-modal></flag-input-modal>
<fps-display></fps-display>
<player-info-modal></player-info-modal>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+41
View File
@@ -1,6 +1,8 @@
import { decodeJwt } from "jose";
import { z } from "zod";
import {
PlayerIdResponse,
PlayerIdResponseSchema,
RefreshResponseSchema,
TokenPayload,
TokenPayloadSchema,
@@ -268,3 +270,42 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
return false;
}
}
export async function fetchPlayerById(
playerId: string,
): Promise<PlayerIdResponse | false> {
try {
const base = getApiBase();
const token = getToken();
if (!token) return false;
const url = `${base}/player/${encodeURIComponent(playerId)}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (res.status !== 200) {
console.warn(
"fetchPlayerById: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = PlayerIdResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchPlayerById: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchPlayerById: request failed", err);
return false;
}
}
+39 -1
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMapType, GameMode, GameType } from "./game/Game";
export const RefreshResponseSchema = z.object({
token: z.string(),
@@ -37,8 +39,8 @@ export const DiscordUserSchema = z.object({
username: z.string(),
global_name: z.string().nullable(),
discriminator: z.string(),
locale: z.string().optional(),
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
export const UserMeResponseSchema = z.object({
user: z.object({
@@ -52,3 +54,39 @@ export const UserMeResponseSchema = z.object({
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
export const PlayerStatsLeafSchema = z.object({
wins: BigIntStringSchema,
losses: BigIntStringSchema,
total: BigIntStringSchema,
stats: PlayerStatsSchema,
});
export type PlayerStatsLeaf = z.infer<typeof PlayerStatsLeafSchema>;
export const PlayerStatsTreeSchema = z.partialRecord(
z.enum(GameType),
z.partialRecord(
z.enum(GameMode),
z.partialRecord(z.enum(Difficulty), PlayerStatsLeafSchema),
),
);
export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
export const PlayerGameSchema = z.object({
gameId: z.string(),
start: z.iso.datetime(),
mode: z.enum(GameMode),
type: z.enum(GameType),
map: z.enum(GameMapType),
difficulty: z.enum(Difficulty),
clientId: z.string().optional(),
});
export type PlayerGame = z.infer<typeof PlayerGameSchema>;
export const PlayerIdResponseSchema = z.object({
createdAt: z.iso.datetime(),
user: DiscordUserSchema.optional(),
games: PlayerGameSchema.array(),
stats: PlayerStatsTreeSchema,
});
export type PlayerIdResponse = z.infer<typeof PlayerIdResponseSchema>;
+15 -17
View File
@@ -1,12 +1,8 @@
import { z } from "zod";
import { UnitType } from "./game/Game";
export const BombUnitSchema = z.union([
z.literal("abomb"),
z.literal("hbomb"),
z.literal("mirv"),
z.literal("mirvw"),
]);
export const bombUnits = ["abomb", "hbomb", "mirv", "mirvw"] as const;
export const BombUnitSchema = z.enum(bombUnits);
export type BombUnit = z.infer<typeof BombUnitSchema>;
export type NukeType =
| UnitType.AtomBomb
@@ -21,7 +17,8 @@ export const unitTypeToBombUnit = {
[UnitType.MIRVWarhead]: "mirvw",
} as const satisfies Record<NukeType, BombUnit>;
export const BoatUnitSchema = z.union([z.literal("trade"), z.literal("trans")]);
export const boatUnits = ["trade", "trans"] as const;
export const BoatUnitSchema = z.enum(boatUnits);
export type BoatUnit = z.infer<typeof BoatUnitSchema>;
export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip;
@@ -30,15 +27,16 @@ export type BoatUnitType = UnitType.TradeShip | UnitType.TransportShip;
// [UnitType.TransportShip]: "trans",
// } as const satisfies Record<BoatUnitType, BoatUnit>;
export const OtherUnitSchema = z.union([
z.literal("city"),
z.literal("defp"),
z.literal("port"),
z.literal("wshp"),
z.literal("silo"),
z.literal("saml"),
z.literal("fact"),
]);
export const otherUnits = [
"city",
"defp",
"port",
"wshp",
"silo",
"saml",
"fact",
] as const;
export const OtherUnitSchema = z.enum(otherUnits);
export type OtherUnit = z.infer<typeof OtherUnitSchema>;
export type OtherUnitType =
| UnitType.City
@@ -88,7 +86,7 @@ export const OTHER_INDEX_CAPTURE = 2; // Structures captured
export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others
export const OTHER_INDEX_UPGRADE = 4; // Structures upgraded
const BigIntStringSchema = z.preprocess((val) => {
export const BigIntStringSchema = z.preprocess((val) => {
if (typeof val === "string" && /^-?\d+$/.test(val)) return BigInt(val);
if (typeof val === "bigint") return val;
return val;
+18
View File
@@ -37,6 +37,14 @@ export enum Difficulty {
Hard = "Hard",
Impossible = "Impossible",
}
export function isDifficulty(difficulty: unknown): difficulty is Difficulty {
return (
difficulty === Difficulty.Easy ||
difficulty === Difficulty.Medium ||
difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible
);
}
export type Team = string;
@@ -130,11 +138,21 @@ export enum GameType {
Public = "Public",
Private = "Private",
}
export function isGameType(type: unknown): type is GameType {
return (
type === GameType.Singleplayer ||
type === GameType.Public ||
type === GameType.Private
);
}
export enum GameMode {
FFA = "Free For All",
Team = "Team",
}
export function isGameMode(mode: unknown): mode is GameMode {
return mode === GameMode.FFA || mode === GameMode.Team;
}
export interface UnitInfo {
cost: (player: Player) => Gold;