- JavaScript 88%
- HTML 8.8%
- CSS 3.2%
| .forgejo/workflows | ||
| docs | ||
| public | ||
| server | ||
| spike | ||
| vendor/ruffle-aqw | ||
| .gitignore | ||
| LICENSE | ||
| NOTICE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
AQ Closet
An outfit previewer for AdventureQuest Worlds, live at aqcloset.com: build a full mock-up set, swap any slot, share the link. An homage to Dress to Impress, the Neopets original.
AQ Closet is an unofficial fan project, not affiliated with or endorsed by Artix Entertainment. AdventureQuest Worlds (aq.com), its names, art, game files and item data are copyright Artix Entertainment, LLC and are not covered by this repository's licence. The code is MIT (LICENSE); NOTICE lists the parts under other terms.
How it draws
AQW cannot be layered like Neopets can. Neopets art is per-item images the browser stacks; AQW art is SWF vector files put together by game code (armour is per-piece classes, weapons have anchors, colours are applied at run time). Something has to run it.
The wardrobe runs the game's own avatar renderer in the visitor's browser,
under Ruffle: aqlite.swf with the in-game renderer charbot compiles for it,
the same one the Discord bot draws with. The Ruffle is a patched build,
with stock Ruffle as the fallback (docs/RUFFLE-BUILD.md).
So a preview needs no render queue, and the server mostly serves files.
docs/SPIKE-RUFFLE.md is the original evidence that
Ruffle can do this.
Downloads, item thumbnails and the pictures the bot posts are drawn by charbot, which runs a real Flash projector under Xvfb: tens of seconds a render, a few at a time, but its pixels can be saved, which Ruffle's cannot.
Architecture
browser: Ruffle (WASM Flash) draws the avatar and the scene live
|
+-- aq-closet server (this repo)
+-- /game/gamefiles/* proxy and cache for aq.com SWFs, for the site's own pages
+-- /api/* search, sets, closets, pictures (every public route is written up at /api)
+-- static site
|
+-- charbot ........ game files, the renderer, item search, thumbnails, PNG/GIF export, character pages
+-- wiki-search .... pre-rendered item thumbnails, CLIP visual search
+-- Ravens site .... the closet: saved sets, shared with the Discord bot
| Service | What it gives the site |
|---|---|
| charbot | The Flash renderer: the in-game renderer the preview runs, every saved picture, item thumbnails drawn on demand, the colour-custom sweep. Also item search over its copy of the community sheet, and fetching aq.com files for the site |
| wiki-search | Pre-rendered item thumbnails (male and female armour), and the CLIP index behind "Looks the part" and "Complete the look" |
| Ravens site | The closet: saved sets are the bot's closet rows, reached through the Ravens site's closet API, so /closet in Discord and the wardrobe read one list |
| aq-closet | This repo: the site and its catalogue, and a file of saved sets on a deploy with no Ravens site |
The catalogue is the community item sheet plus every item a looked-up
character wore that the sheet lacked (modelling, as Dress to Impress does it).
AQ's own item table (server/aq_items.json.gz) settles AC, members' and
rarity where the sheet or the wiki disagree with it.
What works now
- Wardrobe: six slots (armor, helm, cape, weapon, pet, ground), click to try
- Client-side preview: the avatar renders under Ruffle, no server render
- Colour customization: hair, skin, eyes, base, trim, accent
- Favourites: signed in, the game's own star on any card keeps it;
fav:yesor the Favourites switch lists them. Kept with the account, so they follow you; shown on your profile, and on your public closet page if you switch that on - The shelf: drag a card onto it, or press the card's shelf button, to park a maybe; click a shelved piece to swap it with what is worn in that slot, and the piece that was on goes to the shelf in its place. Kept in the browser, and out of the way until something is on it
- Search that knows what a theme is:
angelfinds the seraphic, halo, winged and celestial things after the ones called angel;"light"in quotes is the word alone. The theme list isserver/concepts.json, shared with wiki-search so the bot's/searchagrees with the wardrobe - Complete the look: a button by the body switch: for every empty slot,
what would go with what is on. What people wear together leads when it has
anything to say (
server/cowear.js: every looked-up character and every saved set is an outfit somebody chose, counted once a day per outfit, files only, no names kept), then wiki-search's match route (the style of each render with its slot taken out, and its colours), then the themes the names evoke. Pieces from the same set as what is worn are left out unless asked for: wearing the set is not making an outfit - Shuffle, with locks: a random piece in every unlocked slot, drawn from
whatever the search box would find (
owned:yes is:ingameshuffles what you can wear tonight); a lock on the rail keeps a slot, full or empty - What you own, and where: import the inventory on the profile and
every card says In inventory, In bank, Owned chest, Owned house or Owned
pet (the wiki's shop locations name the item that opens the shop, and you
own it) or Owned badge (a Book of Lore shop your character's badges open; the
bookmarklet brings the badges from the account page, a paste has charbot
read the public character page through
/badges; a pack's badge is not named for its shop, sospike/wiki-badge-shops.mjskeepsserver/badge_shops.jsonfrom the wiki's Bonus Packages page and the matching reads it);owned:takesyes,no,inv,bank,chest,house,petandbadge(server/owned.js) - Looks the part: a strip under the results of items whose pictures match the words, whatever they are called, from wiki-search's CLIP index
- Compare: two share links side by side at
/compare: a picture of each, every piece, the slots that differ marked; the Share module hands the current outfit over as side A - Public closets: a switch on the profile opens your saved sets to anybody
at
/closet/<name>(off by default; your id when the name is not one of a kind) - Palettes: the six colours by name: your own, saved in the browser; the
featured sets' colours, named for the set and the person; a link of their own
(
?pal=…) that colours whatever is worn; and "Match armour" to take the base, trim and accent off the armour's own picture - Share links: the whole outfit lives in the URL
- Recently worn: everything put on, newest first, folded under the shelf and kept in the browser only; a click wears it again
- Pictures that carry their outfit: a downloaded PNG or GIF (and every
/api/outfit.png, and the pictures the Discord bot posts) holds the share link in a PNG text chunk or a GIF comment; drop the file back on the wardrobe and the outfit is on again (public/png-outfit.js) - Linkage resolution: reads each SWF's real symbol table, because the filename stem is often wrong and a wrong guess renders an item invisible
- A fallback model: where the in-game renderer cannot load, the
character-page model (
characterB.swf) draws instead, minimised the way charbot'sswfmin.pydoes it (server/swfmin.js)
Running it
npm install
npm run dev # http://127.0.0.1:8081
npm run spike # in a second shell: the Ruffle probe (page at /spike.html)
Locally everything has a default. AQW's game binaries are not in this repo:
without CHARBOT_URL, the avatar needs a charbot checkout beside it
(LOCAL_SWF_DIR).
Configuration
On a deploy, /api/diagnostics says which of the first group are missing,
and never prints a secret.
The live site needs these
| Variable | Default | Purpose |
|---|---|---|
CHARBOT_URL |
Railway's charbot service | charbot: the in-game renderer, item search, thumbnails, downloads and set pictures, and fetching aq.com files for the site. A bare *.railway.internal host is reached over plain HTTP |
CHARBOT_TOKEN |
AQW_RENDER_TOKEN, if that is set here |
the X-Render-Token sent to charbot (and wiki-search); it must match charbot's own AQW_RENDER_TOKEN |
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET |
(unset) | the Discord app for sign-in. Unset, nobody can sign in |
DISCORD_PROXY_URL |
(unset) | an HTTP proxy for sign-in's calls to Discord, for when Discord refuses the host's own address (a sign-in failing with HTTP 429) |
SITE_BASE_URL |
(unset) | the site's public address. Discord sends people back to its /auth/callback, which has to be in the Discord app's redirects |
OAUTH_REDIRECT_URI |
SITE_BASE_URL + /auth/callback |
the callback in full, when it is somewhere else |
COOKIE_SECRET |
random at each boot | signs sessions; 16 characters or more. Unset, every deploy signs everybody out |
OWNER_DISCORD_ID |
(unset) | the owner's Discord id: who sees the Framing panel and the operator notes that visitors are spared. Unset, the panel is open to everyone |
RAVENS_SITE_URL |
Railway's RavensWebsite service | the Ravens site, whose closet holds the saved sets |
CLOSET_SERVICE_TOKEN |
(unset) | the token for the Ravens site's closet API. Without it, or without a Ravens site address, sets are kept in a file under DATA_DIR |
Optional
| Variable | Default | Purpose |
|---|---|---|
OUTBOUND_PROXY_URL |
(unset) | an HTTP proxy for fetches to aq.com. Game files are asked of charbot first when CHARBOT_URL is set; this is the route for a deploy without charbot, and for backdrops on account.aq.com. The older name AQW_PROXY_URL still works |
OUTBOUND_USER_AGENT |
AQCloset/1.0 (+https://aqcloset.com) |
the User-Agent on every outbound fetch |
GAMEFILE_HEADERS |
(unset) | a JSON object of headers that replaces the default set on direct game file fetches |
AQW_GAMEFILE_HOST |
www.aq.com, then game.aq.com |
the game file servers to try, as comma-separated .../game/gamefiles/ URLs |
WIKI_SEARCH_URL |
Railway's wiki-search service | wiki-search: pre-rendered thumbnails, "Looks the part" and the matches in "Complete the look" |
LOCAL_SWF_DIR |
../charbot-flash-new |
a folder holding aqlite.swf and characterB.swf (a charbot checkout), for running without charbot |
DATA_DIR |
RAILWAY_VOLUME_MOUNT_PATH, else /app/data if it exists, else the repo |
what the site keeps (saved sets, favourites, imported inventories, the map table, display names) under state/, and the file cache. It has to be on a Railway volume or a deploy wipes it; the boot log says which |
ITEMDB_DIR |
/app/data if it exists, else ./data |
the catalogue: the sheet, the learned items and their flags |
GAMEFILE_CACHE |
gamefiles_cache under DATA_DIR |
the on-disk cache of game files and thumbnails |
ITEM_SUBMIT_TOKEN |
(unset) | the X-Closet-Token a program presents to /api/items/submit, /api/items/cc and /api/maps/learned. Unset, all three are closed |
DIAGNOSTICS_OPEN |
(unset) | 1 opens the Framing panel to everyone, whoever the owner is |
PUBLIC_HOST |
the request's host | the host the site is served on, when a proxy in front hides it |
RUFFLE_BUILD |
the patched build | stock takes the patched Ruffle off the site (docs/RUFFLE-BUILD.md) |
RUFFLE_AQW_DIR |
vendor/ruffle-aqw |
where the patched build lives |
CONTACT_EMAIL |
the owner's address | the address the policy pages give |
SESSION_DAYS |
30 |
how long a sign-in lasts |
FRAME_ANCESTORS |
(unset) | any lets other sites put the pages in a frame |
MAP_HARVEST |
on under Railway or NODE_ENV=production |
the weekly harvest of map files from the AQW SWF wiki and the web archive; on or off |
Tuning
| Variable | Default | Purpose |
|---|---|---|
PICTURE_CONCURRENCY |
2 |
set pictures charbot is asked for at once (24 more may wait) |
AQ_UPLOAD_LIMIT |
48mb |
the largest AQ item table the owner can upload, inflated |
HEADERS_TIMEOUT_MS, REQUEST_TIMEOUT_MS, KEEPALIVE_TIMEOUT_MS |
20000, 60000, 65000 |
the HTTP server's own timeouts |
GAMEFILE_MAX_AGE |
604800 |
how long, in seconds, a browser may keep a game file |
GAMEFILE_MISS_TTL_MS |
600000 |
how long a game file nobody had is taken as missing |
PATCHED_CACHE_CAP |
48 |
re-framed copies of the avatar model kept in memory |
PREVIEW_TTL_MS |
60000 |
how long charbot's compiled renderer is kept before it is asked for again |
PRECOMPRESS_BEST, PRECOMPRESS_DELAY_MS |
on, 30000 |
the slow quality-11 brotli pass over big files, and how long after boot it may begin; off keeps quality 9 |
ITEMDB_REFRESH_MS |
6 hours | how often the community sheet is read again; 0 never |
WIKI_CATCHUP_MS, WIKI_CATCHUP_BATCH |
15 minutes, 40 |
how often, and how many at a time, the wiki is asked about items it has not answered for; 0 never |
LOOKALIKE_TIMEOUT_MS |
12000 |
how long wiki-search gets to answer "Looks the part" and a "Complete the look" match |
MAP_HARVEST_DELAY_MS, MAP_HARVEST_GAP_MS |
2 minutes, 1000 |
when the map harvest starts after boot, and the pause between its requests |
PORT is the port to listen on (8081). The suites point the rest at stand-ins:
AQW_ITEMDB_URL (the community sheet), AQW_CHARPAGE_BASE,
AQW_ARCHIVE_CDX, AQW_SWF_WIKI, DISCORD_API_BASE and GITHUB_API_URL.
CHROME_PATH tells the browser suites where Chromium is.
Limits
One visitor's ration, and what the whole site will do at once:
| Variable | Default | What it bounds |
|---|---|---|
RATE_API |
300 |
API calls per minute, per visitor |
RATE_RENDER |
30 |
renders per five minutes, per visitor: downloads (/api/export) and new outfit or set pictures (/api/outfit.png, /api/sets/:id.png, /api/closets/:who/:id.png) |
RATE_THUMB |
240 |
item thumbnails drawn for the first time, per five minutes, per visitor. Its own allowance, and a large one: the wardrobe opens on a page of pictures, and each one is drawn once and then cached for everybody |
RATE_THUMB_OFFSITE |
60 |
thumbnails per five minutes, per address, for anything other than the site's own pages, counted whether or not the picture was already drawn |
TRUSTED_ORIGINS |
(none) | comma-separated origins treated like the site's own pages for game files and thumbnails, such as a known browser extension's chrome-extension://<id> |
RENDER_CONCURRENCY |
4 |
downloads being rendered at once, across everybody |
THUMB_CONCURRENCY |
2 |
thumbnails this site will have charbot drawing at once, across everybody. A queued render holds one of charbot's workers for the whole wait, so this is what keeps the avatar's own files and the backdrops answerable while a page of cards fills in |
CC_SWEEP |
on |
the colour-custom sweep: the site walks its own catalogue, newest first, and has charbot draw each item it has no verdict for, so cc:yes rests on what the render coloured rather than on the wiki, the file name or the art's bytes. Each render also refreshes that thumbnail. It yields to every visitor render and to the bot. off turns it off |
CC_SWEEP_MS |
0 |
the pause between the sweep's renders. 0 runs them back to back whenever nobody else is drawing, about three days for the catalogue at charbot's own pace; a larger value slows it down |
CC_SWEEP_START_MS |
60000 |
how long after boot the sweep starts; a process that lives less than this never spends a render on it |
TRUST_PROXY_HOPS |
1 |
proxies in front of this process |
RATE_LIMIT |
on | off disables all of it |
RATE_REPORT |
5 |
bug reports per hour, per visitor (/api/report) |
GITHUB_ISSUE_TOKEN |
(unset) | a fine-grained token with Issues: write on the repository below; /report files bug reports as issues with it. Unset, the form says reports are not set up yet |
GITHUB_ISSUE_REPO |
Redu667/Fashion-Tester |
where the reports go, owner/name |
This is not DDoS protection and must not be mistaken for it. A volumetric attack is absorbed in front of the origin or not at all: by the time a flood reaches this process it has taken the bandwidth, the sockets and the event loop, and a 429 written here costs nearly what the work would have. Putting the domain behind a CDN that will absorb it is a separate job and the only one that answers that threat.
What these answer is the thing an app can answer. The scarce resource is not CPU or bandwidth, it is charbot: a real Flash projector under Xvfb, tens of seconds per render, six at a time. A hundred GIFs does not need a botnet, it needs a for-loop. So the limits are shaped like the cost rather than like the traffic, pages and files are never rationed, and somebody reading the site should never meet one.
TRUST_PROXY_HOPS must match reality. Too low and every visitor shares
one bucket, so the first busy minute locks out the world; too high (true, as
Express spells "trust everything") and the address comes from a header the
caller writes, so a fresh ration costs one forged line per request. Behind
Railway it is 1. Adding a CDN in front makes it 2.
Branches, and what has to pass
| Branch | What it is |
|---|---|
main |
what aqcloset.com serves |
test |
where everything lands first |
Work goes on a branch of its own, opens a pull request into test, and reaches
main only after it has been seen working. The checks workflow runs on pull
requests into either.
A test branch that nothing deploys is only an extra merge. Every round
that has cost real time here was lost to something no local run could show: a
stale script served from a cache, a FlashVar dropped between two services, a
frame-rate lever that was dead because allowScriptAccess defaults to false.
Those are answered by a deploy, and by nothing else. So for the step to be worth
taking, test wants a Railway service of its own:
- A second service in the same project, deploying from the
testbranch. - Its own volume mounted at
/app/data, saved sets and the learned catalogue live there, and sharing one with production would let a test write into it. - The same variables as production except: a different
SITE_BASE_URL, its ownCOOKIE_SECRET, and that URL's/auth/callbackadded to the Discord app's redirects.OWNER_DISCORD_IDstays the same, so the framing panel is yours there too.
Without that, test is still a place for a change to sit before it is
promoted, but the thing it is being checked against is only the test suite.
What the checks cover
Every spike/*-test.mjs runs on a pull request, except the few the workflow
names in LOCAL_ONLY. The node job runs every suite that needs no browser:
the catalogue, sessions, saved sets, the closet read-through, thumbnails, the
charpage routes, downloads, harvesting and attribution. The pages job runs the
browser suites, in the order PAGE_SUITES gives. Most boot their own site; a
few read the one the job starts, with the charbot stand-in
(spike/mock-charbot.mjs) behind it. A new suite runs without being listed; a
new browser suite has to be added to PAGE_SUITES, and the node job fails by
name until it is.
LOCAL_ONLY is the suites that need charbot's aqlite.swf and
characterB.swf (or scene.py), which are not in this repository, or real
item art from the AQW CDN through a running site, and have never run on a
runner: renderer-trace,
scene-whole, scene-geometry, swfmin, recovery, reach-measure, fit
and prod-outfit, plus reach, which measures and never fails. The ones
that read charbot's files find a checkout at ../charbot-flash-new, or
wherever LOCAL_SWF_DIR says; fit, prod-outfit and reach read a running
site (SPIKE_BASE, else port 8081) that can fetch real item art.
node spike/renderer-trace-test.mjs