feat(assets): a creature's picture is whichever action has one (Phase 6)
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 8m9s
PR Checks / server-tests (pull_request) Failing after 14m27s

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:
2026-09-14 01:09:50 -05:00
parent 53b3aca0c1
commit c6c51b190d
7 changed files with 125 additions and 16 deletions

View File

@@ -250,6 +250,45 @@ test('a fetch passes the catalogue id and decodes the PNG', async (t) => {
assert.equal(assets.get('body/12/a0').width, 24)
})
test('a body catalogued at a later action keeps that action in its row', async (t) => {
// §11.2, phase 6. 73 of a stock client's bodies have no art at action 0 and are
// catalogued at the first action that does — body 820's is 23, and it is a
// horse. The action travels with the row because the atlas join needs it in
// SQL; re-deriving it from the key would put a second parser of §5's scheme in
// the schema.
stub({
manifest: [
manifestPage([
{ ...row(12), action: 0 },
{ key: 'body/820/a23', sha256: 'bb', bytes: 900, width: 68, height: 69, body: 820, action: 23, direction: 1 },
]),
],
})
t.after(restore)
const { rows } = await bridge.readManifest({})
assert.deepEqual(
rows.map((r) => [r.key, r.action]),
[
['body/12/a0', 0],
['body/820/a23', 23],
],
)
})
test('an overlay older than phase 6 reads as action 0 rather than as unknown', async (t) => {
// A phase-3 through phase-5 overlay omits `action` entirely, and every key it
// ever produced ended in `a0`. Reading that as null would make the atlas join
// COALESCE it back to 0 anyway; reading it as 0 here says so once.
stub({ manifest: [manifestPage([row(12)])] })
t.after(restore)
const { rows } = await bridge.readManifest({})
assert.equal(rows[0].action, 0)
})
test('an absent asset is a counted row, not a failed fetch', async (t) => {
// The whole reason this is not an error: two thirds of the playable ghost and
// gargoyle bodies have no art on a stock client (§5.2), and an import that