feat: territory png based skins (#4006)

## Description:

Add image-based territory skins as a new cosmetic type, rendered
alongside the existing 1-bit patterns. Skins render a single PNG
centered on each player's spawn tile — opaque pixels show the skin
(multiplied by team color in team games, raw colors in FFA), transparent
pixels and tiles outside the image bounds fall through to the regular
player palette color.

**Cosmetic plumbing**
- `SkinSchema` in `CosmeticSchemas.ts`, optional `skins` map on
`CosmeticsSchema`
- `PlayerSkin`, `PlayerCosmetics.skin`, `PlayerCosmeticRefs.skinName` in
`Schemas.ts`
- Server-side resolution: `PrivilegeCheckerImpl.isSkinAllowed` (gated by
`skin:*` / `skin:<name>` flares)
- Client persistence: stored under `PATTERN_KEY` (`pattern:` and `skin:`
share one slot — they're mutually exclusive)
- `getPlayerCosmeticsRefs` only emits a `skinName` when cosmetics are
loaded, the skin exists in the catalog, and the user has the right flare
— otherwise drops the ref and clears storage

**Renderer**
- `SkinAtlasArray` — fixed `TEXTURE_2D_ARRAY`, 1024×1024 per layer,
exact layer count allocated once at game start from the locked-in player
set. No resize, no callbacks, no retained `HTMLImageElement`. Zero GPU
cost when no players have skins (1×1 placeholder).
- `skinLayerTex` (R8UI 4096×1) — per-player `layer + 1` (`0` = no skin)
- `skinAnchorTex` (RG16UI 4096×1) — per-player spawn tile, so the PNG
center anchors at each player's spawn (re-uploads when the player
re-picks during spawn phase)
- `WebGLFrameBuilder.syncPlayers` collects unique skin URLs on first
sync and calls `view.initSkinAtlas(urls)` once; `clearCaches()` resets
so seek/replay re-initializes
- `territory.frag.glsl`: skin branch is mutually exclusive with
patterns; bounds-checks UVs against `[0, 1]` so the image is a single
stamp, not tiled; alpha-blends against the player palette color so
transparent pixels and out-of-bounds tiles render as the regular player
color

**Hover highlight (global UX change, not skin-scoped)**
- Existing hover highlight changed from "brighten toward white" to
"saturation boost." Applies to all players regardless of
skin/pattern/flat-color — looks better across the board.

**UI**
- `CosmeticButton` renders skins as a single `<img>` (object-contain)
- `TerritoryPatternsModal` merges patterns + skins into one grid; single
"default" tile clears both
- Selecting a pattern clears the skin and vice versa (mutually
exclusive)
- `Store` pattern tab includes skin entries (purchasable, not-yet-owned)
- `PatternInput` lobby button previews the active skin when one is set

**Memory**
- 0 skin players → ~4 bytes (placeholder) + ~40 KB fixed per-player
tables
- 1 skin player → ~5.6 MB GPU
- 5 skin players → ~28 MB GPU
- 10 skin players → ~56 MB GPU

**Tests**
- `tests/Privilege.test.ts`: 13 new cases covering `isSkinAllowed`
(wildcard, exact-match, missing flare, missing skin, forged refs) and
`isAllowed` integration (allowed/forbidden paths, short-circuit when
invalid skin is paired with valid other cosmetics)

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] 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
- [ ] 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:

