AQ Closet, an outfit builder for AdventureQuest Worlds https://aqcloset.com
  • JavaScript 88%
  • HTML 8.8%
  • CSS 3.2%
Find a file
2026-10-14 01:33:02 +00:00
.forgejo/workflows AQ Closet 2026-10-14 01:01:02 +00:00
docs AQ Closet 2026-10-14 01:01:02 +00:00
public AQ Closet 2026-10-14 01:01:02 +00:00
server AQ Closet 2026-10-14 01:01:02 +00:00
spike AQ Closet 2026-10-14 01:01:02 +00:00
vendor/ruffle-aqw AQ Closet 2026-10-14 01:01:02 +00:00
.gitignore AQ Closet 2026-10-14 01:01:02 +00:00
LICENSE AQ Closet 2026-10-14 01:01:02 +00:00
NOTICE AQ Closet 2026-10-14 01:01:02 +00:00
package-lock.json AQ Closet 2026-10-14 01:01:02 +00:00
package.json Point package.json at OpenAQW/aq-closet 2026-10-14 01:33:02 +00:00
README.md AQ Closet 2026-10-14 01:01:02 +00:00

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:yes or 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: angel finds the seraphic, halo, winged and celestial things after the ones called angel; "light" in quotes is the word alone. The theme list is server/concepts.json, shared with wiki-search so the bot's /search agrees 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:ingame shuffles 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, so spike/wiki-badge-shops.mjs keeps server/badge_shops.json from the wiki's Bonus Packages page and the matching reads it); owned: takes yes, no, inv, bank, chest, house, pet and badge (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's swfmin.py does 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:

  1. A second service in the same project, deploying from the test branch.
  2. 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.
  3. The same variables as production except: a different SITE_BASE_URL, its own COOKIE_SECRET, and that URL's /auth/callback added to the Discord app's redirects. OWNER_DISCORD_ID stays 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