Merge remote-tracking branch 'origin/main' into fix/impassable-minimap-priority

This commit is contained in:
FloPinguin
2026-06-20 17:41:56 +02:00
30 changed files with 542 additions and 62 deletions
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
<path fill="none" d="M0 0h48v48H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

+10
View File
@@ -12,7 +12,13 @@
"failed_to_send_recovery_email": "Failed to send recovery email",
"fetching_account": "Fetching account information...",
"get_magic_link": "Get Magic Link",
"google_alt": "Google",
"link_discord": "Link Discord Account",
"link_google": "Link Google Account",
"link_google_already_linked": "That Google account is already linked to another player.",
"link_google_error": "Couldn't link your Google account. Please make sure you're signed in and try again.",
"link_google_failed": "Couldn't start Google linking. Please try again.",
"link_google_success": "Google account linked.",
"linked_account": "Logged in as {account_name}",
"log_out": "Log Out",
"manage_subscription": "Manage",
@@ -423,6 +429,7 @@
"sent_emoji": "Sent {name}: {emoji}",
"sent_gold_to_player": "Sent {gold} gold to {name}",
"sent_troops_to_player": "Sent {troops} troops to {name}",
"trade_ship_captured": "Your trade ship was captured by {name}",
"unit_destroyed": "Your {unit} was destroyed",
"unit_voluntarily_deleted": "Unit voluntarily deleted",
"wants_to_renew_alliance": "{name} wants to renew your alliance"
@@ -546,6 +553,8 @@
"name_cull_desc": "Hide names smaller than this size",
"name_cull_label": "Minimum name size",
"name_scale_label": "Name Scale",
"nuke_color_desc": "Color of the fallout tint left on territory after a nuke.",
"nuke_color_label": "Nuke fallout color",
"ocean_color_desc": "Base color of ocean.",
"ocean_color_label": "Ocean color",
"rail_distance_desc": "How far zoomed out train tracks remain visible",
@@ -806,6 +815,7 @@
"join": "Join Lobby",
"leaderboard": "Leaderboard",
"login_discord": "Login with Discord",
"login_google": "Login with Google",
"menu": "Menu",
"news": "News",
"play": "Play",
+112 -4
View File
@@ -9,7 +9,13 @@ import {
import { assetUrl } from "../core/AssetUrls";
import { Cosmetics } from "../core/CosmeticSchemas";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import {
discordLogin,
googleLogin,
linkGoogle,
logOut,
sendMagicLink,
} from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
import "./components/baseComponents/stats/PlayerStatsTable";
@@ -96,7 +102,7 @@ export class AccountModal extends BaseModal {
private isLinkedAccount(): boolean {
const me = this.userMeResponse?.user;
return !!(me?.discord ?? me?.email);
return !!(me?.discord ?? me?.google ?? me?.email);
}
protected modalConfig() {
@@ -252,6 +258,18 @@ export class AccountModal extends BaseModal {
if (me?.discord) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
${this.renderCurrency()} ${this.renderLinkGoogleButton()}
${this.renderLogoutButton()}
</div>
`;
} else if (me?.google) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
<div class="text-white text-lg font-medium">
${translateText("account_modal.linked_account", {
account_name: me.google.email,
})}
</div>
${this.renderCurrency()} ${this.renderLogoutButton()}
</div>
`;
@@ -263,13 +281,35 @@ export class AccountModal extends BaseModal {
account_name: me.email,
})}
</div>
${this.renderCurrency()} ${this.renderLogoutButton()}
${this.renderCurrency()} ${this.renderLinkGoogleButton()}
${this.renderLogoutButton()}
</div>
`;
}
return html``;
}
// Shown when logged in without a Google identity yet. Lets the user attach
// Google to their existing account (we never auto-merge by email).
private renderLinkGoogleButton(): TemplateResult {
if (this.userMeResponse?.user?.google) return html``;
return html`
<button
@click=${this.handleLinkGoogle}
class="w-full px-6 py-3 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt=${translateText("account_modal.google_alt")}
class="w-5 h-5"
/>
<span class="font-bold tracking-wide"
>${translateText("account_modal.link_google")}</span
>
</button>
`;
}
private async viewGame(gameId: string): Promise<void> {
this.close();
const encodedGameId = encodeURIComponent(gameId);
@@ -340,6 +380,22 @@ export class AccountModal extends BaseModal {
>
</button>
<!-- Google Login Button (Google brand guidelines: white surface,
dark text, the multicolor "G" mark) -->
<button
@click="${this.handleGoogleLogin}"
class="w-full px-6 py-4 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt="Google"
class="w-6 h-6 relative z-10"
/>
<span class="font-bold relative z-10 tracking-wide"
>${translateText("main.login_google")}</span
>
</button>
<!-- Divider -->
<div class="flex items-center gap-4 py-2">
<div class="h-px bg-white/10 flex-1"></div>
@@ -417,8 +473,60 @@ export class AccountModal extends BaseModal {
discordLogin();
}
protected onOpen(): void {
private handleGoogleLogin() {
googleLogin();
}
private async handleLinkGoogle(): Promise<void> {
// On success linkGoogle navigates to Google; the result comes back as a
// `link=...` router arg handled in handleLinkResult. A false return means we
// couldn't start it.
const started = await linkGoogle();
if (!started) {
alert(translateText("account_modal.link_google_failed"));
}
}
// The Google link callback returns us to #modal=account&link=<result>, so the
// router reopens this modal with a `link` arg. Surface the outcome, then strip
// the one-shot param from the URL so a refresh/re-open doesn't replay it.
private handleLinkResult(args?: Record<string, unknown>): void {
const link = typeof args?.link === "string" ? args.link : undefined;
if (link === undefined) return;
// replaceState doesn't fire hashchange, so removing the param won't re-route.
const params = new URLSearchParams(window.location.hash.slice(1));
params.delete("link");
const rest = params.toString();
history.replaceState(
null,
"",
rest ? `#${rest}` : window.location.pathname + window.location.search,
);
// Defer so the modal paints before the (blocking) alert. "cancel" needs no
// feedback — the user chose to back out.
if (link === "google") {
setTimeout(
() => alert(translateText("account_modal.link_google_success")),
0,
);
} else if (link === "already_linked") {
setTimeout(
() => alert(translateText("account_modal.link_google_already_linked")),
0,
);
} else if (link === "error") {
setTimeout(
() => alert(translateText("account_modal.link_google_error")),
0,
);
}
}
protected onOpen(args?: Record<string, unknown>): void {
this.isLoadingUser = true;
this.handleLinkResult(args);
if (SUBSCRIPTIONS_ENABLED) {
void fetchCosmetics().then((cosmetics) => {
+2 -1
View File
@@ -282,13 +282,14 @@ export function getAudience() {
return domainname;
}
// Check if the user's account is linked to a Discord or email account.
// Check if the user's account is linked to a Discord, Google, or email account.
export function hasLinkedAccount(
userMeResponse: UserMeResponse | false,
): boolean {
return (
userMeResponse !== false &&
(userMeResponse.user?.discord !== undefined ||
userMeResponse.user?.google !== undefined ||
userMeResponse.user?.email !== undefined)
);
}
+35
View File
@@ -19,6 +19,41 @@ export function discordLogin() {
window.location.href = `${getApiBase()}/auth/login/discord?redirect_uri=${redirectUri}`;
}
export function googleLogin() {
const redirectUri = encodeURIComponent(window.location.href);
window.location.href = `${getApiBase()}/auth/login/google?redirect_uri=${redirectUri}`;
}
// Link a Google account to the currently logged-in player. Unlike login this is
// an authenticated request, so we fetch the Google authorize URL with the
// Bearer token (a top-level navigation can't carry it) and then navigate to it.
// Returns false if the user isn't logged in or the request fails.
export async function linkGoogle(): Promise<boolean> {
const authHeader = await getAuthHeader();
if (authHeader === "") return false;
const redirectUri = encodeURIComponent(window.location.href);
try {
const response = await fetch(
`${getApiBase()}/auth/link/google?redirect_uri=${redirectUri}`,
{
headers: { Authorization: authHeader },
credentials: "include",
},
);
if (!response.ok) {
console.error("Failed to start Google link", response);
return false;
}
const { url } = await response.json();
if (typeof url !== "string") return false;
window.location.href = url;
return true;
} catch (e) {
console.error("Failed to start Google link", e);
return false;
}
}
export async function tempTokenLogin(token: string): Promise<string | null> {
const response = await fetch(
`${getApiBase()}/auth/login/token?login-token=${token}`,
+8
View File
@@ -162,6 +162,14 @@ function updateAccountNavButton(userMeResponse: UserMeResponse | false) {
return;
}
// Google logins have no avatar; show the same person/email badge as magic-link.
const google =
userMeResponse !== false ? userMeResponse.user.google : undefined;
if (google) {
showEmailLoggedIn();
return;
}
showSignIn();
}
+3 -1
View File
@@ -122,7 +122,9 @@ export class MatchmakingModal extends BaseModal {
const isLoggedIn =
userMe &&
userMe.user &&
(userMe.user.discord !== undefined || userMe.user.email !== undefined);
(userMe.user.discord !== undefined ||
userMe.user.google !== undefined ||
userMe.user.email !== undefined);
if (!isLoggedIn) {
window.dispatchEvent(
new CustomEvent("show-message", {
+17 -1
View File
@@ -74,11 +74,19 @@ export class EventsDisplay extends LitElement implements Controller {
private _eventsContainer?: HTMLDivElement;
private _shouldScrollToBottom = true;
@query(".important-events-container")
private _importantEventsContainer?: HTMLDivElement;
private _shouldScrollImportantToBottom = true;
updated(changed: Map<string, unknown>) {
super.updated(changed);
if (this._eventsContainer && this._shouldScrollToBottom) {
this._eventsContainer.scrollTop = this._eventsContainer.scrollHeight;
}
if (this._importantEventsContainer && this._shouldScrollImportantToBottom) {
this._importantEventsContainer.scrollTop =
this._importantEventsContainer.scrollHeight;
}
}
private renderButton(options: {
@@ -172,6 +180,14 @@ export class EventsDisplay extends LitElement implements Controller {
this._shouldScrollToBottom = true;
}
if (this._importantEventsContainer) {
const el = this._importantEventsContainer;
this._shouldScrollImportantToBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 5;
} else {
this._shouldScrollImportantToBottom = true;
}
if (!this._isVisible && !this.game.inSpawnPhase()) {
this._isVisible = true;
this.requestUpdate();
@@ -640,7 +656,7 @@ export class EventsDisplay extends LitElement implements Controller {
${tier1Events.length > 0 || showBetrayalTimer
? html`
<div
class="bg-gray-800 backdrop-blur-sm rounded-lg shadow-lg border-l-4 border-red-500"
class="bg-gray-800 backdrop-blur-sm max-h-[30vh] lg:max-h-[40vh] overflow-y-auto rounded-lg shadow-lg border-l-4 border-red-500 important-events-container"
>
<table
class="w-full border-collapse text-white text-base lg:text-lg font-medium pointer-events-auto"
@@ -115,6 +115,22 @@ function falloffToUnitGlowSlider(falloff: number): number {
const HEX_COLOR_RE = /^#?([0-9a-fA-F]{6})$/;
// The stale-nuke (fallout ground tint) color is stored in render-settings.json
// as three 0-1 floats; the color picker wants a "#rrggbb" hex string.
function rgbFloatsToHex(r: number, g: number, b: number): string {
const ch = (v: number) =>
Math.round(v * 255)
.toString(16)
.padStart(2, "0");
return `#${ch(r)}${ch(g)}${ch(b)}`;
}
const NUKE_COLOR_DEFAULT = rgbFloatsToHex(
renderDefaults.mapOverlay.staleNukeR,
renderDefaults.mapOverlay.staleNukeG,
renderDefaults.mapOverlay.staleNukeB,
);
export class ShowGraphicsSettingsModalEvent {
constructor(
public readonly isVisible: boolean = true,
@@ -356,6 +372,20 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchMapOverlay({ coordinateGridOpacity: value });
}
private currentNukeColor(): string {
return (
this.userSettings.graphicsOverrides().mapOverlay?.staleNukeColor ??
NUKE_COLOR_DEFAULT
);
}
private onNukeColorChange(event: Event) {
const value = (event.target as HTMLInputElement).value.trim();
const match = HEX_COLOR_RE.exec(value);
if (!match) return; // ignore partial/invalid hex while typing
this.patchMapOverlay({ staleNukeColor: `#${match[1].toLowerCase()}` });
}
private onRailDrawDistanceChange(event: Event) {
const drawDistance = parseFloat((event.target as HTMLInputElement).value);
// Invert: higher draw distance => tracks visible when more zoomed out.
@@ -572,6 +602,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom();
const railThickness = this.currentRailThickness();
const oceanColor = this.currentOceanColor();
const nukeColor = this.currentNukeColor();
const ambientLevel = this.currentAmbientLevel();
const unitGlow = this.currentUnitGlow();
const colorblind = this.currentColorblind();
@@ -1123,6 +1154,33 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
/>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.nuke_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.nuke_color_desc")}
</div>
</div>
<input
type="text"
.value=${nukeColor}
placeholder=${NUKE_COLOR_DEFAULT}
spellcheck="false"
@change=${this.onNukeColorChange}
class="w-24 px-2 py-1 bg-slate-900 border border-slate-500 rounded-sm text-sm text-white font-mono"
/>
<input
type="color"
.value=${nukeColor}
@input=${this.onNukeColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
@@ -27,6 +27,9 @@ export const GraphicsOverridesSchema = z
territorySaturation: z.number(),
territoryAlpha: z.number(),
coordinateGridOpacity: z.number(),
// "#rrggbb" hex string; overrides the lingering fallout ground tint
// left on territory after a nuke.
staleNukeColor: z.string(),
})
.partial(),
railroad: z
+10
View File
@@ -1,5 +1,6 @@
import type { GraphicsOverrides } from "./GraphicsOverrides";
import { createThemeSettings, type RenderSettings } from "./RenderSettings";
import { hexToRgb } from "./utils/ColorUtils";
/**
* Apply the user's graphics overrides onto a RenderSettings in place: name
@@ -64,6 +65,15 @@ export function applyGraphicsOverrides(
settings.mapOverlay.coordinateGridOpacity =
overrides.mapOverlay.coordinateGridOpacity;
}
if (overrides.mapOverlay?.staleNukeColor !== undefined) {
// hexToRgb yields 0-255 channels; the stale-nuke uniforms are 0-1 floats.
const rgb = hexToRgb(overrides.mapOverlay.staleNukeColor);
if (rgb !== null) {
settings.mapOverlay.staleNukeR = rgb[0] / 255;
settings.mapOverlay.staleNukeG = rgb[1] / 255;
settings.mapOverlay.staleNukeB = rgb[2] / 255;
}
}
if (overrides.railroad?.railMinZoom !== undefined) {
settings.railroad.railMinZoom = overrides.railroad.railMinZoom;
}
+2
View File
@@ -260,6 +260,8 @@ export interface RenderSettings {
nameShadeBot: number;
emojiRowOffset: number;
statusRowOffset: number;
/** Dark outline radius (atlas texels) drawn behind the alliance icon; 0 = off. */
statusOutlineWidth: number;
/** Alpha multiplier applied to a name while the cursor is over it. */
hoverFadeAlpha: number;
/** White glow behind the hovered player's name: px past the outline. */
+9
View File
@@ -355,6 +355,15 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
toggle(s.name, "fillUsePlayerColor", d.name, "Fill = Player Color"),
slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"),
slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"),
slider(
s.name,
"statusOutlineWidth",
d.name,
0,
16,
0.5,
"Status Outline Width",
),
slider(s.name, "hoverFadeAlpha", d.name, 0, 1, 0.05, "Hover Fade Alpha"),
slider(s.name, "hoverGlowWidth", d.name, 0, 8, 0.25, "Hover Glow Width"),
slider(s.name, "hoverGlowAlpha", d.name, 0, 1, 0.05, "Hover Glow Alpha"),
@@ -40,6 +40,7 @@ export class StatusIconProgram {
private uStatusRowOffset: WebGLUniformLocation;
private uFadeOwnerID: WebGLUniformLocation;
private uHoverFadeAlpha: WebGLUniformLocation;
private uStatusOutlinePx: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
@@ -83,6 +84,12 @@ export class StatusIconProgram {
gl.getUniformLocation(this.program, "uStatusPad")!,
sm.pad ?? 0,
);
// Texel size for the outline dilation sampling (static).
gl.uniform2f(
gl.getUniformLocation(this.program, "uStatusTexel")!,
1 / sm.width,
1 / sm.height,
);
// Flash window matches the alliance renewal prompt (10 ticks/sec)
gl.uniform1f(
gl.getUniformLocation(this.program, "uAllianceFlashWindowSec")!,
@@ -111,6 +118,10 @@ export class StatusIconProgram {
this.program,
"uHoverFadeAlpha",
)!;
this.uStatusOutlinePx = gl.getUniformLocation(
this.program,
"uStatusOutlinePx",
)!;
this.loadAtlas();
}
@@ -159,6 +170,7 @@ export class StatusIconProgram {
gl.uniform1f(this.uStatusRowOffset, ns.statusRowOffset);
gl.uniform1f(this.uFadeOwnerID, fadeOwnerID);
gl.uniform1f(this.uHoverFadeAlpha, ns.hoverFadeAlpha);
gl.uniform1f(this.uStatusOutlinePx, ns.statusOutlineWidth);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
@@ -216,6 +216,7 @@
"nameShadeBot": 0.4,
"emojiRowOffset": 1.4,
"statusRowOffset": 1.4,
"statusOutlineWidth": 6,
"hoverFadeAlpha": 0.5,
"hoverGlowWidth": 5,
"hoverGlowAlpha": 0.75
@@ -2,6 +2,8 @@
precision highp float;
uniform sampler2D uStatusAtlas;
uniform vec2 uStatusTexel; // 1/atlasW, 1/atlasH
uniform float uStatusOutlinePx; // outline radius in atlas texels (0 = off)
in vec2 vUV;
in vec2 vLocalUV;
@@ -10,10 +12,18 @@ flat in float vAllianceFraction;
flat in vec2 vFadedUV0;
flat in vec2 vFadedUV1;
flat in float vFlashAlpha;
flat in float vOutline; // 1.0 = draw a dark outline behind this icon
in float vHoverAlpha;
out vec4 fragColor;
// 8 unit directions for the outline dilation sample ring.
const vec2 kRing[8] = vec2[8](
vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0),
vec2(0.707, 0.707), vec2(-0.707, 0.707),
vec2(0.707, -0.707), vec2(-0.707, -0.707)
);
void main() {
if (vDiscard != 0) discard;
@@ -33,8 +43,23 @@ void main() {
texel = vLocalUV.y < topCut ? fadedTexel : texel;
}
// Traitor flash: modulate alpha for urgency pulse
texel.a *= vFlashAlpha * vHoverAlpha;
// Traitor flash + hover fade: modulate alpha
float fade = vFlashAlpha * vHoverAlpha;
texel.a *= fade;
// Dark outline: dilate the icon's alpha so it stays legible over terrain of a
// similar color (the green alliance icon vs. irradiated land). Sampling the
// padded atlas cell never reaches a neighbouring icon.
if (vOutline > 0.5 && uStatusOutlinePx > 0.0) {
float ring = 0.0;
vec2 sampleStep = uStatusTexel * uStatusOutlinePx;
for (int i = 0; i < 8; i++) {
ring = max(ring, texture(uStatusAtlas, vUV + kRing[i] * sampleStep).a);
}
ring *= fade;
float outlineA = ring * (1.0 - texel.a);
texel = vec4(mix(vec3(0.0), texel.rgb, texel.a), max(texel.a, outlineA));
}
if (texel.a < 0.01) discard;
fragColor = texel;
@@ -24,6 +24,7 @@ uniform float uStatusCols; // columns in atlas
uniform float uStatusAtlasW; // atlas texture width
uniform float uStatusAtlasH; // atlas texture height
uniform float uStatusPad; // transparent padding in texels per side
uniform float uStatusOutlinePx; // dark-outline radius in atlas texels (0 = off)
// Configurable layout
uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameWorldScale)
@@ -39,6 +40,7 @@ flat out float vAllianceFraction; // 0 = no drain effect, >0 = active drain
flat out vec2 vFadedUV0; // top-left UV of faded alliance cell
flat out vec2 vFadedUV1; // bottom-right UV of faded alliance cell
flat out float vFlashAlpha; // traitor flash opacity (1.0 = fully visible)
flat out float vOutline; // 1.0 = alliance icon, draw a dark outline
out float vHoverAlpha;
// Status flag float array — indexed by icon slot.
@@ -103,6 +105,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -120,6 +123,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -149,6 +153,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -180,23 +185,41 @@ void main() {
atlasIdx = (pd7.x > 0.5) ? 7 : 8;
}
// Only the alliance icon (slot 3) gets the dark outline.
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
// Fade the status row along with the rest of the name plate when the cursor
// is over any part of it. Hit test runs on the CPU (NamePass).
vHoverAlpha = (uFadeOwnerID > 0.0 && pd4.z == uFadeOwnerID)
? uHoverFadeAlpha : 1.0;
// Quad world position
vec2 iconOrigin = vec2(iconX, iconY);
vec2 worldPos = iconOrigin + aPos * vec2(iconWorldSize, iconWorldSize);
// Dark-outline margin: grow the alliance icon's quad outward into the cell's
// transparent padding so the outline halo isn't clipped at the quad edge.
// The icon content keeps its size; only the quad's bounding box grows. Other
// icons keep marginWorld = 0 and render pixel-identically.
float iconTexels = uStatusCell - 2.0 * uStatusPad;
float marginTex = (vOutline > 0.5 && uStatusOutlinePx > 0.0)
? min(uStatusPad - 2.0, uStatusOutlinePx + 2.0)
: 0.0;
float marginWorld = marginTex * (iconWorldSize / iconTexels);
// Quad world position (expanded by the outline margin, centred on the icon)
vec2 iconOrigin = vec2(iconX, iconY) - vec2(marginWorld);
float quadSize = iconWorldSize + 2.0 * marginWorld;
vec2 worldPos = iconOrigin + aPos * vec2(quadSize, quadSize);
// Camera transform
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
// vLocalUV in icon-content space: 0..1 over the icon, <0/>1 in the outline
// margin. This keeps the drain math below unchanged and samples the
// transparent padding (never a neighbour) when the quad is expanded.
vLocalUV = (aPos * quadSize - marginWorld) / iconWorldSize;
// UV from atlas grid (padded to avoid mipmap bleed)
vec4 uv = cellUV(atlasIdx);
vUV = vec2(mix(uv.x, uv.z, aPos.x), mix(uv.y, uv.w, aPos.y));
vLocalUV = aPos;
vUV = vec2(mix(uv.x, uv.z, vLocalUV.x), mix(uv.y, uv.w, vLocalUV.y));
// Alliance drain: slot 3 = alliance icon
float allianceFrac = pd7.z;
+6
View File
@@ -65,6 +65,11 @@ export const DiscordUserSchema = z.object({
});
export type DiscordUser = z.infer<typeof DiscordUserSchema>;
export const GoogleUserSchema = z.object({
email: z.string(),
});
export type GoogleUser = z.infer<typeof GoogleUserSchema>;
const SingleplayerMapAchievementSchema = z.object({
mapName: z.string(),
difficulty: z.enum(Difficulty),
@@ -73,6 +78,7 @@ const SingleplayerMapAchievementSchema = z.object({
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
google: GoogleUserSchema.optional(),
email: z.string().optional(),
}),
player: z.object({
+6
View File
@@ -108,6 +108,12 @@ export const PlayerStatsSchema = z
attacks: AtLeastOneNumberSchema.optional(),
betrayals: BigIntStringSchema.optional(),
killedAt: BigIntStringSchema.optional(),
// Tiles owned at game end, for OFM standings (set on setWinner).
finalTiles: BigIntStringSchema.optional(),
// Humans this player eliminated (victim clientID + tick), for OFM kill scoring.
kills: z
.array(z.object({ victim: z.string(), tick: BigIntStringSchema }))
.optional(),
conquests: AtLeastOneNumberSchema.optional(),
boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(),
-3
View File
@@ -243,9 +243,6 @@ export class Config {
trainStationMaxRange(): number {
return 110;
}
railroadMaxSize(): number {
return this.trainStationMaxRange();
}
tradeShipGold(dist: number, player: Player | PlayerView): Gold {
// Sigmoid: concave start, sharp S-curve middle, linear end - heavily punishes trades under range debuff.
+8
View File
@@ -69,6 +69,14 @@ export class TradeShipExecution implements Execution {
if (this.wasCaptured !== true && this.origOwner !== tradeShipOwner) {
// Store as variable in case ship is recaptured by previous owner
this.wasCaptured = true;
this.mg.displayMessage(
"events_display.trade_ship_captured",
MessageType.UNIT_DESTROYED,
this.origOwner.id(),
undefined,
{ name: tradeShipOwner.displayName() },
this.tradeShip.id(),
);
}
// If a player captures another player's port while trading we should delete
+7
View File
@@ -854,6 +854,10 @@ export class GameImpl implements Game {
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this._winner = winner;
// OFM: snapshot final tiles for standings (bots skipped in recordFinalTiles).
for (const player of this.players()) {
this.stats().recordFinalTiles(player, player.numTilesOwned());
}
this.addUpdate({
type: GameUpdateType.Win,
winner: this.makeWinner(winner),
@@ -1257,6 +1261,9 @@ export class GameImpl implements Game {
this.stats().goldWar(conqueror, conquered, goldCaptured);
}
// OFM: per-kill log for standings (humans-only filtered in recordKill).
this.stats().recordKill(conqueror, conquered, this.ticks());
this.addUpdate({
type: GameUpdateType.ConquestEvent,
conquerorId: conqueror.id(),
+15 -20
View File
@@ -251,7 +251,6 @@ export class RailNetworkImpl implements RailNetwork {
const maxRange = this.game.config().trainStationMaxRange();
const minRangeSquared = this.game.config().trainStationMinRange() ** 2;
const maxPathSize = this.game.config().railroadMaxSize();
// A City or Port only joins the rail network when a Factory is already in
// range (see CityExecution/PortExecution). A Factory always becomes a
@@ -301,13 +300,11 @@ export class RailNetworkImpl implements RailNetwork {
} else {
continue;
}
const path = this.pathService.findTilePath(tile, targetTile);
if (path.length > 0 && path.length < maxPathSize) {
paths.push(path);
if (neighborStation) {
connectedStations.push(neighborStation);
}
if (path.length === 0) continue;
paths.push(path);
if (neighborStation) {
connectedStations.push(neighborStation);
}
}
@@ -378,19 +375,17 @@ export class RailNetworkImpl implements RailNetwork {
private connect(from: TrainStation, to: TrainStation) {
const path = this.pathService.findTilePath(from.tile(), to.tile());
if (path.length > 0 && path.length < this.game.config().railroadMaxSize()) {
const railroad = new Railroad(from, to, path, this.nextId++);
this.game.addUpdate({
type: GameUpdateType.RailroadConstructionEvent,
id: railroad.id,
tiles: railroad.tiles,
});
from.addRailroad(railroad);
to.addRailroad(railroad);
this.railGrid.register(railroad);
return true;
}
return false;
if (path.length === 0) return false;
const railroad = new Railroad(from, to, path, this.nextId++);
this.game.addUpdate({
type: GameUpdateType.RailroadConstructionEvent,
id: railroad.id,
tiles: railroad.tiles,
});
from.addRailroad(railroad);
to.addRailroad(railroad);
this.railGrid.register(railroad);
return true;
}
private distanceFrom(
+6
View File
@@ -102,6 +102,12 @@ export interface Stats {
// player was killed (0 tiles)
playerKilled(player: Player, tick: number): void;
// Record tiles owned at game end (final standings).
recordFinalTiles(player: Player, tiles: number | bigint): void;
// Record that player eliminated human victim at tick (OFM kill scoring).
recordKill(player: Player, victim: Player, tick: number | bigint): void;
// Player's train arrives at any station, generating gold
trainSelfTrade(player: Player, gold: number | bigint): void;
+16
View File
@@ -286,6 +286,22 @@ export class StatsImpl implements Stats {
this._addPlayerKilled(player, tick);
}
recordFinalTiles(player: Player, tiles: BigIntLike): void {
const p = this._makePlayerStats(player);
if (p === undefined) return;
p.finalTiles = _bigint(tiles);
}
recordKill(player: Player, victim: Player, tick: BigIntLike): void {
if (victim.type() !== PlayerType.Human) return;
const victimId = victim.clientID();
if (victimId === null) return;
const p = this._makePlayerStats(player);
if (p === undefined) return;
p.kills ??= [];
p.kills.push({ victim: victimId, tick: _bigint(tick) });
}
trainSelfTrade(player: Player, gold: BigIntLike): void {
this._addGold(player, GOLD_INDEX_TRAIN_SELF, gold);
}
+25
View File
@@ -0,0 +1,25 @@
import { GoogleUser, GoogleUserSchema } from "../src/core/ApiSchemas";
describe("GoogleUserSchema", () => {
it("accepts a valid email", () => {
const result = GoogleUserSchema.safeParse({ email: "user@example.com" });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe("user@example.com");
}
});
it("rejects a missing email", () => {
expect(GoogleUserSchema.safeParse({}).success).toBe(false);
});
it("rejects a non-string email", () => {
expect(GoogleUserSchema.safeParse({ email: 123 }).success).toBe(false);
});
it("infers the GoogleUser type from the schema", () => {
// Compile-time check that GoogleUser is derived from the schema.
const user: GoogleUser = { email: "typed@example.com" };
expect(user.email).toBe("typed@example.com");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { hasLinkedAccount } from "../src/client/Api";
import { UserMeResponse } from "../src/core/ApiSchemas";
// hasLinkedAccount gates the top bar, matchmaking, single-player, ranked, and
// the skins-page "not logged in" warning. A Google login sets user.google (no
// discord/email), so it must count as logged in — otherwise the UI shows
// "Sign in" despite a valid session (regression: Google users seen as logged
// out everywhere except the account modal).
function userWith(user: Record<string, unknown>): UserMeResponse {
return { user } as unknown as UserMeResponse;
}
describe("hasLinkedAccount", () => {
it("returns false when not logged in", () => {
expect(hasLinkedAccount(false)).toBe(false);
});
it("returns false when the user has no linked identity", () => {
expect(hasLinkedAccount(userWith({}))).toBe(false);
});
it("recognizes a Discord login", () => {
expect(hasLinkedAccount(userWith({ discord: { id: "1" } }))).toBe(true);
});
it("recognizes an email (magic-link) login", () => {
expect(hasLinkedAccount(userWith({ email: "a@example.com" }))).toBe(true);
});
it("recognizes a Google login", () => {
expect(
hasLinkedAccount(userWith({ google: { email: "a@example.com" } })),
).toBe(true);
});
});
+31
View File
@@ -239,10 +239,41 @@ describe("Stats", () => {
});
});
test("recordKill", () => {
stats.recordKill(player1, player2, 30);
stats.recordKill(player1, player2, 35);
expect(stats.getPlayerStats(player1)?.kills).toStrictEqual([
{ victim: "client2", tick: 30n },
{ victim: "client2", tick: 35n },
]);
expect(stats.getPlayerStats(player2)?.kills).toBeUndefined();
});
test("stringify", () => {
stats.unitLose(player1, UnitType.Port);
expect(JSON.stringify(stats.stats(), replacer)).toBe(
'{"client1":{"units":{"port":["0","0","0","1"]}}}',
);
});
test("recordFinalTiles", () => {
stats.recordFinalTiles(player1, 42);
expect(stats.getPlayerStats(player1)?.finalTiles).toBe(42n);
});
test("setWinner snapshots finalTiles for each player", () => {
let count = 0;
game.map().forEachTile((tile) => {
if (count >= 5) return;
if (!game.map().isLand(tile)) return;
player1.conquer(tile);
count++;
});
game.setWinner(player1, game.stats().stats());
const tiles = player1.numTilesOwned();
expect(tiles).toBeGreaterThan(0);
expect(game.stats().getPlayerStats(player1)?.finalTiles).toBe(
BigInt(tiles),
);
});
});
@@ -1,5 +1,5 @@
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
import { Game, Player, Unit } from "../../../src/core/game/Game";
import { Game, MessageType, Player, Unit } from "../../../src/core/game/Game";
import { PathStatus } from "../../../src/core/pathfinding/types";
import { setup } from "../../util/Setup";
@@ -135,6 +135,26 @@ describe("TradeShipExecution", () => {
expect(tradeShip.setTargetUnit).toHaveBeenCalledWith(piratePort);
});
it("should notify the original owner when the ship is captured", () => {
tradeShip.owner = vi.fn(() => pirate);
tradeShipExecution.tick(1);
expect(game.displayMessage).toHaveBeenCalledWith(
"events_display.trade_ship_captured",
MessageType.UNIT_DESTROYED,
origOwner.id(),
undefined,
{ name: pirate.displayName() },
tradeShip.id(),
);
});
it("should only notify the original owner once across ticks", () => {
tradeShip.owner = vi.fn(() => pirate);
tradeShipExecution.tick(1);
tradeShipExecution.tick(2);
expect(game.displayMessage).toHaveBeenCalledTimes(1);
});
it("should complete trade and award gold", () => {
tradeShipExecution["pathFinder"] = {
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 32 })),
+21 -24
View File
@@ -71,7 +71,6 @@ describe("RailNetworkImpl", () => {
config: () => ({
trainStationMaxRange: () => 80,
trainStationMinRange: () => 10,
railroadMaxSize: () => 100,
}),
x: vi.fn(() => 0),
y: vi.fn(() => 0),
@@ -250,7 +249,11 @@ describe("RailNetworkImpl", () => {
pathService.findTilePath.mockReturnValue(mockPath);
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 400 },
{
unit: neighborStation.unit,
distSquared: 400,
euclideanDist: Math.sqrt(400),
},
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
@@ -269,7 +272,11 @@ describe("RailNetworkImpl", () => {
// distSquared = 50 <= minRange^2 (10^2 = 100)
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 50 },
{
unit: neighborStation.unit,
distSquared: 50,
euclideanDist: Math.sqrt(50),
},
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
@@ -283,26 +290,8 @@ describe("RailNetworkImpl", () => {
stationManager.findStation.mockReturnValue(null);
game.nearbyUnits.mockReturnValue([{ unit: { id: 1 }, distSquared: 400 }]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
expect(result).toEqual([]);
});
test("skips paths that exceed max railroad size", () => {
const tile = 42 as any;
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighborStation = createMockStation(1);
neighborStation.tile.mockReturnValue(100);
stationManager.findStation.mockReturnValue(neighborStation);
// Path length >= railroadMaxSize (100)
pathService.findTilePath.mockReturnValue(new Array(100));
game.nearbyUnits.mockReturnValue([
{ unit: neighborStation.unit, distSquared: 400 },
{ unit: { id: 1 }, distSquared: 400, euclideanDist: Math.sqrt(400) },
]);
const result = network.computeGhostRailPaths(UnitType.City, tile);
@@ -314,11 +303,19 @@ describe("RailNetworkImpl", () => {
const railGridMock = { query: vi.fn(() => new Set()) };
(network as any).railGrid = railGridMock;
const neighbors: Array<{ unit: any; distSquared: number }> = [];
const neighbors: Array<{
unit: any;
distSquared: number;
euclideanDist: number;
}> = [];
for (let i = 0; i < 7; i++) {
const station = createMockStation(i);
station.tile.mockReturnValue(100 + i);
neighbors.push({ unit: station.unit, distSquared: 400 + i });
neighbors.push({
unit: station.unit,
distSquared: 400 + i,
euclideanDist: Math.sqrt(400 + i),
});
}
stationManager.findStation.mockImplementation((unit: any) => {