mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Merge remote-tracking branch 'origin/main' into fix/impassable-minimap-priority
This commit is contained in:
@@ -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 |
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user