feat(assets): a creature's picture is whichever action has one (Phase 6)
The shard's catalogue can now answer for 73 bodies it used to report absent —
they have no art at action 0 and real art at a later one, and their key says
which (`body/820/a23` is a horse). This side stores that action and stops
assuming `a0` anywhere.
The atlas join is the part that mattered. It read
a.asset_key = CONCAT('body/', b.body, '/a0')
which would have silently dropped exactly the creatures this phase adds. It now
reads the row's own action, with COALESCE for rows written before the column
existed — a NULL inside CONCAT makes the whole comparison NULL, which would have
taken every portrait off the site on upgrade with the database perfectly correct
and nothing in any log. It still matches at most one row per slug: a deeper key
(`body/820/a23/f4`) does not equal the catalogue key.
Verified against a real MariaDB with the live shard's own 1,095-row manifest: the
ALTER applies to an installed-shape table and is idempotent, the horse joins to
its a23 picture, a pre-phase-6 NULL-action row keeps its portrait, and a stored
frame key does not become a second candidate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -33,7 +33,8 @@ async function batched(conn, sql, rows) {
|
||||
/** Every asset row we hold, as a Map of key → row. */
|
||||
async function allAssets() {
|
||||
const rows = await query(
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, direction, file, catalog FROM shard_assets',
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog ' +
|
||||
'FROM shard_assets',
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
@@ -47,6 +48,7 @@ async function allAssets() {
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: row.body === null ? null : Number(row.body),
|
||||
action: row.action === null ? null : Number(row.action),
|
||||
direction: row.direction === null ? null : Number(row.direction),
|
||||
file: row.file || null,
|
||||
catalog: row.catalog || null,
|
||||
@@ -81,6 +83,7 @@ async function saveAssets(rows, meta) {
|
||||
r.width ?? 0,
|
||||
r.height ?? 0,
|
||||
r.body ?? null,
|
||||
r.action ?? null,
|
||||
r.direction ?? null,
|
||||
r.file ?? null,
|
||||
r.catalog ?? meta?.catalog ?? null,
|
||||
@@ -88,12 +91,13 @@ async function saveAssets(rows, meta) {
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_assets (asset_key, family, sha256, bytes, width, height, body, direction, file, catalog) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?) ' +
|
||||
'INSERT INTO shard_assets ' +
|
||||
'(asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?) ' +
|
||||
'ON DUPLICATE KEY UPDATE family = VALUES(family), sha256 = VALUES(sha256), ' +
|
||||
'bytes = VALUES(bytes), width = VALUES(width), height = VALUES(height), ' +
|
||||
'body = VALUES(body), direction = VALUES(direction), file = VALUES(file), ' +
|
||||
'catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
||||
'body = VALUES(body), action = VALUES(action), direction = VALUES(direction), ' +
|
||||
'file = VALUES(file), catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
||||
values,
|
||||
)
|
||||
|
||||
@@ -260,17 +264,24 @@ async function countBodies() {
|
||||
* first-class state everywhere it is consumed and the expected one for two thirds
|
||||
* of the player bodies (§5.2).
|
||||
*
|
||||
* **The join is pinned to the catalogue key, not merely to the body id.** Today
|
||||
* one body has exactly one asset, so `a.body = b.body` alone would be correct —
|
||||
* and it would stop being correct the moment phase 6 adds `body/400/a2/f0`, at
|
||||
* which point one slug would match dozens of rows and whichever the engine
|
||||
* returned last would become the portrait. Naming the key here means that phase
|
||||
* adds rows without changing what a creature page shows.
|
||||
* **The join is pinned to the catalogue key, not merely to the body id** — and as
|
||||
* of phase 6 that key is no longer always `a0`. 73 of this client's bodies have
|
||||
* no art at action 0 and are catalogued at the first action that does (§11.2), so
|
||||
* a join hardcoding `a0` would silently drop exactly the creatures this phase
|
||||
* added — a horse among them. It reads the row's own `action` instead, which
|
||||
* still excludes any deeper key a later phase adds (`body/400/a2/f0` does not
|
||||
* equal `body/400/a2`), so one slug still matches at most one row.
|
||||
*
|
||||
* `COALESCE(a.action, 0)` because a row written before this column existed has
|
||||
* NULL there and a NULL inside `CONCAT` makes the whole comparison NULL — which
|
||||
* would have dropped every portrait on the site until the next import, with the
|
||||
* database perfectly correct.
|
||||
*/
|
||||
async function artBySlug() {
|
||||
const rows = await query(
|
||||
'SELECT b.slug, a.file FROM shard_creature_bodies b ' +
|
||||
"JOIN shard_assets a ON a.asset_key = CONCAT('body/', b.body, '/a0') " +
|
||||
"JOIN shard_assets a ON a.body = b.body AND a.family = 'body' " +
|
||||
"AND a.asset_key = CONCAT('body/', b.body, '/a', COALESCE(a.action, 0)) " +
|
||||
"WHERE b.status = 'ok' AND b.body IS NOT NULL AND a.file IS NOT NULL",
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ const log = require('../../core').logger('shardAssets')
|
||||
// **The catalogue** (§4.8, §11) is one thumbnail per creature body: the shard
|
||||
// walks bodies 0–2047, validates each index entry, decodes the ones that are real
|
||||
// and hands back `{ key, sha256 }` first and the PNG second. On a stock client
|
||||
// that is **787 sprites**, not the 1,144 the decoder claims — see below.
|
||||
// that is **1,095 sprites** — 787 out of the legacy anim files, 235 more out of
|
||||
// the UOP packages (phase 4), and 73 more since phase 6, which have no art at
|
||||
// action 0 and real art at a later one. Never the 1,144 the decoder claims.
|
||||
//
|
||||
// A key therefore names its action — `body/820/a23` is a horse whose action 0 is
|
||||
// empty — and the key is still one per body. Nothing here treats `a0` as the
|
||||
// shape of a body key; the atlas join reads the row's own action (§11.2).
|
||||
//
|
||||
// **Body resolution** (§8) is the join. The atlas knows a creature by the class
|
||||
// name in `Spawns/*.xml`; the client knows it by a body id; nothing in the ServUO
|
||||
@@ -296,6 +302,7 @@ async function importAssets({ force = false, approve = false } = {}) {
|
||||
width: got.width || row.width,
|
||||
height: got.height || row.height,
|
||||
body: got.body ?? row.body,
|
||||
action: got.action ?? row.action ?? 0,
|
||||
direction: got.direction ?? row.direction,
|
||||
file: name ?? existing?.file ?? null,
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ const log = require('../../core').logger('shardItemArt')
|
||||
// ── Why this is not the body catalogue with a different prefix ─────────
|
||||
//
|
||||
// The bestiary wants every creature, so phase 3 imports a SET: walk a manifest,
|
||||
// diff the hashes, fetch what moved. That works because the set is 1,022 rows
|
||||
// diff the hashes, fetch what moved. That works because the set is 1,095 rows
|
||||
// and one megabyte.
|
||||
//
|
||||
// This side has no set. The shard's client addresses 49,152 item graphics and
|
||||
|
||||
Reference in New Issue
Block a user