Files
sprites/rules/publish.ts
Christopher Monsanto 0d214ecc77 Build the forum's auto avatars, and publish them as a slot table
The forum has carried its default avatars as 702 committed gifs since its
initial commit in 2019: bytes in the wrong repo, and the last sprite
collection it hadn't moved onto a manifest. The sources are all here, so
build them.

They are gen 5 animations on a uniform 96x96 box. The box is not decorative:
XenForo scales an <img> to fill its avatar container, so a sprite published
at its own aspect would arrive stretched. It is also the games' own sprite
size, so nothing is scaled up, and the 45 animations that overflow it are
cropped to it, which is what the 2019 set did too.

The pool is an index, not a set. The forum picks a slot with
crc32(username) % count, so a name added, removed or respelled reassigns
every user's avatar -- which is why rules/avatars.ts spells the pool out
instead of globbing src/sprites/gen5, and why these publish under the PS ids
the 2019 set used (`hooh`, not `ho-oh`) rather than the smogon names every
other set here publishes. publishedName(sn, psid) spells all but two of
them; Arceus-Normal and Swanna-2 were second copies of a picture the pool
already carries, and now share its file.

What ships is that index rather than a name -> url manifest, because nothing
looks an avatar up: __meta/avatars/manifest.json is the directory listing
scandir() used to return, as an array of urls. Two things about that listing
are the index and so are reproduced rather than tidied away. It sorted
filenames with the extension attached, which orders arceus-bug.gif ahead of
arceus.gif where the bare names sort the other way, and disagrees with the
manifest order at 68 of the 702 slots. And it led with `.` and `..`, which
counted toward the divisor and named no avatar: those two rendered a broken
image for two of every 704 usernames, and now get Substitute, which the pool
already carries, so they cost nothing but the slot.

The animations themselves change, unavoidably. The 2019 files came from an
earlier generation of these sources: 649 of the 693 that still resolve have
a different frame count, and no coalesced frame survives. Same Pokemon, same
slot, same framing, re-animated.

Manifest grows url(), which write() already composed inline, so a set can
publish another projection of the same mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:03:06 -04:00

181 lines
6.6 KiB
TypeScript

