Files
OpenFrontIO/src/client/graphics/layers/PlayerPanel.ts
T
scamiv 05e2bc9f0a Improve cacheability with content-hashed public assets and a cacheable app shell (#3494)
## Description:

This reworks asset delivery and cacheability across the app and moves
non-bundled public resources onto immutable, content-hashed URLs.

Vite bundle outputs continue to live under `/assets/**` and remain
content-hashed by Vite. Public resources that were previously fetched
from stable paths in `resources/` now go through a custom hashed
namespace under `/_assets/**`, backed by a generated asset manifest that
is available to the server, browser, and worker runtime.

In parallel, the root app shell is now cacheable shared HTML instead of
request-time `no-store` HTML. Dynamic and live routes remain explicitly
uncached.

## Why
- Improve browser and Cloudflare cacheability for static assets.
- Remove query-string and release-version cache busting for
runtime-fetched assets.
- Allow unchanged public assets to keep the same URL across releases.
- Reduce avoidable work on `/` by serving a shared app shell instead of
rendering HTML on every request.
- Make cache behavior explicit instead of relying on mixed framework
defaults and file-extension heuristics.

## What Changed

### 1. Content-hashed public asset pipeline
- Added a build-time public asset manifest and hashing pipeline for
non-Vite resources.
- Production now emits hashed public assets under `/_assets/**`.
- Added runtime manifest loading for Node so server-rendered paths
resolve against built hashed files instead of rebuilding from source at
runtime.
- Emitted the runtime asset manifest as an ESM module for server
consumption.

Result:
- `/assets/**` = Vite-managed hashed bundle outputs
- `/_assets/**` = custom content-hashed public resources

### 2. Runtime asset URL migration
- Added a shared `assetUrl(...)` resolution path.
- Migrated runtime references away from query-string versioning and
stable source paths.
- Updated browser, worker, and server-side rendering paths to resolve
through the asset manifest.
- Moved map manifests, map binaries, thumbnails, sprites, sounds, fonts,
flags, icons, screenshots, and other runtime-fetched resources onto
hashed URLs.

### 3. Map and preview fixes
- Fixed directory and per-file map asset resolution so map manifest and
binary fetches resolve to the correct hashed URLs.
- Updated preview metadata and map thumbnail paths to use the hashed
asset namespace.
- Fixed runtime manifest loading in prod after deployment.

### 4. Explicit cache policies
- Added explicit immutable cache headers for:
  - `/assets/**`
  - `/_assets/**`
  - worker-prefixed equivalents under `/wN/...`
- Added explicit `no-store` headers for live and dynamic APIs.
- Removed the old `/api/env` bootstrap request and baked `gameEnv` into
the HTML bootstrap instead.

### 5. Cacheable root app shell
- Refactored the root HTML path to serve a shared app shell with:
- `Cache-Control: public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`
- `/` and the SPA fallback now serve shared cacheable HTML instead of
request-time `no-store` rendering.
- `/game/:id` remains dynamic and `no-store`, but now reuses the shared
shell before injecting preview tags.

### 6. Matchmaking instance handling
- Because the app shell is now cacheable, `INSTANCE_ID` was removed from
shared HTML.
- Added `/api/instance` as a temporary `no-store` runtime lookup used
only by matchmaking.
- This preserves correctness with the current random-per-boot
`INSTANCE_ID` model while keeping `/` cacheable, but it is not the
intended long-term design.

## Behavior Changes

### Asset URL contract
Production URLs for non-Vite public resources now change from stable
paths such as:
- `/maps/...`
- `/images/...`
- `/manifest.json`

to content-hashed paths under:
- `/_assets/...`

Examples:
- `/_assets/maps/<map>/manifest.<hash>.json`
- `/_assets/images/Favicon.<hash>.svg`

### Bootstrap/config
- `/api/env` is removed.
- `gameEnv` is now bootstrapped from HTML.

### HTML caching
- `/` and the SPA fallback are now cacheable shared HTML.
- `/game/:id` remains dynamic.

## Cache Matrix After This Branch
- `/_assets/**`: `public, max-age=31536000, immutable`
- `/assets/**`: `public, max-age=31536000, immutable`
- live `/api/**`: explicit `no-store`
- `/api/health`: explicit `no-store`
- `/api/instance`: explicit `no-store`
- `/game/:id`: explicit `no-store`
- `/` and SPA fallback: `public, max-age=0, s-maxage=300,
stale-while-revalidate=86400`

## Notes / Tradeoffs
- `/api/instance` is a temporary compromise. It exists because
`INSTANCE_ID` is currently random per boot, which is not safe to embed
into cacheable shared HTML.
- The current matchmaking flow still asks the client to provide
`instance_id` during `matchmaking/join`. That is functional, but it is
the wrong ownership boundary: instance selection should be handled by
the matchmaking service, not by the browser.
- The cleaner end-state would be:
- make `matchmaking/join` stop requiring `instance_id` from the client,
and let the matchmaking service select a healthy instance from worker
check-ins
- This branch makes the origin behavior edge-cache-friendly, but
Cloudflare still needs matching cache rules if HTML itself should be
cached at the edge.

## Validation
Verified during development with:
- `npx tsc --noEmit`
- `node node_modules\\vite\\bin\\vite.js build`
- `node node_modules\\vitest\\vitest.mjs run
tests/server/RenderHtml.test.ts tests/server/NoStoreHeaders.test.ts
tests/server/StaticAssetCache.test.ts
tests/core/configuration/ConfigLoader.test.ts`

Additional targeted tests added:
- `tests/AssetUrls.test.ts`
- `tests/core/game/FetchGameMapLoader.test.ts`
- `tests/core/configuration/ConfigLoader.test.ts`
- `tests/server/NoStoreHeaders.test.ts`
- `tests/server/StaticAssetCache.test.ts`
- `tests/server/RenderHtml.test.ts`

## Known Existing Warnings
The production build still reports pre-existing warnings that are not
addressed by this branch:
- inconsistent JSON import attributes for `resources/countries.json`
- inconsistent JSON import attributes for `resources/QuickChat.json`
- large chunk warnings from Vite

## Rollout Notes
- Cache rules should treat `/_assets/**` and `/assets/**` as immutable.
- Cloudflare will still classify HTML as dynamic after deploy unless
matching edge cache rules are configured for it.

## Follow-ups
- Remove `/api/instance` by changing `matchmaking/join` so the server
selects the target instance, or by making `INSTANCE_ID` deploy-stable if
the current contract must remain.


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
2026-03-23 11:36:52 -07:00

1010 lines
34 KiB
TypeScript

import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import Countries from "resources/countries.json" with { type: "json" };
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import {
AllPlayers,
PlayerActions,
PlayerProfile,
PlayerType,
Relation,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { actionButton } from "../../components/ui/ActionButton";
import "../../components/ui/Divider";
import {
CloseViewEvent,
MouseUpEvent,
SwapRocketDirectionEvent,
} from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
SendEmbargoAllIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
} from "../../Transport";
import {
renderDuration,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import { UIState } from "../UIState";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
import "./PlayerModerationModal";
import "./SendResourceModal";
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
const chatIcon = assetUrl("images/ChatIconWhite.svg");
const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg");
const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
const stopTradingIcon = assetUrl("images/StopIconWhite.png");
const targetIcon = assetUrl("images/TargetIconWhite.svg");
const startTradingIcon = assetUrl("images/TradingIconWhite.png");
const traitorIcon = assetUrl("images/TraitorIconLightRed.svg");
const breakAllianceIcon = assetUrl("images/TraitorIconWhite.svg");
@customElement("player-panel")
export class PlayerPanel extends LitElement implements Layer {
public g: GameView;
public eventBus: EventBus;
public emojiTable: EmojiTable;
public uiState: UIState;
private actions: PlayerActions | null = null;
private tile: TileRef | null = null;
private _profileForPlayerId: number | null = null;
private kickedPlayerIDs = new Set<string>();
@state() private sendTarget: PlayerView | null = null;
@state() private sendMode: "troops" | "gold" | "none" = "none";
@state() public isVisible: boolean = false;
@state() private allianceExpiryText: string | null = null;
@state() private allianceExpirySeconds: number | null = null;
@state() private otherProfile: PlayerProfile | null = null;
@state() private suppressNextHide: boolean = false;
@state() private moderationTarget: PlayerView | null = null;
private ctModal: ChatModal;
createRenderRoot() {
return this;
}
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (this.isVisible) {
this.hide();
}
});
eventBus.on(SwapRocketDirectionEvent, (event) => {
this.uiState.rocketDirectionUp = event.rocketDirectionUp;
this.requestUpdate();
});
}
init() {
this.eventBus.on(MouseUpEvent, () => {
if (this.suppressNextHide) {
this.suppressNextHide = false;
return;
}
this.hide();
});
this.ctModal = document.querySelector("chat-modal") as ChatModal;
if (!this.ctModal) {
console.warn("ChatModal element not found in DOM");
}
}
async tick() {
if (this.isVisible && this.tile) {
const owner = this.g.owner(this.tile);
if (owner && owner.isPlayer()) {
const pv = owner as PlayerView;
const id = pv.id();
// fetch only if we don't have it or the player changed
if (this._profileForPlayerId !== Number(id)) {
this.otherProfile = await pv.profile();
this._profileForPlayerId = Number(id);
}
}
// Refresh actions & alliance expiry
const myPlayer = this.g.myPlayer();
if (myPlayer !== null && myPlayer.isAlive()) {
this.actions = await myPlayer.actions(this.tile, null);
if (this.actions?.interaction?.allianceInfo?.expiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceInfo.expiresAt;
const remainingTicks = expiresAt - this.g.ticks();
const remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second
if (remainingTicks > 0) {
this.allianceExpirySeconds = remainingSeconds;
this.allianceExpiryText = renderDuration(remainingSeconds);
} else {
this.allianceExpirySeconds = null;
this.allianceExpiryText = null;
}
} else {
this.allianceExpirySeconds = null;
this.allianceExpiryText = null;
}
this.requestUpdate();
}
}
}
public show(actions: PlayerActions, tile: TileRef) {
this.actions = actions;
this.tile = tile;
this.moderationTarget = null;
this.isVisible = true;
this.requestUpdate();
}
public openSendGoldModal(
actions: PlayerActions,
tile: TileRef,
target: PlayerView,
) {
this.suppressNextHide = true;
this.actions = actions;
this.tile = tile;
this.sendTarget = target;
this.sendMode = "gold";
this.moderationTarget = null;
this.isVisible = true;
this.requestUpdate();
}
public hide() {
this.isVisible = false;
this.sendMode = "none";
this.sendTarget = null;
this.moderationTarget = null;
this.requestUpdate();
}
private handleClose(e: Event) {
e.stopPropagation();
this.hide();
}
private handleAllianceClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendAllianceRequestIntentEvent(myPlayer, other));
this.hide();
}
private handleBreakAllianceClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendBreakAllianceIntentEvent(myPlayer, other));
this.hide();
}
private openSendTroops(target: PlayerView) {
this.suppressNextHide = true;
this.sendTarget = target;
this.sendMode = "troops";
}
private openSendGold(target: PlayerView) {
this.suppressNextHide = true;
this.sendTarget = target;
this.sendMode = "gold";
}
private handleDonateTroopClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.openSendTroops(other);
}
private handleDonateGoldClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.openSendGold(other);
}
private closeSend = () => {
this.sendTarget = null;
this.sendMode = "none";
};
private confirmSend = (
e: CustomEvent<{ amount: number; closePanel?: boolean }>,
) => {
this.closeSend();
if (e.detail?.closePanel) this.hide();
};
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(other, "start"));
this.hide();
}
private handleStopEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(other, "stop"));
this.hide();
}
private onStopTradingAllClick(e: Event) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoAllIntentEvent("start"));
}
private onStartTradingAllClick(e: Event) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoAllIntentEvent("stop"));
}
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
if (myPlayer === other) {
this.eventBus.emit(
new SendEmojiIntentEvent(
AllPlayers,
flattenedEmojiTable.indexOf(emoji as Emoji),
),
);
} else {
this.eventBus.emit(
new SendEmojiIntentEvent(
other,
flattenedEmojiTable.indexOf(emoji as Emoji),
),
);
}
this.emojiTable.hideTable();
this.hide();
});
}
private handleChat(e: Event, sender: PlayerView, other: PlayerView) {
e.stopPropagation();
if (!this.ctModal) {
console.warn("ChatModal element not found in DOM");
return;
}
this.ctModal.open(sender, other);
this.hide();
}
private handleTargetClick(e: Event, other: PlayerView) {
e.stopPropagation();
this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id()));
this.hide();
}
private openModeration(e: MouseEvent, other: PlayerView) {
e.stopPropagation();
this.suppressNextHide = true;
this.moderationTarget = other;
}
private closeModeration = () => {
this.moderationTarget = null;
};
private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => {
const playerId = e.detail?.playerId;
if (playerId) this.kickedPlayerIDs.add(String(playerId));
this.closeModeration();
this.hide();
};
private handleToggleRocketDirection(e: Event) {
e.stopPropagation();
const next = !this.uiState.rocketDirectionUp;
this.eventBus.emit(new SwapRocketDirectionEvent(next));
}
private identityChipProps(type: PlayerType) {
switch (type) {
case PlayerType.Nation:
return {
labelKey: "player_type.nation",
classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200",
icon: "🏛️",
};
case PlayerType.Bot:
return {
labelKey: "player_type.bot",
classes: "border-purple-400/25 bg-purple-500/10 text-purple-200",
icon: "⚔️",
};
case PlayerType.Human:
default:
return {
labelKey: "player_type.player",
classes: "border-zinc-400/20 bg-zinc-500/5 text-zinc-300",
icon: "👤",
};
}
}
private getRelationClass(relation: Relation): string {
const base =
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 " +
"shadow-[inset_0_0_8px_rgba(255,255,255,0.04)]";
switch (relation) {
case Relation.Hostile:
return `${base} border-red-400/30 bg-red-500/10 text-red-200`;
case Relation.Distrustful:
return `${base} border-red-300/40 bg-red-300/10 text-red-300`;
case Relation.Friendly:
return `${base} border-emerald-400/30 bg-emerald-500/10 text-emerald-200`;
case Relation.Neutral:
default:
return `${base} border-zinc-400/30 bg-zinc-500/10 text-zinc-200`;
}
}
private getRelationName(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
return translateText("relation.hostile");
case Relation.Distrustful:
return translateText("relation.distrustful");
case Relation.Friendly:
return translateText("relation.friendly");
case Relation.Neutral:
default:
return translateText("relation.neutral");
}
}
private getExpiryColorClass(seconds: number | null): string {
if (seconds === null) return "text-white"; // Default color
if (seconds <= 30) return "text-red-400"; // Last 30 seconds: Red
if (seconds <= 60) return "text-yellow-400"; // Last 60 seconds: Yellow
return "text-emerald-400"; // More than 60 seconds: Green
}
private getTraitorRemainingSeconds(player: PlayerView): number | null {
const ticksLeft = player.data.traitorRemainingTicks ?? 0;
if (!player.isTraitor() || ticksLeft <= 0) return null;
return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second
}
private renderTraitorBadge(other: PlayerView) {
if (!other.isTraitor()) return html``;
const secs = this.getTraitorRemainingSeconds(other);
const label = secs !== null ? renderDuration(secs) : null;
const dotCls =
secs !== null
? `mx-1 size-1 rounded-full bg-red-400/70 ${secs <= 10 ? "animate-pulse" : ""}`
: "";
return html`
<div class="mt-1" role="status" aria-live="polite" aria-atomic="true">
<span
class="inline-flex items-center gap-2 rounded-full border border-red-400/30
bg-red-500/10 px-2.5 py-0.5 text-sm font-semibold text-red-200
shadow-[inset_0_0_8px_rgba(239,68,68,0.12)]"
title=${translateText("player_panel.traitor")}
>
<img src=${traitorIcon} alt="" aria-hidden="true" class="size-4.5" />
<span class="tracking-tight"
>${translateText("player_panel.traitor")}</span
>
${label
? html`<span class=${dotCls}></span>
<span
class="tabular-nums font-bold text-red-100 whitespace-nowrap text-sm"
>
${label}
</span>`
: ""}
</span>
</div>
`;
}
private renderModeration(my: PlayerView, other: PlayerView) {
if (!my.isLobbyCreator()) return html``;
const moderationTitle = translateText("player_panel.moderation");
return html`
<ui-divider></ui-divider>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.openModeration(e, other),
icon: shieldIcon,
iconAlt: "Moderation",
title: moderationTitle,
label: moderationTitle,
type: "red",
})}
</div>
`;
}
private renderRelationPillIfNation(other: PlayerView, my: PlayerView) {
if (other.type() !== PlayerType.Nation) return html``;
if (other.isTraitor()) return html``;
if (my?.isAlliedWith && my.isAlliedWith(other)) return html``;
if (!this.otherProfile || !my) return html``;
const relation =
this.otherProfile.relations?.[my.smallID()] ?? Relation.Neutral;
const cls = this.getRelationClass(relation);
const name = this.getRelationName(relation);
return html`
<div class="mt-1">
<span class="text-sm font-semibold ${cls}">${name}</span>
</div>
`;
}
private renderIdentityRow(other: PlayerView, my: PlayerView) {
const flagCode = other.cosmetics.flag;
const country =
typeof flagCode === "string"
? Countries.find((c) => c.code === flagCode)
: undefined;
const chip =
other.type() === PlayerType.Human
? null
: this.identityChipProps(other.type());
return html`
<div class="flex items-center gap-2.5 flex-wrap">
${country && typeof flagCode === "string"
? html`<img
src=${assetUrl(`flags/${encodeURIComponent(flagCode)}.svg`)}
alt=${country?.name ?? "Flag"}
class="h-10 w-10 rounded-full object-cover"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>`
: ""}
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.displayName()}
>
${other.displayName()}
</h2>
</div>
${chip
? html`<span
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
role="status"
aria-label=${translateText(chip.labelKey)}
title=${translateText(chip.labelKey)}
>
<span aria-hidden="true" class="leading-none">${chip.icon}</span>
<span class="tracking-tight"
>${translateText(chip.labelKey)}</span
>
</span>`
: html``}
</div>
${this.renderTraitorBadge(other)}
${this.renderRelationPillIfNation(other, my)}
`;
}
private renderResources(other: PlayerView) {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-lg bg-white/4 px-3 py-1.5 shrink-0
text-white w-35"
>
<span class="mr-0.5">💰</span>
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
${renderNumber(other.gold() || 0)}
</span>
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.gold")}</span
>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-lg bg-white/4 px-3 py-1.5
text-white w-35 shrink-0"
>
<span class="mr-0.5">🛡️</span>
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
${renderTroops(other.troops() || 0)}
</span>
<span class="text-zinc-200 whitespace-nowrap">
${translateText("player_panel.troops")}</span
>
</div>
</div>
`;
}
private renderRocketDirectionToggle() {
return html`
<ui-divider></ui-divider>
<button
class="flex w-full items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-left text-white hover:bg-white/8 active:scale-[0.995] transition"
@click=${(e: Event) => this.handleToggleRocketDirection(e)}
>
<div class="flex flex-col">
<span class="text-sm font-semibold tracking-tight">
${translateText("player_panel.flip_rocket_trajectory")}
</span>
<span class="text-xs text-zinc-300" translate="no">
${this.uiState.rocketDirectionUp
? translateText("player_panel.arc_up")
: translateText("player_panel.arc_down")}
</span>
</div>
<span class="text-lg" aria-hidden="true">🔀</span>
</button>
`;
}
private renderStats(other: PlayerView, my: PlayerView) {
return html`
<!-- Betrayals -->
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚠️</span>
<span>${translateText("player_panel.betrayals")}</span>
</div>
<div class="text-right text-[14px] font-semibold text-zinc-200">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Trading / Embargo -->
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2">
<div
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
>
<span aria-hidden="true">⚓</span>
<span>${translateText("player_panel.trading")}</span>
</div>
<div
class="flex items-center justify-end gap-2 text-[14px] font-semibold"
>
${other.hasEmbargoAgainst(my)
? html`<span class="text-amber-400"
>${translateText("player_panel.stopped")}</span
>`
: html`<span class="text-blue-400"
>${translateText("player_panel.active")}</span
>`}
</div>
</div>
`;
}
private renderAlliances(other: PlayerView) {
const allies = other.allies();
const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
const alliesSorted = [...allies].sort((a, b) =>
nameCollator.compare(a.displayName(), b.displayName()),
);
return html`
<div class="select-none">
<div class="flex items-center justify-between mb-2">
<div
id="alliances-title"
class="text-[15px] font-medium text-zinc-200"
>
${translateText("player_panel.alliances")}
</div>
<span
aria-labelledby="alliances-title"
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
>
${allies.length}
</span>
</div>
<div
class="rounded-lg bg-zinc-800/70 ring-1 ring-zinc-700/60 w-full min-w-0"
>
<ul
class="max-h-30 overflow-y-auto p-2
flex flex-wrap gap-1.5
scrollbar-thin scrollbar-thumb-zinc-600 hover:scrollbar-thumb-zinc-500 scrollbar-track-zinc-800"
role="list"
aria-labelledby="alliances-title"
translate="no"
>
${alliesSorted.length === 0
? html`<li class="text-zinc-400 text-[14px] px-1">
${translateText("common.none")}
</li>`
: alliesSorted.map(
(p) =>
html`<li
class="max-w-full inline-flex items-center gap-1.5
rounded-md border border-white/10 bg-white/5
px-2.5 py-1 text-[14px] text-zinc-100
hover:bg-white/8 active:scale-[0.99] transition"
title=${p.displayName()}
>
<span class="truncate">${p.displayName()}</span>
</li>`,
)}
</ul>
</div>
</div>
`;
}
private renderAllianceExpiry() {
if (this.allianceExpiryText === null) return html``;
return html`
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-base">
<div class="font-semibold text-zinc-300">
${translateText("player_panel.alliance_time_remaining")}
</div>
<div class="text-right font-semibold">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[14px] font-bold ${this.getExpiryColorClass(
this.allianceExpirySeconds,
)}"
>${this.allianceExpiryText}</span
>
</div>
</div>
`;
}
private renderActions(my: PlayerView, other: PlayerView) {
const myPlayer = this.g.myPlayer();
const canDonateGold = this.actions?.interaction?.canDonateGold;
const canDonateTroops = this.actions?.interaction?.canDonateTroops;
const canSendAllianceRequest =
this.actions?.interaction?.canSendAllianceRequest;
const canSendEmoji =
other === myPlayer
? this.actions?.canSendEmojiAllPlayers
: this.actions?.interaction?.canSendEmoji;
const canBreakAlliance = this.actions?.interaction?.canBreakAlliance;
const canTarget = this.actions?.interaction?.canTarget;
const canEmbargo = this.actions?.interaction?.canEmbargo;
return html`
<div class="flex flex-col gap-2.5">
<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.handleChat(e, my, other),
icon: chatIcon,
iconAlt: "Chat",
title: translateText("player_panel.chat"),
label: translateText("player_panel.chat"),
})}
${canSendEmoji
? actionButton({
onClick: (e: MouseEvent) => this.handleEmojiClick(e, my, other),
icon: emojiIcon,
iconAlt: "Emoji",
title: translateText("player_panel.emotes"),
label: translateText("player_panel.emotes"),
type: "normal",
})
: ""}
${canTarget
? actionButton({
onClick: (e: MouseEvent) => this.handleTargetClick(e, other),
icon: targetIcon,
iconAlt: "Target",
title: translateText("player_panel.target"),
label: translateText("player_panel.target"),
type: "normal",
})
: ""}
${canDonateTroops
? actionButton({
onClick: (e: MouseEvent) =>
this.handleDonateTroopClick(e, my, other),
icon: donateTroopIcon,
iconAlt: "Troops",
title: translateText("player_panel.send_troops"),
label: translateText("player_panel.troops"),
type: "normal",
})
: ""}
${canDonateGold
? actionButton({
onClick: (e: MouseEvent) =>
this.handleDonateGoldClick(e, my, other),
icon: donateGoldIcon,
iconAlt: "Gold",
title: translateText("player_panel.send_gold"),
label: translateText("player_panel.gold"),
type: "normal",
})
: ""}
</div>
<ui-divider></ui-divider>
${other === my
? html``
: html`
<div class="grid auto-cols-fr grid-flow-col gap-1">
${canEmbargo
? actionButton({
onClick: (e: MouseEvent) =>
this.handleEmbargoClick(e, my, other),
icon: stopTradingIcon,
iconAlt: "Stop Trading",
title: translateText("player_panel.stop_trade"),
label: translateText("player_panel.stop_trade"),
type: "yellow",
})
: actionButton({
onClick: (e: MouseEvent) =>
this.handleStopEmbargoClick(e, my, other),
icon: startTradingIcon,
iconAlt: "Start Trading",
title: translateText("player_panel.start_trade"),
label: translateText("player_panel.start_trade"),
type: "green",
})}
${canBreakAlliance
? actionButton({
onClick: (e: MouseEvent) =>
this.handleBreakAllianceClick(e, my, other),
icon: breakAllianceIcon,
iconAlt: "Break Alliance",
title: translateText("player_panel.break_alliance"),
label: translateText("player_panel.break_alliance"),
type: "red",
})
: ""}
${canSendAllianceRequest
? actionButton({
onClick: (e: MouseEvent) =>
this.handleAllianceClick(e, my, other),
icon: allianceIcon,
iconAlt: "Alliance",
title: translateText("player_panel.send_alliance"),
label: translateText("player_panel.send_alliance"),
type: "indigo",
})
: ""}
</div>
`}
${other === my
? html`<div class="grid auto-cols-fr grid-flow-col gap-1">
${actionButton({
onClick: (e: MouseEvent) => this.onStopTradingAllClick(e),
icon: stopTradingIcon,
iconAlt: "Stop Trading With All",
title: !this.actions?.canEmbargoAll
? `${translateText("player_panel.stop_trade_all")} - ${translateText("cooldown")}`
: translateText("player_panel.stop_trade_all"),
label: !this.actions?.canEmbargoAll
? `${translateText("player_panel.stop_trade_all")}`
: translateText("player_panel.stop_trade_all"),
type: "yellow",
disabled: !this.actions?.canEmbargoAll,
})}
${actionButton({
onClick: (e: MouseEvent) => this.onStartTradingAllClick(e),
icon: startTradingIcon,
iconAlt: "Start Trading With All",
title: !this.actions?.canEmbargoAll
? `${translateText("player_panel.start_trade_all")} - ${translateText("cooldown")}`
: translateText("player_panel.start_trade_all"),
label: !this.actions?.canEmbargoAll
? `${translateText("player_panel.start_trade_all")}`
: translateText("player_panel.start_trade_all"),
type: "green",
disabled: !this.actions?.canEmbargoAll,
})}
</div>`
: ""}
${this.renderModeration(my, other)}
</div>
`;
}
render() {
if (!this.isVisible) return html``;
const my = this.g.myPlayer();
if (!my) return html``;
if (!this.tile) return html``;
const owner = this.g.owner(this.tile);
if (!owner || !owner.isPlayer()) {
this.hide();
console.warn("Tile is not owned by a player");
return html``;
}
const other = owner as PlayerView;
const myGoldNum = my.gold();
const myTroopsNum = Number(my.troops());
return html`
<style>
/* Soft glowing ring animation for traitors */
.traitor-ring {
border-radius: 1rem;
box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.34),
0 0 12px 4px rgba(239, 68, 68, 0.22),
inset 0 0 14px rgba(239, 68, 68, 0.13);
animation: glowPulse 2.4s ease-in-out infinite;
}
@keyframes glowPulse {
0%,
100% {
box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.22),
0 0 8px 2px rgba(239, 68, 68, 0.15),
inset 0 0 8px rgba(239, 68, 68, 0.07);
}
50% {
box-shadow:
0 0 0 4px rgba(239, 68, 68, 0.38),
0 0 18px 6px rgba(239, 68, 68, 0.26),
inset 0 0 18px rgba(239, 68, 68, 0.15);
}
}
</style>
<div
class="fixed inset-0 z-10001 flex items-center justify-center overflow-auto
bg-black/15 backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<div
class="pointer-events-auto max-h-[90vh] min-w-75 max-w-100 px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="relative">
<div
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
></div>
<div
class=${`relative w-full bg-zinc-900/95 rounded-2xl text-zinc-100 shadow-2xl shadow-black/50
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
>
<div class="overflow-visible">
<div
class="overflow-auto [-webkit-overflow-scrolling:touch] resize-y max-h-[calc(100vh-120px-env(safe-area-inset-bottom))]"
>
<div class="sticky top-0 z-20 flex justify-end p-2">
<button
@click=${this.handleClose}
class="absolute right-3 top-3 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors"
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
</div>
<div
class="p-6 flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
${this.moderationTarget
? html`
<player-moderation-modal
.open=${true}
.myPlayer=${my}
.target=${this.moderationTarget}
.eventBus=${this.eventBus}
.alreadyKicked=${this.kickedPlayerIDs.has(
String(this.moderationTarget.id()),
)}
@close=${this.closeModeration}
@kicked=${this.handleModerationKicked}
></player-moderation-modal>
`
: ""}
<ui-divider></ui-divider>
<!-- Resources -->
${this.renderResources(other)}
<!-- Rocket direction toggle -->
${other === my ? this.renderRocketDirectionToggle() : ""}
<ui-divider></ui-divider>
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
<ui-divider></ui-divider>
<!-- Alliances list -->
${this.renderAlliances(other)}
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<ui-divider></ui-divider>
<!-- Actions -->
${this.renderActions(my, other)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
}