mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 08:13:53 +00:00
Clan System Part 1 (#3276)
## Description: Properly split out clantags and usernames, a clantag should not be part of a username. <img width="285" height="286" alt="image" src="https://github.com/user-attachments/assets/8ac56e82-b12c-4fc0-9774-e445252a6e61" /> https://api.openfront.dev/game/ojkqZFb2 <img width="296" height="596" alt="image" src="https://github.com/user-attachments/assets/85152f80-c111-4f87-b85b-8516c9c6137b" /> https://api.openfront.dev/game/MF32BkVc requires; https://github.com/openfrontio/infra/pull/264 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
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
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function createGameRunner(
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
p.isLobbyCreator ?? false,
|
||||
p.clanTag,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -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[];
|
||||
|
||||
+31
-24
@@ -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,
|
||||
@@ -482,7 +482,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 +659,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 +757,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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user