import * as spritedata from '@smogon/sprite-data/index.ts';
import type {Artifact} from '../tools/build/artifact.ts';
import type {DeployCtx, SrcFile} from '../tools/deploy/api.ts';
// Anything with a sprite-id nominal name and an extension: a built artifact
// or a raw source file.
export type Sprite = Artifact | SrcFile;
// The published tree a stamped set goes into: where it sits in the output, and
// the url that root is served at. Both live here rather than in a `dir` so
// that they are written once and everything a set publishes -- the copies, the
// urls, the mirror -- is spelled from the same place.
export type Tree = {
root: string,
served: string,
};
// One copy this manifest made: where under the tree root it landed, and the
// un-stamped filename it answers to.
type Entry = {
dir: string,
stamped: string,
filename: string,
};
// The unhashed name -> served url mapping published beside a stamped set. An
// entry is the tree's url plus the copy's place in it, so no consumer needs a
// prefix of its own.
export class Manifest {
#ctx: DeployCtx;
#tree: Tree;
#entries = new Map<string, Entry>();
constructor(ctx: DeployCtx, tree: Tree) {
this.#ctx = ctx;
this.#tree = tree;
}
// Queue a copy of `f` under `dir` with a content-stamped name and record
// the entry that points at it.
async copy(f: Sprite, {dir, ext}: Dest, name: string): Promise<void> {
let h = await this.#ctx.hash(f);
let e = extOf(f, ext);
// Keyed on the name alone, since a url names the whole path and has
// nothing to disambiguate with an extension. ActionQueue only dedups
// final dsts, and hashed dsts differ even where unhashed names
// collide, so the collision is caught here or not at all.
if (this.#entries.has(name)) {
throw new Error(`duplicate sprite name ${name}`);
}
let stamped = `${name}-${h}.${e}`;
this.#entries.set(name, {dir, stamped, filename: `${name}.${e}`});
this.#ctx.copy(f, `${this.#tree.root}/${dir}/${stamped}`);
}
// The whole url the copy `name` answers to. Exposed on its own for a set
// that publishes some other projection of this mapping than write()'s.
url(name: string): string {
let e = this.#entries.get(name);
if (e === undefined) {
throw new Error(`no sprite named ${name}`);
}
return `${this.#tree.served}/${e.dir}/${e.stamped}`;
}
write(dst: string): void {
let sorted: Record<string, string> = {};
for (let [k] of this.#sorted()) {
sorted[k] = this.url(k);
}
this.#ctx.write(dst, JSON.stringify(sorted, null, 4) + '\n');
}
// The same set again under its un-stamped names, as links to the stamped
// files. `dst` is a root of its own rather than the tree's, so what a link
// says is a path and not a name: the two ends land apart, and the reader
// that follows one composed its half from nothing but the sprite. The
// tree's own root is not repeated under it, since the mirror is already
// one set's worth of it.
links(dst: string): void {
for (let [, e] of this.#sorted()) {
this.#ctx.symlink(`${dst}/${e.dir}/${e.filename}`,
`${this.#tree.root}/${e.dir}/${e.stamped}`);
}
}
#sorted(): [string, Entry][] {
return [...this.#entries].sort((a, b) => a[0] < b[0] ? -1 : 1);
}
}
export type Dest = {
// Under the tree root, not from the output root.
dir: string,
ext?: string,
};
function extOf(f: Sprite, ext?: string): string {
let result = ext ?? f.ext;
if (result === null) {
throw new Error(`Sprite ${f.name} has no extension`);
}
return result;
}
// The names a sprite publishes under on the smogon side, or none where it
// isn't published at all. A set that backfills another needs this before it
// copies, since the mapping isn't one name per filename in either direction:
// Meowstic answers to two, and the forme slots the games gave one sprite (the
// six Minior meteors, Zygarde's Power Construct pair) answer to the same one.
export type NameOpts = {
// forumsprites publishes Unknown; no other set does.
allowUnknown?: boolean,
// The gen 6 icon sets publish the formes they have no icon for under a
// borrowed one. See ICON_ALIASES.
icons?: boolean,
};
export function publishedNames(f: Sprite, opts: NameOpts = {}): string[] {
let sn = spritedata.parseFilename(f.name);
// Skip asymmetrical for now
if (sn.extra.has('a') || sn.extra.has('b') || sn.extra.has('s')) {
return [];
}
if (sn.kind === 'x') {
// Skip these, we don't use Unknown/Substitute
if (!opts.allowUnknown || sn.name !== 'unknown') {
return [];
}
} else if (sn.kind !== 's') {
throw new Error(`Not a specie sprite: ${f.name}`);
}
return opts.icons ? spritedata.iconNames(sn) : spritedata.smogonNames(sn);
}
export async function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
opts: NameOpts = {}): Promise<void> {
for (let name of publishedNames(f, opts)) {
await manifest.copy(f, dest, name);
}
}
// TODO: merge with above
export async function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): Promise<void> {
let sn = spritedata.parseFilename(f.name);
if (sn.kind !== 'i') {
throw new Error(`Not an item sprite: ${f.name}`);
}
await manifest.copy(f, dest, spritedata.smogon(sn.name));
for (let alias of spritedata.ITEM_ALIASES[sn.name] ?? []) {
await manifest.copy(f, dest, spritedata.smogon(alias));
}
}
// A copy that takes a published name only where nothing before it did. Every
// layered set is built this way: the later sources are backfills, and the
// claim is per published name rather than per filename because the mapping is
// one to many in both directions. One name can be spelled several ways, and
// gen 5 carries a sprite per forme slot, so its six Minior meteors and its two
// Zygarde Power Construct slots all want the name the layer above already
// published, which the manifest would refuse as a duplicate.
export function firstWins(manifest: Manifest, dest: Dest,
opts: NameOpts = {}): (f: Sprite) => Promise<void> {
let seen = new Set<string>();
return async (f: Sprite): Promise<void> => {
let names = publishedNames(f, opts);
if (names.some(n => seen.has(n))) {
return;
}
for (let name of names) {
seen.add(name);
await manifest.copy(f, dest, name);
}
};
}