mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 05:56:24 +00:00
Merge branch 'main' into player-text-opacity
This commit is contained in:
+18
-5
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user