Merge branch 'main' into player-text-opacity

This commit is contained in:
bijx
2026-03-20 13:17:17 -04:00
104 changed files with 3322 additions and 1558 deletions
+18 -5
View File
@@ -1,8 +1,21 @@
import { z } from "zod";
import { base64urlToUuid } from "./Base64";
import { ClanTagSchema } from "./Schemas";
import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas";
import { Difficulty, GameMode, GameType, RankedType } from "./game/Game";
function stripClanTagFromUsername(username: string): string {
return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim();
}
// Historical leaderboard rows can include legacy usernames
// that predate current strict join-time validation rules.
const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
});
@@ -114,7 +127,7 @@ export const PlayerProfileSchema = z.object({
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
clanTag: LeaderboardClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
@@ -137,8 +150,8 @@ export type ClanLeaderboardResponse = z.infer<
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
@@ -166,8 +179,8 @@ export const RankedLeaderboardEntrySchema = z.object({
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
+14 -16
View File
@@ -5,9 +5,7 @@ import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterE
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
BuildableUnit,
Cell,
Game,
GameUpdates,
NameViewData,
@@ -52,6 +50,7 @@ export async function createGameRunner(
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
);
});
@@ -254,24 +253,23 @@ export class GameRunner {
} as PlayerBorderTiles;
}
public attackAveragePosition(
public attackClusteredPositions(
playerID: number,
attackID: string,
): Cell | null {
attackID?: string,
): { id: string; positions: { x: number; y: number }[] }[] {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
if (!player.isPlayer())
throw new Error(`player with id ${playerID} not found`);
}
const all = [...player.outgoingAttacks(), ...player.incomingAttacks()];
const attacks = attackID ? all.filter((a) => a.id() === attackID) : all;
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
return attacks.map((a) => ({
id: a.id(),
positions: a.clusteredPositions().map((tile) => ({
x: this.game.map().x(tile),
y: this.game.map().y(tile),
})),
}));
}
public bestTransportShipSpawn(
+16 -7
View File
@@ -141,9 +141,21 @@ export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
export const UsernameSchema = z
.string()
.regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u)
.min(3)
.max(27);
export const ClanTagSchema = z
.string()
.regex(/^[a-zA-Z0-9]{2,5}$/)
.nullable();
const ClientInfoSchema = z.object({
clientID: z.string(),
username: z.string(),
username: UsernameSchema,
clanTag: ClanTagSchema,
});
export const GameInfoSchema = z.object({
@@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent {
export interface ClientInfo {
clientID: ClientID;
username: string;
clanTag: string | null;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX);
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
export const UsernameSchema = z
.string()
.regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u)
.min(3)
.max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
@@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
cosmetics: PlayerCosmeticsSchema.optional(),
isLobbyCreator: z.boolean().optional(),
});
@@ -630,6 +639,7 @@ export const ClientJoinMessageSchema = z.object({
token: TokenSchema, // WARNING: PII - server extracts persistentID from this
gameID: ID,
username: UsernameSchema,
clanTag: ClanTagSchema,
// Server replaces the refs with the actual cosmetic data.
cosmetics: PlayerCosmeticRefsSchema.optional(),
turnstileToken: z.string().nullable(),
@@ -659,7 +669,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
export const PlayerRecordSchema = PlayerSchema.extend({
persistentID: PersistentIdSchema.nullable(), // WARNING: PII
clanTag: z.string().optional(),
stats: PlayerStatsSchema,
});
export type PlayerRecord = z.infer<typeof PlayerRecordSchema>;
+5 -17
View File
@@ -340,29 +340,17 @@ export function sigmoid(
return 1 / (1 + Math.exp(-decayRate * (value - midpoint)));
}
// Compute clan from name
export function getClanTag(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1].toUpperCase() : null;
}
export function getClanTagOriginalCase(name: string): string | null {
const clanTag = clanMatch(name);
return clanTag ? clanTag[1] : null;
export function formatPlayerDisplayName(
username: string,
clanTag?: string | null,
): string {
return clanTag ? `[${clanTag}] ${username}` : username;
}
const CLAN_TAG_CHARS = "a-zA-Z0-9";
const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g");
const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`);
export function sanitizeClanTag(tag: string): string {
return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase();
}
function clanMatch(name: string): RegExpMatchArray | null {
if (!name.includes("[") || !name.includes("]")) {
return null;
}
return name.match(CLAN_TAG_REGEX);
}
+6 -1
View File
@@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
}
/** SAM launcher construction duration in ticks (non-instant-build). */
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
@@ -430,7 +433,9 @@ export class DefaultConfig implements Config {
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
UnitType.SAMLauncher,
),
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
constructionDuration: this.instantBuild()
? 0
: SAM_CONSTRUCTION_TICKS,
upgradable: true,
};
break;
+1 -1
View File
@@ -90,7 +90,7 @@ export class MirvExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`,
`⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`,
MessageType.MIRV_INBOUND,
this.targetPlayer.id(),
);
+2 -2
View File
@@ -150,7 +150,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - atom bomb inbound`,
`${this.player.displayName()} - atom bomb inbound`,
MessageType.NUKE_INBOUND,
target.id(),
);
@@ -158,7 +158,7 @@ export class NukeExecution implements Execution {
this.mg.displayIncomingUnit(
this.nuke.id(),
// TODO TranslateText
`${this.player.name()} - hydrogen bomb inbound`,
`${this.player.displayName()} - hydrogen bomb inbound`,
MessageType.HYDROGEN_BOMB_INBOUND,
target.id(),
);
+23 -4
View File
@@ -97,8 +97,11 @@ export class PlayerExecution implements Execution {
}
}
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
if (this.player.lastTileChange() > this.lastCalc) {
if (
ticks - this.lastCalc > this.ticksPerClusterCalc ||
this.player.numTilesOwned() < 100
) {
if (this.player.lastTileChange() >= this.lastCalc) {
this.lastCalc = ticks;
const start = performance.now();
this.removeClusters();
@@ -157,6 +160,12 @@ export class PlayerExecution implements Execution {
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const tile of cluster) {
let hasUnownedNeighbor = false;
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
@@ -170,6 +179,12 @@ export class PlayerExecution implements Execution {
const ownerId = this.mg.ownerID(n);
if (ownerId !== this.player.smallID()) {
enemies.add(ownerId);
const px = this.mg.x(n);
const py = this.mg.y(n);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
});
if (hasUnownedNeighbor) {
@@ -182,9 +197,13 @@ export class PlayerExecution implements Execution {
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
if (inscribed(enemyBox, clusterBox)) {
const localEnemyBox = {
min: new Cell(minX, minY),
max: new Cell(maxX, maxY),
};
if (inscribed(localEnemyBox, clusterBox)) {
return enemy;
}
return false;
+79 -18
View File
@@ -1,4 +1,4 @@
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { Attack, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
@@ -97,28 +97,89 @@ export class AttackImpl implements Attack {
}
}
averagePosition(): Cell | null {
// Returns the top 2 clustered positions of the attack's border.
// If the second cluster is too small, only returns the largest one.
clusteredPositions(): TileRef[] {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
const tile = this.sourceTile();
return tile !== null ? [tile] : [];
}
return this.clusterBorderTiles(30, 2);
}
// Partitions the attack's border tiles into disconnected segments using BFS,
// then returns one representative tile per segment.
//
// Border tiles naturally fragment when fighting across non-contiguous
// territory (e.g. islands, chokepoints).
//
// Results are sorted largest-first, small clusters below minSize are
// dropped (the largest is always kept as a fallback), and the list is capped
// at maxClusters to avoid label clutter on heavily fragmented borders.
private clusterBorderTiles(minSize: number, maxClusters: number): TileRef[] {
const map = this._mg.map();
const visited = new Set<TileRef>();
const clusters: { tile: TileRef; size: number }[] = [];
for (const startTile of this._border) {
if (visited.has(startTile)) continue;
const queue: TileRef[] = [startTile];
visited.add(startTile);
let qi = 0;
let sumX = 0;
let sumY = 0;
let count = 0;
while (qi < queue.length) {
const t = queue[qi++];
sumX += map.x(t);
sumY += map.y(t);
count++;
this._mg.forEachNeighborWithDiag(t, (neighbor) => {
if (this._border.has(neighbor) && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
// The centroid (sumX/count, sumY/count) may not be a real border tile,
// so we pick whichever tile in the cluster is closest to it. This ensures
// the representative always sits on an actual front-line tile.
const cx = sumX / count;
const cy = sumY / count;
let best = queue[0];
let bestDist = Infinity;
for (const t of queue) {
const dx = map.x(t) - cx;
const dy = map.y(t) - cy;
const dist = dx * dx + dy * dy;
if (dist < bestDist) {
bestDist = dist;
best = t;
}
}
clusters.push({ tile: best, size: count });
}
let averageX = 0;
let averageY = 0;
clusters.sort((a, b) => b.size - a.size);
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
switch (clusters.length) {
case 0:
return [];
case 1:
// If there's only one cluster, return it even if it's smaller than minSize.
return [clusters[0].tile];
default: {
const significant = clusters.filter((c) => c.size >= minSize);
if (significant.length === 0) {
// Always keep at least the largest cluster even if it falls below minSize.
return [clusters[0].tile];
}
return significant.slice(0, maxClusters).map((c) => c.tile);
}
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
}
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { Config } from "../configuration/Config";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { formatPlayerDisplayName } from "../Util";
import { GameMap, TileRef } from "./GameMap";
import {
GameUpdate,
@@ -470,7 +470,7 @@ export interface Attack {
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
clusteredPositions(): TileRef[];
}
export interface AllianceRequest {
@@ -503,7 +503,7 @@ export interface MutableAlliance extends Alliance {
}
export class PlayerInfo {
public readonly clan: string | null;
public readonly displayName: string;
constructor(
public readonly name: string,
@@ -513,8 +513,9 @@ export class PlayerInfo {
// TODO: make player id the small id
public readonly id: PlayerID,
public readonly isLobbyCreator: boolean = false,
public readonly clanTag: string | null = null,
) {
this.clan = getClanTag(name);
this.displayName = formatPlayerDisplayName(this.name, this.clanTag);
}
}
@@ -706,7 +707,6 @@ export interface Player {
// Either allied or on same team.
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
team(): Team | null;
clan(): string | null;
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
+35 -29
View File
@@ -4,7 +4,7 @@ import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName } from "../Util";
import { createRandomName, formatPlayerDisplayName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
@@ -453,11 +453,10 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
@@ -482,7 +481,7 @@ export class PlayerView {
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
: this.data.displayName;
}
clientID(): ClientID | null {
@@ -659,21 +658,15 @@ export class GameView implements GameMap {
private _mapData: TerrainMapData,
private _myClientID: ClientID | undefined,
private _myUsername: string,
private _myClanTag: string | null,
private _gameID: GameID,
private humans: Player[],
humans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
// Replace the local player's username with their own stored username.
// This way the user does not know they are being censored.
for (const h of this.humans) {
if (h.clientID === this._myClientID) {
h.username = this._myUsername;
}
}
this._cosmetics = new Map(
this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
);
for (const nation of this._mapData.nations) {
// Nations don't have client ids, so we use their name as the key instead.
@@ -763,25 +756,38 @@ export class GameView implements GameMap {
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
gu.updates[GameUpdateType.Player].forEach((pu) => {
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
const player = this._players.get(pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
player.nameData = gu.playerNameViewData[pu.id];
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
}
} else {
this._players.set(
pu.id,
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
);
this._players.set(pu.id, player);
}
});
+6 -15
View File
@@ -84,9 +84,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = new Set();
private _name: string;
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
@@ -115,10 +112,8 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = mg.config().startingGold(playerInfo);
this._displayName = this._name;
this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id));
}
@@ -193,10 +188,10 @@ export class PlayerImpl implements Player {
}
name(): string {
return this._name;
return this.playerInfo.name;
}
displayName(): string {
return this._displayName;
return this.playerInfo.displayName;
}
clientID(): ClientID | null {
@@ -211,10 +206,6 @@ export class PlayerImpl implements Player {
return this.playerInfo.playerType;
}
clan(): string | null {
return this.playerInfo.clan;
}
units(...types: UnitType[]): Unit[] {
const len = types.length;
if (len === 0) {
@@ -760,14 +751,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_TROOPS_TO_PLAYER,
this.id(),
undefined,
{ troops: renderTroops(troops), name: recipient.name() },
{ troops: renderTroops(troops), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_troops_from_player",
MessageType.RECEIVED_TROOPS_FROM_PLAYER,
recipient.id(),
undefined,
{ troops: renderTroops(troops), name: this.name() },
{ troops: renderTroops(troops), name: this.displayName() },
);
return true;
}
@@ -784,14 +775,14 @@ export class PlayerImpl implements Player {
MessageType.SENT_GOLD_TO_PLAYER,
this.id(),
undefined,
{ gold: renderNumber(gold), name: recipient.name() },
{ gold: renderNumber(gold), name: recipient.displayName() },
);
this.mg.displayMessage(
"events_display.received_gold_from_player",
MessageType.RECEIVED_GOLD_FROM_PLAYER,
recipient.id(),
gold,
{ gold: renderNumber(gold), name: this.name() },
{ gold: renderNumber(gold), name: this.displayName() },
);
return true;
}
+8 -8
View File
@@ -16,24 +16,24 @@ export function assignTeams(
// Sort players into clan groups or no-clan list
for (const player of players) {
if (player.clan) {
if (!clanGroups.has(player.clan)) {
clanGroups.set(player.clan, []);
const clanTag = player.clanTag;
if (clanTag) {
if (!clanGroups.has(clanTag)) {
clanGroups.set(clanTag, []);
}
clanGroups.get(player.clan)!.push(player);
clanGroups.get(clanTag)!.push(player);
} else {
noClanPlayers.push(player);
}
}
// Sort clans by size (largest first)
const sortedClans = Array.from(clanGroups.entries()).sort(
(a, b) => b[1].length - a[1].length,
const sortedClanPlayers = Array.from(clanGroups.values()).sort(
(a, b) => b.length - a.length,
);
// First, assign clan players
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, clanPlayers] of sortedClans) {
for (const clanPlayers of sortedClanPlayers) {
// Try to keep the clan together on the team with fewer players
let team: Team | null = null;
let teamSize = 0;
+8
View File
@@ -89,6 +89,14 @@ export class UserSettings {
return this.get("settings.territoryPatterns", true);
}
attackingTroopsOverlay() {
return this.get("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.set("settings.attackingTroopsOverlay", !this.attackingTroopsOverlay());
}
cursorCostLabel() {
const legacy = this.get("settings.ghostPricePill", true);
return this.get("settings.cursorCostLabel", legacy);
+28 -1
View File
@@ -1,8 +1,10 @@
import { translateText } from "../../client/Utils";
import { UsernameSchema } from "../Schemas";
import { ClanTagSchema, UsernameSchema } from "../Schemas";
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 27;
export const MIN_CLAN_TAG_LENGTH = 2;
export const MAX_CLAN_TAG_LENGTH = 5;
export function validateUsername(username: string): {
isValid: boolean;
@@ -44,3 +46,28 @@ export function validateUsername(username: string): {
// All checks passed
return { isValid: true };
}
export function validateClanTag(clanTag: string): {
isValid: boolean;
error?: string;
} {
if (clanTag.length === 0) {
return { isValid: true };
}
if (clanTag.length < MIN_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
if (clanTag.length > MAX_CLAN_TAG_LENGTH) {
return { isValid: false, error: translateText("username.tag_too_short") };
}
const parsed = ClanTagSchema.safeParse(clanTag);
if (!parsed.success) {
return {
isValid: false,
error: translateText("username.tag_invalid_chars"),
};
}
return { isValid: true };
}
+12 -9
View File
@@ -3,7 +3,7 @@ import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
AttackClusteredPositionsResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -243,25 +243,28 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_average_position":
case "attack_clustered_positions":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const averagePosition = (await gameRunner).attackAveragePosition(
const attacks = (await gameRunner).attackClusteredPositions(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_average_position_result",
type: "attack_clustered_positions_result",
id: message.id,
x: averagePosition ? averagePosition.x : null,
y: averagePosition ? averagePosition.y : null,
} as AttackAveragePositionResultMessage);
attacks,
} as AttackClusteredPositionsResultMessage);
} catch (error) {
console.error("Failed to get attack average position:", error);
throw error;
console.error("Failed to get attack front line centers:", error);
sendMessage({
type: "attack_clustered_positions_result",
id: message.id,
attacks: [],
} as AttackClusteredPositionsResultMessage);
}
break;
case "transport_ship_spawn":
+25 -16
View File
@@ -231,10 +231,10 @@ export class WorkerClient {
});
}
attackAveragePosition(
attackClusteredPositions(
playerID: number,
attackID: string,
): Promise<Cell | null> {
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
@@ -243,25 +243,34 @@ export class WorkerClient {
const messageId = generateID();
const timeout = setTimeout(() => {
this.messageHandlers.delete(messageId);
reject(new Error("attack_clustered_positions request timed out"));
}, 5000);
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "attack_average_position_result" &&
message.x !== undefined &&
message.y !== undefined
) {
if (message.x === null || message.y === null) {
resolve(null);
} else {
resolve(new Cell(message.x, message.y));
}
clearTimeout(timeout);
if (message.type !== "attack_clustered_positions_result") {
reject(
new Error(
`Unexpected message type for attackClusteredPositions: ${message.type}`,
),
);
return;
}
resolve(
message.attacks.map((a) => ({
id: a.id,
positions: a.positions.map((c) => new Cell(c.x, c.y)),
})),
);
});
this.worker.postMessage({
type: "attack_average_position",
type: "attack_clustered_positions",
id: messageId,
playerID: playerID,
attackID: attackID,
playerID,
attackID,
});
});
}
+11 -11
View File
@@ -24,8 +24,8 @@ export type WorkerMessageType =
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -108,16 +108,16 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
type: "attack_average_position";
export interface AttackClusteredPositionsMessage extends BaseWorkerMessage {
type: "attack_clustered_positions";
playerID: number;
attackID: string;
attackID?: string;
}
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
type: "attack_average_position_result";
x: number | null;
y: number | null;
export interface AttackClusteredPositionsResultMessage
extends BaseWorkerMessage {
type: "attack_clustered_positions_result";
attacks: { id: string; positions: { x: number; y: number }[] }[];
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
@@ -139,7 +139,7 @@ export type MainThreadMessage =
| PlayerBuildablesMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| AttackClusteredPositionsMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -151,5 +151,5 @@ export type WorkerMessage =
| PlayerBuildablesResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;