mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 12:58:09 +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,7 +18,7 @@ export class Client {
|
||||
public readonly flares: string[] | undefined,
|
||||
public readonly ip: string,
|
||||
public username: string,
|
||||
public readonly uncensoredUsername: string,
|
||||
public clanTag: string | null,
|
||||
public ws: WebSocket,
|
||||
public readonly cosmetics: PlayerCosmetics | undefined,
|
||||
) {}
|
||||
|
||||
@@ -46,11 +46,11 @@ export class GameManager {
|
||||
persistentID: string,
|
||||
gameID: GameID,
|
||||
lastTurn: number = 0,
|
||||
newUsername?: string,
|
||||
identityUpdate?: { username: string; clanTag: string | null },
|
||||
): boolean {
|
||||
const game = this.games.get(gameID);
|
||||
if (!game) return false;
|
||||
return game.rejoinClient(ws, persistentID, lastTurn, newUsername);
|
||||
return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate);
|
||||
}
|
||||
|
||||
createGame(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
|
||||
import { formatPlayerDisplayName } from "../core/Util";
|
||||
import { GameMode } from "../core/game/Game";
|
||||
|
||||
export const PlayerInfoSchema = z.object({
|
||||
clientID: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
username: UsernameSchema.optional(),
|
||||
clanTag: ClanTagSchema,
|
||||
stats: z.unknown().optional(),
|
||||
});
|
||||
|
||||
@@ -85,7 +87,10 @@ function parseWinner(
|
||||
if (!winnerArray || winnerArray.length < 2) return undefined;
|
||||
|
||||
const idToName = new Map(
|
||||
(players ?? []).map((p) => [p.clientID, p.username]),
|
||||
(players ?? []).map((p) => [
|
||||
p.clientID,
|
||||
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
|
||||
]),
|
||||
);
|
||||
|
||||
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
|
||||
@@ -228,7 +233,9 @@ export function buildPreview(
|
||||
// Show host
|
||||
const hostClient = lobby.clients?.[0];
|
||||
if (hostClient?.username) {
|
||||
sections.push(`Host: ${hostClient.username}`);
|
||||
sections.push(
|
||||
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const gameOptions: string[] = [];
|
||||
|
||||
+11
-12
@@ -23,7 +23,7 @@ import {
|
||||
StampedIntent,
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
import { createPartialGameRecord } from "../core/Util";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
|
||||
@@ -266,15 +266,13 @@ export class GameServer {
|
||||
}
|
||||
|
||||
// Attempt to reconnect a client by persistentID. Returns true if successful.
|
||||
// Only the WebSocket is updated — username, cosmetics, etc. are preserved
|
||||
// from the original join to maintain consistency throughout the game session.
|
||||
// Exception: in the pre-game lobby, the username is updated so players can
|
||||
// rename between leaving and rejoining.
|
||||
// WebSocket is always updated. Optional identity updates are applied only
|
||||
// before the game has started.
|
||||
public rejoinClient(
|
||||
ws: WebSocket,
|
||||
persistentID: string,
|
||||
lastTurn: number = 0,
|
||||
newUsername?: string,
|
||||
identityUpdate?: { username: string; clanTag: string | null },
|
||||
): boolean {
|
||||
const clientID = this.getClientIdForPersistentId(persistentID);
|
||||
if (!clientID) return false;
|
||||
@@ -294,14 +292,13 @@ export class GameServer {
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
this.activeClients.push(client);
|
||||
if (identityUpdate && !this.hasStarted()) {
|
||||
client.username = identityUpdate.username;
|
||||
client.clanTag = identityUpdate.clanTag;
|
||||
}
|
||||
client.lastPing = Date.now();
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
|
||||
// Allow username updates in the pre-game lobby
|
||||
if (!this._hasStarted && newUsername !== undefined) {
|
||||
client.username = newUsername;
|
||||
}
|
||||
|
||||
client.ws = ws;
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
@@ -662,6 +659,7 @@ export class GameServer {
|
||||
config: this.gameConfig,
|
||||
players: this.activeClients.map((c) => ({
|
||||
username: c.username,
|
||||
clanTag: c.clanTag ?? null,
|
||||
clientID: c.clientID,
|
||||
cosmetics: c.cosmetics,
|
||||
isLobbyCreator: this.lobbyCreatorID === c.clientID,
|
||||
@@ -873,6 +871,7 @@ export class GameServer {
|
||||
gameID: this.id,
|
||||
clients: this.activeClients.map((c) => ({
|
||||
username: c.username,
|
||||
clanTag: c.clanTag ?? null,
|
||||
clientID: c.clientID,
|
||||
})),
|
||||
lobbyCreatorClientID: this.lobbyCreatorID,
|
||||
@@ -983,11 +982,11 @@ export class GameServer {
|
||||
return {
|
||||
clientID: player.clientID,
|
||||
username: player.username,
|
||||
clanTag: player.clanTag,
|
||||
persistentID:
|
||||
this.allClients.get(player.clientID)?.persistentID ?? "",
|
||||
stats,
|
||||
cosmetics: player.cosmetics,
|
||||
clanTag: getClanTag(player.username) ?? undefined,
|
||||
} satisfies PlayerRecord;
|
||||
},
|
||||
);
|
||||
|
||||
+32
-32
@@ -18,7 +18,7 @@ import {
|
||||
PlayerCosmetics,
|
||||
PlayerPattern,
|
||||
} from "../core/Schemas";
|
||||
import { getClanTagOriginalCase, simpleHash } from "../core/Util";
|
||||
import { simpleHash } from "../core/Util";
|
||||
|
||||
export const shadowNames = [
|
||||
"UnhuggedToday",
|
||||
@@ -72,7 +72,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes and censors profane usernames and clan tags.
|
||||
* Sanitizes and censors profane usernames and clan tags separately.
|
||||
* Profane username is overwritten, profane clan tag is removed.
|
||||
*
|
||||
* Removing bad clan tags won't hurt existing clans nor cause desyncs:
|
||||
@@ -80,36 +80,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
* - only each separate local player name with a profane clan tag will remain, no clan team assignment
|
||||
*
|
||||
* Examples:
|
||||
* - "GoodName" -> "GoodName"
|
||||
* - "BadName" -> "Censored"
|
||||
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
|
||||
* - "[CLaN]BadName" -> "[CLAN] Censored"
|
||||
* - "[BAD]GoodName" -> "GoodName"
|
||||
* - "[BAD]BadName" -> "Censored"
|
||||
* - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null }
|
||||
* - username="BadName", clanTag=null -> { username: "Censored", clanTag: null }
|
||||
* - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" }
|
||||
* - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null }
|
||||
* - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null }
|
||||
*/
|
||||
function censorUsernameWithMatcher(
|
||||
username: string,
|
||||
matcher: RegExpMatcher,
|
||||
): string {
|
||||
const clanTag = getClanTagOriginalCase(username);
|
||||
|
||||
const nameWithoutClan = clanTag
|
||||
? username.replace(`[${clanTag}]`, "").trim()
|
||||
function censorWithMatcher(
|
||||
username: string,
|
||||
clanTag: string | null,
|
||||
matcher: RegExpMatcher,
|
||||
): { username: string; clanTag: string | null } {
|
||||
const usernameIsProfane = matcher.hasMatch(username);
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(username) % shadowNames.length]
|
||||
: username;
|
||||
|
||||
const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false;
|
||||
const usernameIsProfane = matcher.hasMatch(nameWithoutClan);
|
||||
const censoredClanTag =
|
||||
clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null;
|
||||
|
||||
const censoredName = usernameIsProfane
|
||||
? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length]
|
||||
: nameWithoutClan;
|
||||
|
||||
// Restore clan tag only if it's clean, otherwise remove it entirely
|
||||
if (clanTag && !clanTagIsProfane) {
|
||||
return `[${clanTag.toUpperCase()}] ${censoredName}`;
|
||||
}
|
||||
|
||||
return censoredName;
|
||||
return { username: censoredName, clanTag: censoredClanTag };
|
||||
}
|
||||
|
||||
type CosmeticResult =
|
||||
@@ -118,7 +110,10 @@ type CosmeticResult =
|
||||
|
||||
export interface PrivilegeChecker {
|
||||
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult;
|
||||
censorUsername(username: string): string;
|
||||
censor(
|
||||
username: string,
|
||||
clanTag: string | null,
|
||||
): { username: string; clanTag: string | null };
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
@@ -217,8 +212,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
return { color };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
return censorUsernameWithMatcher(username, this.matcher);
|
||||
censor(
|
||||
username: string,
|
||||
clanTag: string | null,
|
||||
): { username: string; clanTag: string | null } {
|
||||
return censorWithMatcher(username, clanTag, this.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,8 +228,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
return { type: "allowed", cosmetics: {} };
|
||||
}
|
||||
|
||||
censorUsername(username: string): string {
|
||||
// Fail open: use matcher with just the built-in English profanity dataset
|
||||
return censorUsernameWithMatcher(username, defaultMatcher);
|
||||
censor(
|
||||
username: string,
|
||||
clanTag: string | null,
|
||||
): { username: string; clanTag: string | null } {
|
||||
return censorWithMatcher(username, clanTag, defaultMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-13
@@ -358,20 +358,21 @@ export async function startWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize username and clan tag before any rejoin/join handling.
|
||||
// If this connection maps to an existing lobby client, we still want
|
||||
// the latest pre-join identity to be reflected.
|
||||
const { clanTag: censoredClanTag, username: censoredUsername } =
|
||||
privilegeRefresher
|
||||
.get()
|
||||
.censor(clientMsg.username, clientMsg.clanTag ?? null);
|
||||
|
||||
// Try to reconnect an existing client (e.g., page refresh)
|
||||
// If successful, skip all authorization (but pass updated username
|
||||
// so players can rename in the pre-game lobby)
|
||||
const censoredUsername = privilegeRefresher
|
||||
.get()
|
||||
.censorUsername(clientMsg.username);
|
||||
// If successful, skip all authorization
|
||||
if (
|
||||
gm.rejoinClient(
|
||||
ws,
|
||||
persistentId,
|
||||
clientMsg.gameID,
|
||||
0,
|
||||
censoredUsername,
|
||||
)
|
||||
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
|
||||
username: censoredUsername,
|
||||
clanTag: censoredClanTag,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -463,7 +464,7 @@ export async function startWorker() {
|
||||
flares,
|
||||
ip,
|
||||
censoredUsername,
|
||||
clientMsg.username,
|
||||
censoredClanTag,
|
||||
ws,
|
||||
cosmeticResult.cosmetics,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user