mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 23:11:55 +00:00
test
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user