clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / identicon.ts
identicon.ts 3.8K
1/**2 * Script identicons: a stable pictographic identity hashed from the script3 * id — one Urizen tile, one foreground tint, one border color. The space is4 * ~4,000 usable tiles x 20 tints x 12 borders ≈ 1M combinations, and when5 * the pigeonhole eventually pinches, collisions degrade gracefully: the6 * hover title still carries the true identity.7 *8 * Deterministic: same id renders the same icon everywhere, forever.9 */10import { TILE, loadUrizen, type UrizenAtlas } from './render/urizen';1112/** Foreground tints: spread hues, bright enough for the dark ground. */13const FG = [14 'hsl(20 70% 62%)', 'hsl(38 75% 60%)', 'hsl(55 65% 58%)', 'hsl(75 55% 55%)',15 'hsl(100 50% 58%)', 'hsl(130 50% 58%)', 'hsl(160 55% 55%)', 'hsl(180 55% 58%)',16 'hsl(200 65% 62%)', 'hsl(220 65% 68%)', 'hsl(245 60% 70%)', 'hsl(265 55% 68%)',17 'hsl(285 50% 65%)', 'hsl(310 55% 65%)', 'hsl(330 60% 65%)', 'hsl(350 65% 65%)',18 'hsl(0 0% 82%)', 'hsl(30 30% 70%)', 'hsl(90 20% 68%)', 'hsl(210 20% 72%)',19] as const;2021/** Border colors: same wheel, dimmed — reads as a frame, not a shout. */22const BORDER = [23 'hsl(20 45% 38%)', 'hsl(55 40% 35%)', 'hsl(100 35% 35%)', 'hsl(160 35% 34%)',24 'hsl(200 40% 38%)', 'hsl(245 35% 42%)', 'hsl(285 30% 40%)', 'hsl(330 40% 40%)',25 'hsl(0 0% 45%)', 'hsl(38 50% 40%)', 'hsl(180 30% 36%)', 'hsl(310 30% 38%)',26] as const;2728function fnv(text: string): number {29 let h = 0x811c9dc5;30 for (let i = 0; i < text.length; i++) {31 h ^= text.charCodeAt(i);32 h = Math.imul(h, 0x01000193);33 }34 return h >>> 0;35}3637/** Magenta ruler columns (26-column cadence) are never identicon tiles. */38const isRulerCol = (col: number): boolean => (col + 1) % 26 === 0;3940let atlasPromise: Promise<UrizenAtlas> | null = null;41function atlas(): Promise<UrizenAtlas> {42 atlasPromise ??= loadUrizen();43 return atlasPromise;44}4546/** Pick the tile for a hash: probe until a tile has real ink (not blank,47 * not a solid wall slab). Deterministic probe sequence, capped. */48function pickTile(a: UrizenAtlas, h: number): { col: number; row: number } {49 const total = a.grid.cols * a.grid.rows;50 for (let k = 0; k < 48; k++) {51 const idx = (h + k * 7919) % total;52 const col = idx % a.grid.cols;53 const row = Math.floor(idx / a.grid.cols);54 if (isRulerCol(col)) continue;55 const ink = a.inkRatio(col, row);56 if (ink >= 0.08 && ink <= 0.85) return { col, row };57 }58 return { col: 1, row: 1 }; // deterministic last resort59}6061export interface Identicon {62 el: HTMLElement;63 /** The tint, for callers matching adjacent accents to the icon. */64 fg: string;65}6667/**68 * Build the identicon element (span.identicon with a canvas inside). The69 * fallback initials show until the atlas resolves; on atlas failure they70 * simply remain — the page never breaks on a missing sheet.71 */72export function identicon(id: string, fallbackTitle: string, sizePx = 22): Identicon {73 const h = fnv(id);74 const fg = FG[h % FG.length]!;75 const border = BORDER[Math.floor(h / FG.length) % BORDER.length]!;7677 const el = document.createElement('span');78 el.className = 'identicon';79 el.style.borderColor = border;80 el.style.width = `${sizePx + 6}px`;81 el.style.height = `${sizePx + 6}px`;82 const words = fallbackTitle.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);83 el.textContent =84 words.length >= 285 ? (words[0]![0]! + words[1]![0]!).toUpperCase()86 : (words[0] ?? '??').slice(0, 2).toUpperCase();8788 void atlas()89 .then((a) => {90 const { col, row } = pickTile(a, Math.floor(h / (FG.length * BORDER.length)));91 const canvas = document.createElement('canvas');92 canvas.width = sizePx;93 canvas.height = sizePx;94 const ctx = canvas.getContext('2d');95 if (!ctx) return;96 a.drawRaw(ctx, col, row, 0, 0, sizePx, fg);97 el.textContent = '';98 el.append(canvas);99 })100 .catch(() => {101 /* initials stay */102 });103104 return { el, fg };105}106107export { TILE };