evan
This commit is contained in:
Evan
2026-05-27 21:00:07 +01:00
committed by GitHub
parent ddf63066fa
commit aa3959bffe
16 changed files with 783 additions and 36 deletions
+89 -3
View File
@@ -8,6 +8,7 @@ import {
Pack,
Pattern,
Product,
Skin,
Subscription,
} from "../core/CosmeticSchemas";
import {
@@ -30,6 +31,29 @@ export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
let __cosmeticsCache: Cosmetics | null = null;
/**
* Synchronous accessor for the most recently resolved cosmetics. Returns null
* before the first successful `fetchCosmetics()` call. Useful when a code path
* cannot await (e.g. WebGL per-frame sync).
*/
export function getCachedCosmetics(): Cosmetics | null {
return __cosmeticsCache;
}
/**
* Resolve the local player's selected skin from UserSettings + cached
* cosmetics. Returns null if no skin is selected, cosmetics aren't loaded,
* or the saved skin no longer exists.
*/
export function getLocalSelectedSkin(): { name: string; url: string } | null {
const skinName = new UserSettings().getSelectedSkinName();
if (!skinName) return null;
const skin = __cosmeticsCache?.skins?.[skinName];
if (!skin) return null;
return { name: skin.name, url: skin.url };
}
export type PaymentMethod = "dollar" | "hard" | "soft";
@@ -175,6 +199,7 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
.join(",");
__cosmeticsHash = simpleHash(hashInput);
__cosmeticsCache = result.data;
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
@@ -309,12 +334,31 @@ export function flagRelationship(
);
}
export function skinRelationship(
skin: Skin,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "skin:*",
requiredFlare: `skin:${skin.name}`,
product: skin.product,
priceSoft: skin.priceSoft,
priceHard: skin.priceHard,
affiliateCode,
itemAffiliateCode: skin.affiliateCode ?? null,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
type: "pattern" | "flag" | "pack" | "subscription";
cosmetic: Pattern | Flag | Pack | Subscription | null;
type: "pattern" | "skin" | "flag" | "pack" | "subscription";
cosmetic: Pattern | Skin | Flag | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "skin:mountain" */
key: string;
};
@@ -377,6 +421,19 @@ export function resolveCosmetics(
});
}
// Skins (image-based territory cosmetics). No separate "default" entry —
// the pattern default doubles as "no skin": selecting it clears both.
for (const [skinKey, skin] of Object.entries(cosmetics.skins ?? {})) {
const rel = skinRelationship(skin, userMeResponse, affiliateCode);
result.push({
type: "skin",
cosmetic: skin,
colorPalette: null,
relationship: rel,
key: `skin:${skinKey}`,
});
}
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
@@ -479,10 +536,32 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
userSettings.clearFlag();
}
let skinName = userSettings.getSelectedSkinName() ?? undefined;
if (skinName) {
const skin = cosmetics?.skins?.[skinName];
if (cosmetics && !skin) {
// Cosmetics loaded but the saved skin no longer exists.
skinName = undefined;
} else if (skin) {
const userMe = await getUserMe();
if (userMe) {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("skin:*");
if (!hasWildcard && !flares.includes(`skin:${skin.name}`)) {
skinName = undefined;
}
}
}
if (skinName === undefined) {
userSettings.setSelectedPatternName(undefined);
}
}
return {
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
skinName,
};
}
@@ -520,6 +599,13 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
}
}
if (refs.skinName && cosmetics) {
const skin = cosmetics.skins?.[refs.skinName];
if (skin) {
result.skin = { name: refs.skinName, url: skin.url };
}
}
return result;
}
+19 -5
View File
@@ -4,7 +4,7 @@ import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
} from "../core/game/UserSettings";
import { PlayerPattern } from "../core/Schemas";
import { PlayerPattern, PlayerSkin } from "../core/Schemas";
import { renderPatternPreview } from "./components/PatternPreview";
import { getPlayerCosmetics } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -13,6 +13,7 @@ import { translateText } from "./Utils";
@customElement("pattern-input")
export class PatternInput extends LitElement {
@state() public pattern: PlayerPattern | null = null;
@state() public skin: PlayerSkin | null = null;
@state() public selectedColor: string | null = null;
@state() private isLoading: boolean = true;
@@ -24,10 +25,11 @@ export class PatternInput extends LitElement {
private _abortController: AbortController | null = null;
private _onPatternSelected = async () => {
private _onCosmeticSelected = async () => {
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
this.skin = cosmetics.skin ?? null;
};
private onInputClick(e: Event) {
@@ -48,11 +50,12 @@ export class PatternInput extends LitElement {
const cosmetics = await getPlayerCosmetics();
this.selectedColor = cosmetics.color?.color ?? null;
this.pattern = cosmetics.pattern ?? null;
this.skin = cosmetics.skin ?? null;
if (!this.isConnected) return;
this.isLoading = false;
window.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
this._onPatternSelected,
this._onCosmeticSelected,
{
signal: this._abortController.signal,
},
@@ -72,7 +75,9 @@ export class PatternInput extends LitElement {
}
private getIsDefaultPattern(): boolean {
return this.pattern === null && this.selectedColor === null;
return (
this.pattern === null && this.skin === null && this.selectedColor === null
);
}
private shouldShowSelectLabel(): boolean {
@@ -121,8 +126,17 @@ export class PatternInput extends LitElement {
`;
}
// Skin takes precedence over pattern (mutually exclusive in-game too).
let previewContent;
if (this.pattern) {
if (this.skin) {
previewContent = html`<img
src=${this.skin.url}
alt=${this.skin.name}
class="pointer-events-none"
draggable="false"
loading="lazy"
/>`;
} else if (this.pattern) {
previewContent = renderPatternPreview(this.pattern, 128, 128);
} else {
previewContent = renderPatternPreview(null, 128, 128);
+5 -2
View File
@@ -71,7 +71,7 @@ export class StoreModal extends BaseModal {
this.affiliateCode,
).filter(
(r) =>
r.type === "pattern" &&
(r.type === "pattern" || r.type === "skin") &&
r.relationship !== "blocked" &&
r.relationship !== "owned",
);
@@ -237,7 +237,10 @@ export class StoreModal extends BaseModal {
this.affiliateCode,
).filter(
(r) =>
(r.type === "pattern" || r.type === "flag" || r.type === "pack") &&
(r.type === "pattern" ||
r.type === "skin" ||
r.type === "flag" ||
r.type === "pack") &&
r.relationship === "purchasable",
);
+36 -11
View File
@@ -2,7 +2,7 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { Cosmetics, Skin } from "../core/CosmeticSchemas";
import {
PATTERN_KEY,
USER_SETTINGS_CHANGED_EVENT,
@@ -29,6 +29,7 @@ export class TerritoryPatternsModal extends BaseModal {
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private selectedSkinName: string | null = null;
@state() private search = "";
private cosmetics: Cosmetics | null = null;
@@ -66,6 +67,7 @@ export class TerritoryPatternsModal extends BaseModal {
const cosmetics = await getPlayerCosmetics();
this.selectedPattern = cosmetics.pattern ?? null;
this.selectedColor = cosmetics.color?.color ?? null;
this.selectedSkinName = cosmetics.skin?.name ?? null;
}
async onUserMe(userMeResponse: UserMeResponse | false) {
@@ -84,14 +86,15 @@ export class TerritoryPatternsModal extends BaseModal {
this.search = (event.target as HTMLInputElement).value;
}
private renderPatternGrid(): TemplateResult {
/** Combined patterns + skins grid. To the user they're the same: "skins". */
private renderSkinGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
null,
).filter(
(r) =>
r.type === "pattern" &&
(r.type === "pattern" || r.type === "skin") &&
r.relationship === "owned" &&
(r.cosmetic === null
? !this.search
@@ -105,11 +108,19 @@ export class TerritoryPatternsModal extends BaseModal {
>
${items.map((r) => {
const isSelected =
(r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null));
r.type === "pattern"
? (r.cosmetic === null && this.selectedPattern === null) ||
(r.cosmetic !== null &&
this.selectedPattern?.name === r.cosmetic.name &&
(this.selectedPattern?.colorPalette?.name ?? null) ===
(r.colorPalette?.name ?? null))
: (() => {
const skinName = (r.cosmetic as Skin | null)?.name ?? null;
return (
(skinName === null && this.selectedSkinName === null) ||
(skinName !== null && this.selectedSkinName === skinName)
);
})();
return html`
<cosmetic-button
.resolved=${r}
@@ -165,7 +176,7 @@ export class TerritoryPatternsModal extends BaseModal {
}}
></o-button>
</div>
<div class="px-3 pb-3">${this.renderPatternGrid()}</div>
<div class="px-3 pb-3">${this.renderSkinGrid()}</div>
`;
}
@@ -178,8 +189,21 @@ export class TerritoryPatternsModal extends BaseModal {
}
private selectCosmetic(resolved: ResolvedCosmetic) {
if (resolved.type !== "pattern") return;
this.selectPattern(resolvedToPlayerPattern(resolved));
if (resolved.type === "pattern") {
this.selectPattern(resolvedToPlayerPattern(resolved));
} else if (resolved.type === "skin") {
this.selectSkin((resolved.cosmetic as Skin | null)?.name ?? null);
}
}
private selectSkin(skinName: string | null) {
this.userSettings.setSelectedPatternName(
skinName === null ? undefined : `skin:${skinName}`,
);
this.selectedSkinName = skinName;
this.selectedPattern = null;
this.refresh();
this.close();
}
private selectPattern(pattern: PlayerPattern | null) {
@@ -194,6 +218,7 @@ export class TerritoryPatternsModal extends BaseModal {
this.userSettings.setSelectedPatternName(`pattern:${name}`);
}
this.selectedPattern = pattern;
this.selectedSkinName = null;
this.refresh();
this.showSkinSelectedPopup();
this.close();
+47
View File
@@ -30,6 +30,14 @@ export class WebGLFrameBuilder {
private readonly patternData: Uint8Array;
private readonly knownSmallIDs = new Set<number>();
/**
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
* spawn during the spawn phase, so this tracks the latest value rather than
* just first-seen — re-uploads only when the tile actually changes.
*/
private readonly lastSpawnTile = new Map<number, number>();
/** Skin atlas allocated once on first syncPlayers — player set is locked at game start. */
private skinsInitialized = false;
// The renderer needs to know which player is "me" so affiliation tint,
// unit colors, and SAM-radius perspective work. Push it once the local
// player's update arrives (may take several ticks during join).
@@ -46,17 +54,42 @@ export class WebGLFrameBuilder {
/** Drop internal caches to force a full re-upload of state on the next update(). */
clearCaches(): void {
this.knownSmallIDs.clear();
this.lastSpawnTile.clear();
this.localPlayerSmallID = 0;
this.skinsInitialized = false;
}
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
this.syncTerrainDeltas(gameView);
uploadFrameData(this.view, gameView.frameData());
}
/**
* Push each player's current spawn tile to the renderer as the skin anchor
* (image center lines up with this tile). Players re-pick spawn during the
* spawn phase, so we re-upload whenever the tile changes, not just on first
* sighting. Once spawn phase ends, spawnTile is locked and this becomes a
* no-op via the cache check.
*/
private syncPlayerSpawns(gameView: GameView): void {
for (const p of gameView.players()) {
const smallID = p.smallID();
const spawnTile = p.state.spawnTile;
if (spawnTile === undefined) continue;
if (this.lastSpawnTile.get(smallID) === spawnTile) continue;
this.lastSpawnTile.set(smallID, spawnTile);
this.view.setPlayerSpawn(
smallID,
gameView.x(spawnTile),
gameView.y(spawnTile),
);
}
}
/**
* Water-nuke conversions (land → water) mutate the underlying terrain.
* Forward this tick's terrain-changed refs to the renderer so it can
@@ -126,6 +159,15 @@ export class WebGLFrameBuilder {
}
private syncPlayers(gameView: GameView): void {
if (!this.skinsInitialized) {
this.skinsInitialized = true;
const urls = new Set<string>();
for (const p of gameView.players()) {
const url = p.cosmetics.skin?.url;
if (url) urls.add(assetUrl(url));
}
this.view.initSkinAtlas([...urls]);
}
const newPlayers: PlayerStatic[] = [];
for (const p of gameView.players()) {
const smallID = p.smallID();
@@ -140,6 +182,11 @@ export class WebGLFrameBuilder {
const flagRef = p.cosmetics.flag;
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
const skin = p.cosmetics.skin;
if (skin?.url) {
this.view.setPlayerSkin(smallID, assetUrl(skin.url));
}
const pattern = p.cosmetics.pattern;
if (pattern && pattern.patternData) {
try {
+32 -5
View File
@@ -1,6 +1,12 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pack, Pattern, Subscription } from "../../core/CosmeticSchemas";
import {
Flag,
Pack,
Pattern,
Skin,
Subscription,
} from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import {
PaymentMethod,
@@ -46,7 +52,7 @@ export class CosmeticButton extends LitElement {
if (c === null) {
return translateText("territory_patterns.pattern.default");
}
if (this.resolved.type === "pattern") {
if (this.resolved.type === "pattern" || this.resolved.type === "skin") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
if (this.resolved.type === "pack") {
@@ -72,6 +78,25 @@ export class CosmeticButton extends LitElement {
return renderPatternPreview(playerPattern, 150, 150);
}
if (this.resolved.type === "skin") {
const c = this.resolved.cosmetic as Skin | null;
if (c === null) {
// "Default" tile — visually consistent with pattern's default tile.
return html`<div
class="w-full h-full flex items-center justify-center text-white/40 text-xs uppercase"
>
${translateText("territory_patterns.pattern.default")}
</div>`;
}
return html`<img
src=${c.url}
alt=${c.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
/>`;
}
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
@@ -149,13 +174,14 @@ export class CosmeticButton extends LitElement {
render() {
const c = this.resolved.cosmetic;
const priced = c as Pattern | Flag | Pack | null;
const priced = c as Pattern | Skin | Flag | Pack | null;
const priceHard = priced?.priceHard;
const priceSoft = priced?.priceSoft;
const artist = priced?.artist;
const isPurchasable = this.resolved.relationship === "purchasable";
const type = this.resolved.type;
const isPattern = type === "pattern";
const isSkin = type === "skin";
const isOwnedSubscription =
type === "subscription" && this.resolved.relationship === "owned";
const dollarLabelKey =
@@ -167,7 +193,7 @@ export class CosmeticButton extends LitElement {
const priceSuffix =
type === "subscription" ? translateText("store.price_per_month") : "";
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
const crazygamesClass = isPattern ? "no-crazygames " : "";
const crazygamesClass = isPattern || isSkin ? "no-crazygames " : "";
return html`
<cosmetic-container
@@ -191,7 +217,8 @@ export class CosmeticButton extends LitElement {
.name=${this.displayName}
>
<button
class="group relative flex flex-col items-center w-full ${isPattern
class="group relative flex flex-col items-center w-full ${isPattern ||
isSkin
? "gap-2"
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${() => this.handleClick()}
+9
View File
@@ -264,6 +264,15 @@ export class GameView {
): void {
this.renderer?.addPlayers(players, paletteData, patternMeta, patternData);
}
setPlayerSkin(smallID: number, url: string): void {
this.renderer?.setPlayerSkin(smallID, url);
}
initSkinAtlas(urls: readonly string[]): void {
this.renderer?.initSkinAtlas(urls);
}
setPlayerSpawn(smallID: number, x: number, y: number): void {
this.renderer?.setPlayerSpawn(smallID, x, y);
}
uploadRailroadState(data: Uint8Array): void {
this.renderer?.uploadRailroadState(data);
}
+115 -1
View File
@@ -47,6 +47,7 @@ import { RailroadPass } from "./passes/RailroadPass";
import { RangeCirclePass } from "./passes/RangeCirclePass";
import { SAMRadiusPass } from "./passes/SamRadiusPass";
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
import { StructureLevelPass } from "./passes/StructureLevelPass";
@@ -130,6 +131,13 @@ export class GPURenderer {
private paletteData: Float32Array;
private patternMetaTex: WebGLTexture;
private patternDataTex: WebGLTexture;
private skinAtlas: SkinAtlasArray;
private skinLayerTex: WebGLTexture;
/** CPU-side mirror of skinLayerTex (0 = no skin, otherwise layer + 1). */
private skinLayerCpu: Uint8Array;
/** Per-player anchor (x,y) for skin sampling. (0,0) = world-origin anchor. */
private skinAnchorTex: WebGLTexture;
private skinAnchorCpu: Uint16Array;
private canvas: HTMLCanvasElement;
private settings: RenderSettings;
private sceneTarget: RenderTarget;
@@ -242,6 +250,33 @@ export class GPURenderer {
filter: gl.NEAREST,
});
// --- Skin atlas (TEXTURE_2D_ARRAY of PNG layers) + per-player layer map ---
this.skinLayerCpu = new Uint8Array(palW);
this.skinLayerTex = createTexture2D(gl, {
width: palW,
height: 1,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.skinLayerCpu,
filter: gl.NEAREST,
});
// Per-player skin anchor: RG16UI, 2× uint16 per player → 4 bytes each.
// (0,0) sentinel means "no anchor" — shader uses world origin.
this.skinAnchorCpu = new Uint16Array(palW * 2);
this.skinAnchorTex = createTexture2D(gl, {
width: palW,
height: 1,
internalFormat: gl.RG16UI,
format: gl.RG_INTEGER,
type: gl.UNSIGNED_SHORT,
data: this.skinAnchorCpu,
filter: gl.NEAREST,
});
// Construct with no URLs — the real atlas is built once initSkinAtlas() is
// called with the locked-in player skin URLs at game start.
this.skinAtlas = new SkinAtlasArray(gl, [], () => {});
// --- Border compute (creates its own borderTex) ---
// Need a temporary tileTex reference for border compute — we'll create
// GPUResources first, then wire everything.
@@ -282,7 +317,7 @@ export class GPURenderer {
this.settings,
);
// --- Territory (needs tileTex, paletteTex, patternTexs) ---
// --- Territory (needs tileTex, paletteTex, patternTexs, skinTexs) ---
this.territoryPass = new TerritoryPass(
gl,
mapW,
@@ -291,6 +326,9 @@ export class GPURenderer {
this.paletteTex,
this.patternMetaTex,
this.patternDataTex,
this.skinAtlas.texture,
this.skinLayerTex,
this.skinAnchorTex,
this.settings,
);
@@ -454,6 +492,9 @@ export class GPURenderer {
for (const p of header.players) {
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
}
// Team mode = any player has a team. Drives skin tint behavior:
// FFA shows raw skin colors; teams multiply skin by team primary color.
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
this.startLoop();
}
@@ -644,6 +685,76 @@ export class GPURenderer {
for (const p of players) {
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
}
// Renderer was constructed with players: [] (real list arrives via this
// method), so team mode must be re-evaluated whenever new players arrive
// — otherwise team games never enable the skin-tint branch.
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
}
/**
* Anchor a player's skin sampling at world coords (x, y). The center of the
* skin image lines up with this tile. Default (0,0) anchors at world origin.
*/
setPlayerSpawn(smallID: number, x: number, y: number): void {
const off = smallID * 2;
this.skinAnchorCpu[off] = x;
this.skinAnchorCpu[off + 1] = y;
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
1,
gl.RG_INTEGER,
gl.UNSIGNED_SHORT,
this.skinAnchorCpu,
);
}
/**
* Allocate the skin atlas to exactly `urls.length` layers. The player set is
* locked at game start so this is called once with the complete URL list;
* URLs not in this set will be ignored by `setPlayerSkin`.
*
* Layers are zero-initialized (browsers do this for security regardless of
* the GL spec's "undefined" wording), so players whose images haven't
* decoded yet render with alpha=0 → falls through to base player color.
*/
initSkinAtlas(urls: readonly string[]): void {
this.skinAtlas.dispose();
this.skinAtlas = new SkinAtlasArray(this.gl, urls, () => {});
this.territoryPass.setSkinAtlas(this.skinAtlas.texture);
}
/**
* Map a player to a pre-registered skin layer. URLs not registered via
* `initSkinAtlas` are silently dropped. If the image is still decoding the
* layer renders transparent (zero-init) until decode completes.
*/
setPlayerSkin(smallID: number, url: string): void {
const layer = this.skinAtlas.getLayer(url);
if (layer < 0) return;
this.skinLayerCpu[smallID] = layer + 1;
this.uploadSkinLayerTex();
}
private uploadSkinLayerTex(): void {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
1,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.skinLayerCpu,
);
}
uploadRailroadState(data: Uint8Array): void {
@@ -1235,6 +1346,9 @@ export class GPURenderer {
this.gl.deleteTexture(this.paletteTex);
this.gl.deleteTexture(this.patternMetaTex);
this.gl.deleteTexture(this.patternDataTex);
this.gl.deleteTexture(this.skinLayerTex);
this.gl.deleteTexture(this.skinAnchorTex);
this.skinAtlas.dispose();
this.gl.deleteFramebuffer(this.sceneTarget.fbo);
this.gl.deleteTexture(this.sceneTarget.tex);
this.lastUnits = new Map();
@@ -0,0 +1,144 @@
/**
* SkinAtlasArray — fixed-size TEXTURE_2D_ARRAY of territory skin PNGs.
*
* The player set is locked at game start, so the unique skin URL count is
* known up front. The atlas allocates exactly that many `SKIN_DIM × SKIN_DIM`
* layers once and never resizes. Each layer is filled in asynchronously as
* its PNG decodes; `onLayerReady(url, layer)` fires per layer so callers can
* patch their per-player layer table.
*
* If `urls` is empty the atlas binds a 1×1×1 placeholder so the shader's
* `uSkinAtlas` sampler still has something to read from (the shader's
* skinLayer table will be all zeros, so it never actually samples).
*
* Sampler wrap is CLAMP_TO_EDGE — the shader treats UVs outside [0,1] as
* transparent so the image appears as a single stamp centered at the anchor.
*/
/** Per-side dimension for every atlas layer. Larger images are downscaled. */
export const SKIN_DIM = 1024;
export class SkinAtlasArray {
private gl: WebGL2RenderingContext;
private tex: WebGLTexture;
/** url → layer index. Layers are assigned in iteration order at construction. */
private layers = new Map<string, number>();
private onLayerReady: (url: string, layer: number) => void;
/**
* @param urls Unique skin URLs needed for this game. If empty, the atlas is
* a 1×1×1 placeholder. Order determines layer assignment.
*/
constructor(
gl: WebGL2RenderingContext,
urls: readonly string[],
onLayerReady: (url: string, layer: number) => void,
) {
this.gl = gl;
this.onLayerReady = onLayerReady;
if (urls.length === 0) {
this.tex = this.makeTex(1, 1, 1);
return;
}
this.tex = this.makeTex(SKIN_DIM, SKIN_DIM, urls.length);
urls.forEach((url, layer) => {
this.layers.set(url, layer);
this.load(url, layer);
});
}
get texture(): WebGLTexture {
return this.tex;
}
/** Layer index for a URL, or -1 if this URL wasn't registered at construction. */
getLayer(url: string): number {
return this.layers.get(url) ?? -1;
}
private load(url: string, layer: number): void {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
this.uploadImage(img, layer);
this.onLayerReady(url, layer);
};
img.onerror = () => {
console.warn("Skin image failed to load:", url);
};
img.src = url;
}
/**
* Draw image centered in a SKIN_DIM×SKIN_DIM canvas, downscale if larger,
* keep native size if smaller. The shader samples cell-center as the spawn
* anchor (UV 0.5), so centering keeps the image aligned with the spawn tile.
*/
private uploadImage(img: HTMLImageElement, layer: number): void {
const canvas = document.createElement("canvas");
canvas.width = SKIN_DIM;
canvas.height = SKIN_DIM;
const ctx = canvas.getContext("2d", { willReadFrequently: false })!;
const scale = Math.min(
1,
SKIN_DIM / img.naturalWidth,
SKIN_DIM / img.naturalHeight,
);
const drawW = (img.naturalWidth * scale) | 0;
const drawH = (img.naturalHeight * scale) | 0;
const offX = ((SKIN_DIM - drawW) / 2) | 0;
const offY = ((SKIN_DIM - drawH) / 2) | 0;
ctx.drawImage(img, offX, offY, drawW, drawH);
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex);
gl.texSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
layer,
SKIN_DIM,
SKIN_DIM,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
canvas,
);
gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
}
private makeTex(w: number, h: number, layerCount: number): WebGLTexture {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, tex);
gl.texStorage3D(
gl.TEXTURE_2D_ARRAY,
mipLevels(w, h),
gl.RGBA8,
w,
h,
layerCount,
);
gl.texParameteri(
gl.TEXTURE_2D_ARRAY,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
dispose(): void {
this.gl.deleteTexture(this.tex);
this.layers.clear();
}
}
function mipLevels(w: number, h: number): number {
return Math.floor(Math.log2(Math.max(w, h))) + 1;
}
@@ -38,13 +38,18 @@ export class TerritoryPass {
private uHighlightOwner: WebGLUniformLocation;
private uHighlightBrighten: WebGLUniformLocation;
private uShowPatterns: WebGLUniformLocation;
private uIsTeamMode: WebGLUniformLocation;
private highlightOwner = 0;
private isTeamMode = false;
private vao: WebGLVertexArrayObject;
private tileTex: WebGLTexture;
private paletteTex: WebGLTexture;
private patternMetaTex: WebGLTexture;
private patternDataTex: WebGLTexture;
private skinAtlasTex: WebGLTexture;
private skinLayerTex: WebGLTexture;
private skinAnchorTex: WebGLTexture;
private altView = false;
private showPatterns = true;
@@ -78,6 +83,9 @@ export class TerritoryPass {
paletteTex: WebGLTexture,
patternMetaTex: WebGLTexture,
patternDataTex: WebGLTexture,
skinAtlasTex: WebGLTexture,
skinLayerTex: WebGLTexture,
skinAnchorTex: WebGLTexture,
settings: RenderSettings,
) {
this.gl = gl;
@@ -88,6 +96,9 @@ export class TerritoryPass {
this.paletteTex = paletteTex;
this.patternMetaTex = patternMetaTex;
this.patternDataTex = patternDataTex;
this.skinAtlasTex = skinAtlasTex;
this.skinLayerTex = skinLayerTex;
this.skinAnchorTex = skinAnchorTex;
this.cpuTileState = new Uint16Array(mapW * mapH);
this.nBuckets = Math.max(1, settings.tileDrip.bucketCount | 0);
@@ -129,12 +140,16 @@ export class TerritoryPass {
"uHighlightBrighten",
)!;
this.uShowPatterns = gl.getUniformLocation(this.program, "uShowPatterns")!;
this.uIsTeamMode = gl.getUniformLocation(this.program, "uIsTeamMode")!;
gl.useProgram(this.program);
gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uPatternMeta"), 2);
gl.uniform1i(gl.getUniformLocation(this.program, "uPatternData"), 3);
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAtlas"), 4);
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinLayer"), 5);
gl.uniform1i(gl.getUniformLocation(this.program, "uSkinAnchor"), 6);
this.vao = createMapQuad(gl, mapW, mapH);
}
@@ -365,6 +380,19 @@ export class TerritoryPass {
this.showPatterns = show;
}
/**
* Update the skin atlas texture handle. Called once at game start after
* the renderer learns the locked-in skin URL set.
*/
setSkinAtlas(tex: WebGLTexture): void {
this.skinAtlasTex = tex;
}
/** Whether this game has teams (controls skin tinting). */
setTeamMode(isTeamMode: boolean): void {
this.isTeamMode = isTeamMode;
}
/** Set the hovered player's smallID for territory-fill brightening (0 = off). */
setHighlightOwner(ownerID: number): void {
this.highlightOwner = ownerID;
@@ -396,6 +424,7 @@ export class TerritoryPass {
this.uShowPatterns,
this.settings.passEnabled.territoryPatterns && this.showPatterns ? 1 : 0,
);
gl.uniform1i(this.uIsTeamMode, this.isTeamMode ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
@@ -405,6 +434,12 @@ export class TerritoryPass {
gl.bindTexture(gl.TEXTURE_2D, this.patternMetaTex);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex);
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.skinAtlasTex);
gl.activeTexture(gl.TEXTURE5);
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
gl.activeTexture(gl.TEXTURE6);
gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
@@ -1,12 +1,18 @@
#version 300 es
precision highp float;
precision highp usampler2D;
precision highp sampler2DArray;
uniform usampler2D uTileTex; // R16UI — tile state per cell
uniform sampler2D uPalette; // RGBA32F — player colors
uniform sampler2D uPatternMeta; // RGBA32F — 1D buffer, 1 px per owner. R=hasPattern, G=width, B=height, A=scale
uniform usampler2D uPatternData; // R8UI — 2D buffer, row per owner, bytes for bitmask
uniform sampler2DArray uSkinAtlas; // RGBA8 — per-skin PNG layer, tiled via REPEAT wrap
uniform usampler2D uSkinLayer; // R8UI — 1D buffer, 1 px per owner. 0=no skin, otherwise layer+1
uniform usampler2D uSkinAnchor; // RG16UI — 1D buffer, anchor tile (cx, cy) per owner. (0,0) = world origin
uniform int uShowPatterns;
uniform int uIsTeamMode; // 1 = teams (tint skin by team color), 0 = FFA (raw skin colors)
const float SKIN_DIM = 1024.0; // atlas cell size in tiles — must match SkinAtlasArray.SKIN_DIM
uniform vec2 uMapSize;
uniform int uAltView;
@@ -48,32 +54,58 @@ void main() {
float u = (float(owner) + 0.5) / float(PALETTE_SIZE);
vec4 color = texture(uPalette, vec2(u, 0.25));
if (uShowPatterns == 1) {
// uShowPatterns gates both skins and patterns — they're the same
// "decorate the territory fill" feature from the user's perspective.
uint skinLayerPlus1 =
uShowPatterns == 1
? texelFetch(uSkinLayer, ivec2(int(owner), 0), 0).r
: 0u;
if (skinLayerPlus1 > 0u) {
// Skin overrides pattern entirely (mutually exclusive). The image is a
// single stamp centered at the player's spawn tile — UVs outside [0,1]
// are treated as transparent so tiles beyond the image bounds fall back
// to the regular palette color. (0,0) anchor sentinel = world origin.
uvec2 anchor = texelFetch(uSkinAnchor, ivec2(int(owner), 0), 0).rg;
vec2 anchorOffset = (anchor == uvec2(0u)) ? vec2(0.0) : vec2(anchor);
vec2 skinUV = (vec2(tc) - anchorOffset) / vec2(SKIN_DIM) + vec2(0.5);
vec4 skin = texture(uSkinAtlas, vec3(skinUV, float(skinLayerPlus1) - 1.0));
bool inBounds =
skinUV.x >= 0.0 && skinUV.x <= 1.0 &&
skinUV.y >= 0.0 && skinUV.y <= 1.0;
float skinAlpha = inBounds ? skin.a : 0.0;
// Transparent (or out-of-bounds) pixels fall through to the player color;
// opaque pixels show the skin (tinted by team color in team games).
vec3 skinColor = (uIsTeamMode == 1) ? color.rgb * skin.rgb : skin.rgb;
color.rgb = mix(color.rgb, skinColor, skinAlpha);
} else if (uShowPatterns == 1) {
vec4 meta = texelFetch(uPatternMeta, ivec2(int(owner), 0), 0);
if (meta.r > 0.0) {
int pWidth = int(meta.g);
int pHeight = int(meta.b);
int pScale = int(meta.a);
int px = tc.x >> pScale;
int py = tc.y >> pScale;
int mx = ((px % pWidth) + pWidth) % pWidth;
int my = ((py % pHeight) + pHeight) % pHeight;
int bitIndex = my * pWidth + mx;
int byteIndex = bitIndex >> 3;
uint patternByte = texelFetch(uPatternData, ivec2(byteIndex, int(owner)), 0).r;
bool isPrimary = (patternByte & (1u << uint(bitIndex & 7))) == 0u;
if (!isPrimary) {
color = texture(uPalette, vec2(u, 0.75));
}
}
}
// Hover highlight: brighten every tile owned by the hovered player.
// Hover highlight: boost saturation on the hovered player's tiles.
// luma = grayscale equivalent; mixing past 1.0 pushes color away from gray.
if (uHighlightOwner != 0u && owner == uHighlightOwner) {
color.rgb = mix(color.rgb, vec3(1.0), uHighlightBrighten);
float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = clamp(mix(vec3(luma), color.rgb, 1.6), 0.0, 1.0);
}
fragColor = color;
+6
View File
@@ -6,6 +6,7 @@ import { PlayerPattern } from "./Schemas";
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
export type Pattern = z.infer<typeof PatternSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type Skin = z.infer<typeof SkinSchema>;
export type Pack = z.infer<typeof PackSchema>;
export type Subscription = z.infer<typeof SubscriptionSchema>;
export type PatternName = z.infer<typeof CosmeticNameSchema>;
@@ -80,6 +81,10 @@ export const FlagSchema = CosmeticSchema.extend({
url: z.string(),
});
export const SkinSchema = CosmeticSchema.extend({
url: z.string(),
});
export const PackSchema = CosmeticSchema.extend({
displayName: z.string(),
currency: z.enum(["hard", "soft"]),
@@ -98,6 +103,7 @@ export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
skins: z.record(z.string(), SkinSchema).optional(),
currencyPacks: z.record(z.string(), PackSchema).optional(),
subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
});
+8
View File
@@ -133,6 +133,7 @@ export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type PlayerSkin = z.infer<typeof PlayerSkinSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
export type GameInfo = z.infer<typeof GameInfoSchema>;
export type PublicGames = z.infer<typeof PublicGamesSchema>;
@@ -537,6 +538,12 @@ export const PlayerCosmeticRefsSchema = z.object({
color: z.string().optional(),
patternName: CosmeticNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
skinName: CosmeticNameSchema.optional(),
});
export const PlayerSkinSchema = z.object({
name: CosmeticNameSchema,
url: z.string(),
});
// Server converts refs to the actual cosmetics here
@@ -544,6 +551,7 @@ export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
skin: PlayerSkinSchema.optional(),
});
export const PlayerSchema = z.object({
+25 -3
View File
@@ -42,6 +42,11 @@ export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
}
export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
/**
* Storage key for the player's selected territory cosmetic. Stores either
* `"pattern:<name>[:<palette>]"` or `"skin:<name>"` — patterns and skins are
* mutually exclusive, so they share one slot.
*/
export const PATTERN_KEY = "territoryPattern";
export const FLAG_KEY = "flag";
export const COLOR_KEY = "settings.territoryColor";
@@ -256,7 +261,11 @@ export class UserSettings {
if (cosmetics === null) return null;
let data = this.getCached(PATTERN_KEY);
if (data === null) return null;
// Skin selections share this key — defer to getSelectedSkinName.
if (data.startsWith("skin:")) return null;
const patternPrefix = "pattern:";
// Accept both `pattern:<name>[:<palette>]` (current) and bare `<name>[:<palette>]`
// (older builds wrote unprefixed) so existing localStorage values still resolve.
if (data.startsWith(patternPrefix)) {
data = data.slice(patternPrefix.length);
}
@@ -270,14 +279,27 @@ export class UserSettings {
} satisfies PlayerPattern;
}
setSelectedPatternName(patternName: string | undefined): void {
if (patternName === undefined) {
/**
* Accepts a fully-prefixed cosmetic value: `"pattern:<name>[:<palette>]"`
* or `"skin:<name>"`. Patterns and skins share storage because they're
* mutually exclusive — writing one automatically clears the other.
*/
setSelectedPatternName(value: string | undefined): void {
if (value === undefined) {
this.removeCached(PATTERN_KEY);
} else {
this.setCached(PATTERN_KEY, patternName);
this.setCached(PATTERN_KEY, value);
}
}
/** Returns the bare skin name (no `skin:` prefix), or null if a pattern (or nothing) is selected. */
getSelectedSkinName(): string | null {
const data = this.getCached(PATTERN_KEY);
if (data === null) return null;
const skinPrefix = "skin:";
return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null;
}
getFlag(): string | null {
let flag = this.getCached(FLAG_KEY);
if (!flag) return null;
+18
View File
@@ -18,6 +18,7 @@ import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
PlayerSkin,
} from "../core/Schemas";
import { simpleHash } from "../core/Util";
@@ -203,10 +204,27 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
return { type: "forbidden", reason: "invalid flag: " + message };
}
}
if (refs.skinName) {
try {
cosmetics.skin = this.isSkinAllowed(flares, refs.skinName);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { type: "forbidden", reason: "invalid skin: " + message };
}
}
return { type: "allowed", cosmetics };
}
isSkinAllowed(flares: string[], name: string): PlayerSkin {
const found = this.cosmetics.skins?.[name];
if (!found) throw new Error(`Skin ${name} not found`);
if (flares.includes("skin:*") || flares.includes(`skin:${found.name}`)) {
return { name: found.name, url: found.url };
}
throw new Error(`No flares for skin ${name}`);
}
isPatternAllowed(
flares: readonly string[],
name: string,
+157
View File
@@ -56,6 +56,37 @@ const flagChecker = new PrivilegeCheckerImpl(
bannedWords,
);
const skinCosmetics = {
patterns: {},
colorPalettes: {},
flags: {},
skins: {
mountain: {
name: "mountain",
url: "https://example.com/mountain.png",
affiliateCode: null,
product: { productId: "prod_1", priceId: "price_1", price: "$4.99" },
priceSoft: undefined,
priceHard: undefined,
rarity: "common",
},
forest: {
name: "forest",
url: "https://example.com/forest.png",
affiliateCode: null,
product: null,
priceSoft: undefined,
priceHard: undefined,
rarity: "rare",
},
},
};
const skinChecker = new PrivilegeCheckerImpl(
skinCosmetics,
mockDecoder,
bannedWords,
);
describe("UsernameCensor", () => {
describe("isProfane (via matcher.hasMatch)", () => {
test("detects exact banned words", () => {
@@ -362,3 +393,129 @@ describe("Flag validation in isAllowed", () => {
}
});
});
describe("Skin validation", () => {
describe("isSkinAllowed (direct)", () => {
test("returns skin when user has wildcard flare", () => {
const result = skinChecker.isSkinAllowed(["skin:*"], "mountain");
expect(result).toEqual({
name: "mountain",
url: "https://example.com/mountain.png",
});
});
test("returns skin when user has exact-match flare", () => {
const result = skinChecker.isSkinAllowed(["skin:mountain"], "mountain");
expect(result).toEqual({
name: "mountain",
url: "https://example.com/mountain.png",
});
});
test("ignores unrelated flares", () => {
expect(() =>
skinChecker.isSkinAllowed(
["skin:forest", "pattern:*", "flag:*"],
"mountain",
),
).toThrow(/No flares for skin mountain/);
});
test("throws when user has no skin flares", () => {
expect(() => skinChecker.isSkinAllowed([], "mountain")).toThrow(
/No flares for skin mountain/,
);
});
test("throws when skin does not exist in cosmetics", () => {
expect(() =>
skinChecker.isSkinAllowed(["skin:*"], "nonexistent"),
).toThrow(/Skin nonexistent not found/);
});
test("throws when skin does not exist even with exact-match flare", () => {
// Forged refs.skinName must not bypass the existence check.
expect(() =>
skinChecker.isSkinAllowed(["skin:nonexistent"], "nonexistent"),
).toThrow(/Skin nonexistent not found/);
});
test("throws when checker has no skins map at all", () => {
// checker is constructed with mockCosmetics (no skins key).
expect(() => checker.isSkinAllowed(["skin:*"], "anything")).toThrow(
/Skin anything not found/,
);
});
});
describe("isAllowed integration", () => {
test("allows valid skin with wildcard flare", () => {
const result = skinChecker.isAllowed(["skin:*"], {
skinName: "mountain",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.skin).toEqual({
name: "mountain",
url: "https://example.com/mountain.png",
});
}
});
test("allows valid skin with exact-match flare", () => {
const result = skinChecker.isAllowed(["skin:forest"], {
skinName: "forest",
});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.skin).toEqual({
name: "forest",
url: "https://example.com/forest.png",
});
}
});
test("rejects skin when user lacks flare", () => {
const result = skinChecker.isAllowed([], { skinName: "mountain" });
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/invalid skin/);
}
});
test("rejects skin when flare is for a different skin", () => {
const result = skinChecker.isAllowed(["skin:forest"], {
skinName: "mountain",
});
expect(result.type).toBe("forbidden");
});
test("rejects nonexistent skin", () => {
const result = skinChecker.isAllowed(["skin:*"], {
skinName: "ghost",
});
expect(result.type).toBe("forbidden");
if (result.type === "forbidden") {
expect(result.reason).toMatch(/Skin ghost not found/);
}
});
test("no skin in refs leaves cosmetics.skin undefined", () => {
const result = skinChecker.isAllowed(["skin:*"], {});
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.skin).toBeUndefined();
}
});
test("invalid skin short-circuits and does not return other cosmetics", () => {
// pattern is valid (no pattern requested), color is valid, skin is invalid —
// the whole result must be forbidden, with no partial cosmetics leaking out.
const result = skinChecker.isAllowed(["color:red"], {
color: "red",
skinName: "mountain",
});
expect(result.type).toBe("forbidden");
});
});
});