Skip to content

Json encoding#42

Merged
SyntaxNyah merged 58 commits into
masterfrom
json-encoding
May 30, 2026
Merged

Json encoding#42
SyntaxNyah merged 58 commits into
masterfrom
json-encoding

Conversation

@OmniTroid
Copy link
Copy Markdown
Collaborator

No description provided.

OmniTroid and others added 30 commits May 28, 2026 13:57
Introduce `client.sendPacketToServer(codec, packet)` as the single choke
point for outgoing typed packets. When `encode_packets_as_json` is on it
emits `{"$header": codec.header, ...packet}`; otherwise it delegates to
`codec.encode(packet)`. The legacy `sendToServer(message: string)` is
renamed to `sendStringToServer` as a raw-wire escape hatch.

`PacketCodec` now carries `header: string` so the codec is the single
source of truth for both its wire header and its encode/decode rules.

Direction-asymmetric packets get split into Client/Server codec pairs
mirroring the `MSClient`/`MSServer` pattern: `ID`, `VS_SPEAK`, `VS_JOIN`,
`VS_LEAVE`. Send-only codecs are added for `AM`, `AN`, `AE`, `VS_FRAME`.

The per-packet `sendXX` wrappers and the ad-hoc raw-wire callers
(`HI`, `SP`, voice, list pagination) all funnel through
`sendPacketToServer`, so the JSON flag toggles every outgoing path.

MS-specific: drop the replay-loopback branch from `sendMS`, rename
`self_offset` -> `offset`, and lift `MSPacketClient`/`MSPacketServer`
to the top of the file.

Also replace every `→` with `->` across the codebase.
Aligns all packet interface field names with the Packet Reference spec,
which uses snake_case throughout. Internal client-side state (e.g. the
`playerlist` Map type) is left in camelCase since it is not on the wire.

Renames per packet:
- ARUP: updateType/updateData -> update_type/update_data
- ASS: assetUrl -> asset_url
- AUTH: authState -> auth_state
- CC: charId/charPw -> char_id/char_pw
- CH: charId -> char_id
- CT: isFromServer -> is_from_server
- EE / PE: desc/img -> description/image
- FM / SM: musicList -> music_list
- MA: length -> duration
- MC: charId/track -> char_id/name
- PN: playerCount/maxPlayers/serverDescription -> snake_case
- PV: charId -> char_id
- SC: charData -> char_data
- SI: charCnt/eviCnt/musCnt -> char_cnt/evi_cnt/mus_cnt
- TI: timerId -> timer_id

Kept ID's `player_count` (deliberate divergence from spec's misleading
`player_number`).
Adds a leaner schema-driven API alongside the existing `PacketCodec`:

- `Field { name, type, default? }` — `default` makes the field optional;
  without it the field is required and `decode` throws if missing.
- Free `decode(fields, body)` and `encode(header, fields, packet, asJson)`
  functions handle both positional `HEADER#a#b#%` wire and the JSON
  envelope `{"$header": "HEADER", ...}`.

Wire-format negotiation:

- `encode_packets_as_json` is now a mutable `let` with a setter.
- The `decryptor` packet (always sent by servers on connect) doubles as
  the negotiation packet: `decryptor#JSON#%` flips the client into JSON
  mode for all subsequent outgoing packets.
- `HI` is deferred from `onOpen` to `receivedecryptor`, so the wire
  format is settled before the first outgoing packet.

Split MC into Client/Server forms to mirror MS/ID/VS_* (asymmetric wire:
Server -> Client has `looping`/`channel`, Client -> Server omits them).

The legacy `PacketCodec`/`makeCodec`/`parsePacket`/`encodePacket` path is
preserved so existing packets continue to work; only MC and decryptor
have moved partially. Full migration to come.
Each packet shape can now be declared as a class whose property order is
the wire field order and whose initializers carry both the default value
and (via `typeof`) the type. A `req("type")` sentinel marks required
fields — declared-as the actual TS type at compile time, recognised as
"required + this type" at runtime by the schema walker.

MC's two direction variants are now `MCPacketClient` and `MCPacketServer`
classes; the separate interface declarations and `Field[]` arrays are
gone. The class doubles as the TS type used by handlers
(`receiveMC(packet: MCPacketClient)`).

`decode` and `encode` accept either a `Field[]` array or a class
constructor; `walkSchema` normalises both. The registry's
`PacketBinding` gains a `schema` slot for class-based packets alongside
the existing `fields` and `codec` slots. Dispatcher checks `schema`
first, then `fields`, then `codec`.

Only MC has migrated; everything else still uses `codec`/`fields`.
- `coerce` now applies `decodeChat` (after `unescapeChat`) so fanta-decoded
  string fields arrive at consumers fully decoded — matching what
  `JSON.parse` does natively. `receiveMC` no longer calls `decodeChat`.
- `safeTags` removed from `receiveMC`; it's HTML protection and belongs at
  HTML sinks, not in the parse step. None of MC's sinks need it.
- `looping` is a proper `boolean` defaulting to `false`; the schema infers
  type via `typeof` and the wire still encodes 0/1.
- `encode` recognises the `req()` sentinel as "field unset" so a fresh
  `new MCPacketServer()` with required fields unfilled throws cleanly
  instead of serialising the sentinel as garbage.
- `sendPacketToServer` / `sendPacketToSelf` gain a class-schema overload;
  each schema class declares its wire identity via `static header`. Per-
  packet send wrappers shrink to a one-liner that delegates to it.
- `receiveMC` body simplified: top-level locals dropped, `packet.X`
  inlined, char-lookup uses optional chaining instead of try/catch, and
  the redundant `musicname` reassignment is gone.
- `receiveMC` and `sendMC` are now `function` declarations.
- sendPacketToServer -> sendPacket
- sendStringToServer -> sendString
- receiveServerString -> receiveString

The send/receive verbs already encode direction; the suffix was noise.
Direction markers on codec / class names (`MCPacketServer`, `MSServer`,
etc.) are unchanged — those encode wire-form direction, not method
direction.
Refactors MC to match the target mental model:

- `receiveMC(body)` is the literal inverse of `sendMC(packet)`. Both are
  per-packet, full-pipeline orchestrators looked up via the registry.
- Per-packet `decodeMC(body) -> MCPacketClient` and
  `encodeMC(packet) -> string` make the type/defaults seam explicit.
  Orchestrators only ever call these wrappers, never the generic
  `decode`/`encode` — so a future schema library can plug in by replacing
  just `decodeMC`/`encodeMC`.
- `decode` accepts string OR already-parsed object so the JSON receive
  path doesn't re-stringify.
- `encode` and `decode` share a private `cast()` helper that runs the
  same gauntlet (instantiate class -> overlay -> validate `req()`
  sentinels). Public `applyDefaults` is gone.
- Registry entry for MC is `{ receive: receiveMC, send: sendMC }` — no
  schema, no codec, no parse field. Dispatcher hands the raw body to
  `entry.receive` and lets the per-packet code own decoding.
- `PacketBinding.receive` widened to `(input: any) => void` so the new
  wire-input pattern (MC) and the legacy typed-input pattern coexist
  until the rest of the packets migrate.
`decode` now always takes a string and auto-detects format via the
leading `{` (JSON envelope) or split-on-`#` (positional fanta). Drops
the dual string|object input shape.

`receivePacket` is gone. `receiveString` in JSON mode hands the whole
chunk to `dispatchPacket`, which already auto-detects format via
`readHeader`. One pipeline, one decode entry point.

`receiveMC` / `decodeMC` tightened to `(body: string) => ...`.
Verifies the inverse property: `decode(schema, encode(schema, packet))`
equals the original packet across both wire formats (fanta and JSON) and
both direction-specific schemas (`MCPacketClient`, `MCPacketServer`).
Also covers class defaults being filled in on partial-encode round-trips,
and the "missing required field" throws on both ends.
Class schemas now extend a shared `Packet` base whose `[key: string]:
unknown` index signature lets `cast`/`encode`/`decode` do dynamic
property access without `Record<string, unknown>` escapes. The static
property and the JSON envelope key both use `$header` for symmetry.

The dispatch model splits along role and direction:

  clientReceive / clientSend  -- what the program does as a Client
  serverReceive / serverSend  -- what it does as a Server

Each map is `header -> single function` (no shared codec/binding
object); receive handlers own their decode, senders own their encode.
The legacy `clientPacketRegistry` and `serverPacketRegistry` stay
during the migration as a fallback for unmigrated packets.

Other cleanups in the same pass:
  - cast strict: drops keys not declared on the schema
  - coerce strict: throws on empty/invalid number tokens and on
    booleans other than "0"/"1"
  - decode accepts the raw frame including `#%` (peels terminators)
  - encode emits canonical `HEADER#%` for zero-field schemas
  - `Schema<T>` collapsed to class-only (legacy Field[] arm dropped)
  - walkSchema yields just `{ name, type }`
  - receiveData replaces receiveString/dispatchPacket; one WS frame =
    one packet, TCP-era `%`-splitting + temp_packet buffer gone
  - `json_mode` rename (the flag drives both directions, not just
    encode)

Migrated alongside MC: CC (client -> server, with the gatekeep
preserved) and DONE (server -> client, empty payload). Round-trip
tests added for both, plus a focused `codec.test.ts` exercising the
pure decode/encode mechanics against synthetic schemas.

Tests directory renamed from `__tests__` to `tests`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `encoding.ts` module name overloaded the protocol concept of
"encoding" (the wire-format encode/decode pipeline) with what this
file actually does — escape and unescape string fragments. Renamed
the module and its four exports to describe the operation directly:

  encoding.ts      -> escaping.ts
  escapeChat       -> escapeFanta      (AO1 wire meta-char escaping)
  unescapeChat     -> unescapeFanta    (inverse)
  safeTags         -> safeHtmlTags     (HTML angle-bracket escaping)
  decodeChat       -> unescapeUnicode  (\uXXXX -> char)

`escapeFanta` / `unescapeFanta` now make the wire layer they operate
on explicit; `safeHtmlTags` is HTML-domain, not protocol-domain; and
`unescapeUnicode` describes what the function actually does instead
of being a generic "decode."

Updated all 35 consumer files plus docs in tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now that the old `encoding.ts` module is gone (renamed to
`escaping.ts`), the `encoding` name is free for what these tests
actually cover: the protocol encode/decode pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`AUTHPacket extends Packet` with `auth_state` as `req("number")`; the
legacy `PacketCodec` is gone. `receiveAUTH(body)` inlines its decode
against the class schema. Registry entry moves from the legacy
`clientPacketRegistry` codec branch into the `clientReceive` quadrant
map.

Round-trip tests cover both wire formats and the
missing-required-field error path (encode + decode).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The AO1 spec defines CC's wire as `CC#0#{char_id}#{char_pw}#%` where
position 0 is a hardcoded literal `0`. webAO has historically emitted
`playerID` at that slot in violation of the spec. `char_pw` is also
deprecated (and was always passed as the placeholder string `"web"`).

Introduces `lit()` as a third schema primitive (alongside `req()` and
plain defaults). A literal field is emitted at its declared wire
position on encode, the position is consumed but dropped on decode,
and `cast` strips it from the typed result. The new `Literal` brand
type plus a `Wire<T>` mapped type remove literal-typed fields from
the public-facing `encode` input and `decode` output, so callers
never see `_zero` or `_char_pw_deprecated` in autocomplete.

CC now models the wire honestly: `_zero = lit(0)` and
`_char_pw_deprecated = lit("")` carry the wire positions; the typed
API surfaces just `{ char_id }`. Both senders (pickChar, DONE) drop
the spec-violating player_id and the deprecated char_pw.

`sendCC`'s roster-validity gatekeep moves to `pickChar` — that's a
UI concern about which slots map to real characters, not a protocol
concern.

Alongside this:
  - BB migrated to the Packet schema pattern (message = req("string")).
  - Encoding tests grow a `WithLiteral` synthetic schema and 5 focused
    tests for the lit() mechanic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PV's wire is `PV#{player_id}#CID#{char_id}#%`. The `CID` token at
position 1 is a protocol-mandated padding literal — now declared on
the schema via `_cid = lit("CID")` instead of being hand-built in
the codec template.

Per-packet logic also got too heavy: receivePV was doing all the
character-switch work (loading the ini, rebuilding the emote panel,
probing extensions, toggling the custom button). Extracted to
`src/client/changeChar.ts` so `receivePV` is just decode-and-dispatch.
`iniedit` now calls `changeChar` directly rather than synthesising a
fake PV wire just to retrigger the UI flow.

Side effects:
  - CC's PV-ack synthesis switches from a raw template string to
    `encode(PVPacket, ...)`, picking up the CID literal from the
    schema.
  - PVPacket interface and the legacy PV codec are gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous I/O surface conflated two distinct loopback directions
under a single `sendToSelf` and named its outbound counterpart after
its argument type (`sendString`). The clean shape is direction-named:

  sendData            -- transmit to the server (replay: loopback to
                         the server-side dispatcher)
  receiveData         -- dispatch via clientReceive (we, as client,
                         got this from a server — real or synthesized)
  receiveDataAsServer -- dispatch via serverReceive (we, as server,
                         got this from a client — replay / emulation)

There is no `sendDataToClient` because that's logically equivalent
to `receiveData` — pretending the server sent us something is the
same as us receiving it.

Under the hood `receiveData` and `receiveDataAsServer` are one-line
delegates to a shared `dispatchFrame(data, map, role)`. The role
string surfaces in the "Unknown packet header for X receiver" warning
so it's obvious which side rejected.

`sendToSelf` and `handleSelf` retired. Their three real call sites
(receiveCC, receiveHI, receiveaskchaa) all synthesized server -> client
packets and become `client.receiveData(wire)`. Same for the other
in-handler synth points (RD, RM, RC, ID, changeBackgroundOOC).
`sendString` -> `sendData` everywhere (MC, CC, sendPacket).
`handleReplay` calls `receiveData` directly.

Replay-mode loopback now finally reaches the server-emulation
handlers it was always meant to (HI, CC, askchaa) by routing through
`receiveDataAsServer`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Receive-only (server -> client), simple single-direction shapes:
  ASS, AUTH, BB, BD, CHECK, decryptor, JD, KB, KK, MM, PR, TI

Bidirectional with server-side emulation handlers:
  RC, RM, RD

RC/RM/RD are client-to-server in real operation (the client asks
the server for more characters / music / a ready signal). Their
receive handlers are server-emulation: they fire when the server
side receives the packet from a client and synthesise the SC/SM/
BN+DONE response back. These entries belong in `serverReceive` so
the replay-mode loopback (`sendData` -> `receiveDataAsServer`)
actually finds them. Each gets a matching `sendX` and a `clientSend`
entry, so SI / SC / SM (the consumers that previously called
`client.sendPacket(RC, {})` against the legacy codec API) now use
`client.send.RC({})` / `RM` / `RD` with full autocomplete.

Tally so far: 19 packets migrated (15 in `clientReceive`, 4 in
`serverReceive`, 5 in `clientSend`).

`tests/simpleReceivers.test.ts` batches round-trip tests for ASS,
BD, KB, KK, JD, CHECK -- shapes are uniform enough that one file
covers them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`sendData` previously branched on `mode === "replay"` to decide
whether to write to the WebSocket or loop back through
`receiveDataAsServer`. That conflated "the URL is a replay URL"
with "we should synthesise server responses locally" -- the same
condition today, but conceptually different and not what we'd want
to flip from a test.

Promote the second meaning to an `acting_as_server` instance flag
on Client, initialised from `mode === "replay"` so existing replay
flows behave identically. `sendData` checks the flag directly; the
two remaining `mode === "replay"` checks (the WebSocket setup and
the connection-string fallback) are genuine URL-mode decisions and
stay as-is.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The literal-field mechanism (`lit()`, `Literal` brand, special-case
literal handling in walkSchema/cast/encode/decode) was working but
leaked `_zero?: Literal`-shaped fields into the typed caller API. Even
with the Literal brand making them inert, they still cluttered
autocomplete and required `Wire<T>` mapped types to strip them again.

Replaced with two narrow override hooks at the schema-class level:

  static toArgs?(packet): string[]      // produce fanta args list
  static fromArgs?(args): Partial<T>    // parse fanta args list

The library still owns wire framing (`HEADER#…#%`), terminator handling,
header reading, the JSON envelope path, and the cast/required-field
gauntlet. Only the args-list step is overridable.

Default behavior (the 99% case) goes through `defaultToArgs` /
`defaultFromArgs`, which walk the schema and serialize/coerce in
declaration order — same code as before, just extracted as named
exports so overrides can call them when partially customising.

Schema classes now declare fields with their true TS-level
required/optional shape (`name: type = req()` for required, `name?: type
= default` for optional). Encode's parameter type is `T` directly; no
`Partial<Wire<T>>`. Decode's return type is the new `Decoded<T> =
{ [K in keyof T]-?: T[K] }` — reflects the runtime guarantee that cast
fills defaults / throws on missing required.

CC and PV migrated as the worked examples: literals (`0`, deprecated
`char_pw`, `CID`) sealed inside their respective toArgs/fromArgs;
caller-facing type is just `{ char_id }` for CC and
`{ player_id, char_id }` for PV. No more `_zero` / `_cid` in
autocomplete or decoded results.

Other migrated packets (AUTH, BB, BD, JD, KB, KK, MC, PR, TI,
decryptor, ASS, RC, RD, RM) updated to the new field syntax.

Library-side cleanups:
  - Dropped legacy `makeCodec`, `parsePacket`, `fillDefaults`, `zeroFor`,
    `FieldSpec` — no consumers after the migration. ~55 lines deleted.
  - Replaced per-packet `sendX` boilerplate with `makeSender` /
    `makeServerSender` factories that derive sender signatures from
    the schema. Registries collapse to one-line entries.
  - `sendDataAsServer` checks header against `serverSend` registry
    before routing back to `receiveData`.
  - `client.sendAsServer = serverSend` exposed for typed
    `client.sendAsServer.PV({...})` calls.
  - Added `acting_as_server` instance flag, decoupling sendData's
    loopback branch from `mode === "replay"`.

Tests updated for the new typed signatures; cases that intentionally
pass missing required fields use `as Schema` casts to bypass TS and
verify the runtime gauntlet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
aolib is the in-progress redesign of the packet codec layer as a
standalone, transport-agnostic TypeScript library that owns every AO
packet schema, both wire formats (fanta + JSON), encode/decode, and the
typed session/dispatch surface.

Currently scaffolded:
  - README with usage, API surface, design principles
  - fields.ts with the field primitives (implemented)
  - index.ts with stub session factories (typed surface locked; runtime
    stubbed until schema/encode/decode/transport land)
  - exampleClient.ts / exampleServer.ts — runnable spec for both sides
  - package.json / tsconfig.json — set up for standalone extraction

URL is a local file:// for now since there's no remote yet. When aolib
moves to a real remote (GitHub etc.), edit .gitmodules to point there
and `git submodule sync src/aolib` to update the working tree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tracks aolib bcf0a6a — adds "Protocol reference" section pointing at
https://github.com/AttorneyOnline/docs as the authoritative wire spec
and the convention for when real-world implementations diverge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tracks aolib a01f1c5 — adds the packet() constructor (schema.ts) and
locks the wrong-direction guarantee in the README ("Guarantees"
section) plus the stub session error message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Submodule overhead wasn't worth it at this stage — aolib is still
co-evolving with the rest of webAO, and the bump-pointer-per-change
workflow adds friction for a library that's basically a folder.
Bringing it back into the parent repo so changes are one commit
instead of two.

The aolib commit history is collapsed into this single commit. The
content is exactly what was at aolib's last submodule commit a01f1c5:
README, fields.ts, index.ts, schema.ts, exampleClient.ts,
exampleServer.ts, package.json, tsconfig.json, .gitignore.

The standalone-package metadata (package.json / tsconfig.json /
.gitignore) is kept — when aolib is ready to extract back out into
its own repo, the move is a `git mv src/aolib /elsewhere && git init`
away.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Restructures the field layer to make JSON validation library-level
instead of per-field, adds the missing array primitive, and renames
blob to nested.

fields.ts:
  - FieldKind now granular: "string" | "number" | "boolean" instead
    of one "scalar" tag. Lets the JSON walker dispatch by leaf type
    without per-field methods.
  - Field interface shrinks to { kind, __t, fromFanta, toFanta } —
    JSON codecs gone.
  - Wrappers carry inner: OptionalField has inner: Field<T> so the
    JSON walker can recurse for validation.
  - blob() -> nested(). Better name for the cross-format concept; the
    fanta `separator` stays as fanta-only metadata. JSON sees a real
    nested object.
  - New array(element). Variable-length list; fanta-greedy (consumes
    remaining slots), native array in JSON. Per-token codecs throw —
    the schema walker special-cases arrays and calls element.fromFanta
    directly.
  - custom() keeps optional fromJson / toJson for genuinely weird
    custom fields; default is identity for the JSON path.

json.ts (new):
  - fromJson(field, value, name) switches on kind and validates
    strictly per leaf type, recursing through nested and array.
  - toJson(field, value) is identity for everything except nested /
    array (recurses) and custom (hook if present).
  - The nested separator is NEVER read here — that's a fanta detail.
    JSON sees nested objects natively.
  - Error messages include field paths: 'foo.bar[3]' style.

index.ts: exports updated (nested + array replace blob, new types
re-exported, fromJson / toJson added).

README: folder structure lists json.ts; EI example uses nested();
added SM (array of scalars) and VS_PEERS (array of nested) worked
examples showing fanta-vs-JSON wire shape contrast.

Deferred (not needed yet): discriminated unions (ARUP), records,
JSON Schema interop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the type-level walker and the JSON Schema documentation export.

types.ts:
  - In<S>  — caller input shape: required fields required, optional
    fields optional, wire-only literals stripped, nested + array
    recurse to preserve typed structure.
  - Out<S> — decoded output shape: same as In<S> but optionals lose
    their `?` because cast has already filled defaults. Literals
    still stripped.
  - InFields<F> / OutFields<F> at the fields-record level; schema-
    level In/Out are thin delegations.
  - FieldInValue / FieldOutValue per-field, recursing through
    nested(...) and array(...) so types like
    array(nested({uid: num(), name: str()})) come out as
    { uid: number; name: string }[].
  - Worked examples (commented) demonstrate the expected shapes and
    catch type drift on regression.

jsonSchema.ts:
  - toJsonSchema(schema) emits standards-compliant JSON Schema
    (draft-07) for the JSON envelope shape.
  - Literal fields (CC's `0`, PV's `CID`) are wire-only — omitted from
    the schema, matching what the JSON wire actually carries.
  - nested → object, array → array, optional → default-annotated.
  - custom fields opt in to a `jsonSchema` property; default is `{}`.
  - Consumable by AsyncAPI / Stoplight / Redoc / Ajv; documentation
    path, not the runtime validation path (fromJson still does that).

fields.ts:
  - ArrayField<E extends Field<unknown>> instead of ArrayField<T> —
    carries the element FIELD as generic so In/Out can recurse into
    nested-inside-array shapes.

README:
  - Adds a "Documentation export" section between principles and
    guarantees showing the toJsonSchema output for MC.
  - Folder structure lists jsonSchema.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four new test files cover the foundational layer end-to-end:

fields.test.ts (35 tests):
  - str/num/bool: round-trip ASCII, chat-escape meta-chars
    (#&%$), unicode escapes (\uXXXX), strict validation errors
    include the field name.
  - opt: kind, inner reference, default value, delegated codecs.
  - lit: kind, value preserved for string/number/boolean variants.
    Notes that walkers never invoke its codecs.
  - nested: default and custom separators, packing/unpacking,
    round-trip, sub-field errors propagate with dotted paths.
  - array: kind, element reference, per-token codecs throw with
    a clear message (walker special-cases arrays).
  - custom: codecs stored, errors propagate, optional JSON hooks.

json.test.ts (38 tests):
  - fromJson per kind: strict type validation, descriptive
    error messages with field paths.
  - Optional defaults, literal returns value, nested + array
    recursion with sub-paths in errors (foo.bar[3]-style).
  - Empty arrays, deep nesting, custom field opt-in.
  - toJson identity for leaves, recursion for nested + array,
    custom hook dispatch.
  - Round-trip property for scalars, nested, array-of-nested.

schema.test.ts (5 tests):
  - packet() sets $header, fields. Optional toArgs/fromArgs
    overrides stored when given, absent when not. Empty schemas.

jsonSchema.test.ts (12 tests):
  - draft-07 envelope, $header as const, additionalProperties:
    false. Required list excludes optional and literal fields.
  - Optional fields carry their default. Literals omitted.
  - Nested → type:object recursion. Array → type:array with
    items. Array of nested objects emits recursive items schema.
  - Custom field jsonSchema property opt-in, default {}.

Synthetic schemas only — no per-AO-packet tests yet since the
packets/ directory doesn't exist. Tests are exhaustive over the
primitives themselves; per-packet behavior will be covered by
their own test files once schemas land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OmniTroid and others added 27 commits May 29, 2026 20:58
cast.ts:
  - The required-field gauntlet shared by both wire formats.
  - For each schema field: literal ⇒ strip; optional with no value ⇒
    fill from inner.default; required with no value ⇒ throw.
  - Recurses through nested (sub-fields) and array (elements). Extra
    keys in partial input are silently dropped — the schema is the
    source of truth for shape.

encode.ts:
  - encode(schema, packet, mode) with mode: "fanta" | "json".
  - Single pipeline: cast(packet) → mode dispatch.
  - JSON path:  { $header, ...toJson(field, value) per non-literal }
                then JSON.stringify.
  - Fanta path: schema.toArgs(filled) override if defined, else
                toFantaArgs(fields, filled). Framed as HEADER#…#%
                (or HEADER#% for zero-field schemas).

decode.ts:
  - decode(schema, wire) auto-detects format from the first byte
    (`{` ⇒ JSON; else ⇒ fanta).
  - JSON path:  JSON.parse → match $header → per-field fromJson →
                cast.
  - Fanta path: peel terminator (canonical #%, legacy #, or none) →
                split on # → match header → schema.fromArgs(args)
                override if defined, else fromFantaArgs(fields, args)
                → cast.
  - Header mismatch throws defensively; the session-level dispatcher
    should already route by header, but cheap defense in depth.

index.ts: exports encode, decode, cast, type WireMode.

Tests (73 new, 206 aolib total, 391 full suite):
  - cast.test.ts: required-missing, optional default fill, literal
    strip, extra-key drop, nested + array recursion (each with
    happy + error cases), empty schema.
  - encode.test.ts: JSON envelope shape including literal stripping,
    fanta wire shape including literal positions, defaults filled
    via cast, array greedy expansion, schema-level toArgs override
    in fanta-only path.
  - decode.test.ts: format auto-detect, JSON + fanta paths with all
    terminator variants, literal forgiveness on the wire, override
    dispatch, header mismatch, malformed input, full encode/decode
    round-trips for MC / CC / PV / DONE / SM / VS_PEERS.

The library can now do end-to-end wire conversion for any schema
expressible with the primitives. Next file: session implementation
in index.ts (replace stub with real registries + dispatch + the
wrong-direction guard).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the index.ts stub session with a working implementation.
Both ends of the protocol now compile, encode, decode, and dispatch
end-to-end through aolib alone.

session.ts:
  - makeSession(role, config) returns the shared ServerSession &
    ClientSession runtime. The two factories (server / client) just
    pick the role.
  - .send is a Proxy keyed by header. On every access it:
      1. Looks up the schema in the role's outbound registry
         (server -> c2sSchemas, client -> s2cSchemas).
      2. If absent but present in the *opposite* outbound, throws a
         role-aware wrong-direction error explaining which factory
         the caller should be using.
      3. If absent entirely, throws "no schema registered".
      4. Otherwise returns (packet) => transport.send(encode(schema,
         packet, mode)).
  - .on works symmetrically against the inbound registry; the wrong-
    direction message also tells the caller to use the opposite
    factory's on instead of this one's.
  - receive(wire) is the single dispatch pipeline:
      readHeader -> schema lookup -> decode -> mode-flip check
      -> handler dispatch
    Each failure point routes through one of the five observability
    hooks; default behavior is a console warn / error so callers can
    skip hooks during prototyping without losing visibility.
  - Wire mode is per-session, starts at "fanta", flips to "json"
    when receive sees `decryptor` with value `JSON`. The flip only
    affects outbound — inbound auto-detect always handles both
    formats.
  - close() drops handlers and gates subsequent send / receive.

packets/:
  Initial set of schemas — enough to exercise both directions and
  the mode-flip:
    c2s: HI, CC, MC (request shape)
    s2c: decryptor, ID, PV, BB, DONE, SM, MC (broadcast shape)
  index.ts exports c2sSchemas and s2cSchemas as `const`-asserted
  objects; the session's mapped types pick literal keys from them
  to build .send and .on namespaces. MC has two schemas (request /
  broadcast) with the same wire header but different field sets,
  proving the bidirectional case works.

decode.ts:
  - Adds readHeader(wire) — peeks at just the header without decoding
    the body. Used by the session dispatcher; throws on malformed
    input so the caller can route to onMalformedFrame.

index.ts:
  - Removed the AnyPacket placeholders, the stub session factories,
    and the loose SendRegistry / OnRegistry types — they're all
    obsolete now that session.ts owns the real implementation.
  - Re-exports session factories, packet registries, every schema
    constant, and the existing field / wire-format / type / schema
    primitives.

exampleClient.ts / exampleServer.ts:
  - Rewritten against the strict typed namespaces. Both files now
    typecheck and use only the packets currently in the registry;
    they'll grow as more schemas land.

Tests (35 new, 276 aolib total, 426 full suite):
  - send.<correct-direction>: encodes, includes positional literals,
    fills defaults via cast, refuses on closed session.
  - send.<wrong-direction>: role-aware error explains both why and
    which factory to use instead.
  - send.<unknown header>: separate "no schema registered" error.
  - on.<correct-direction>: dispatches typed packet, strips literals,
    replaces handler on re-registration.
  - on.<wrong-direction>: role-aware error.
  - bidirectional MC: server.send.MC uses request shape, server.on.MC
    receives broadcast shape; mirror for client.
  - receive dispatch: header routing, JSON wire format, ignores after
    close.
  - Each of the five hooks fires only on its specific failure mode;
    a single integration test runs all five paths sequentially and
    asserts the order.
  - Mode-flip: starts fanta, flips on decryptor("JSON"), no-op on
    other decryptor values, per-session (two sessions don't share).
  - Hookless defaults don't throw (console fallback is silenced
    around these tests to keep output clean).

Next: extend the packets/ registry with the rest of the AO surface
(MS, AUTH, ARUP, etc.) — pure additive work now that the session
runtime is settled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the c2sSchemas / s2cSchemas registries to near-complete AO2
coverage. Each packet lives in its own file under src/aolib/packets/
and is registered (under its wire header) into whichever direction
maps apply.

C2S (23 total, 14 new):
  - Lifecycle: askchaa, RC, RD, RM, CH
  - Gameplay: CT, HP, RT, ZZ
  - Evidence: AE, AM, AN, DE, EE, PE
  - Moderation: MA
  - Voice: VS_FRAME, VS_JOIN, VS_LEAVE, VS_SPEAK
  - Already had: HI, CC, MC

S2C (41 total, 32 new):
  - Lifecycle: SI, CHECK
  - Gameplay: CT, HP, RT, ZZ, SP, JD, TI, BN, ASS, AUTH
  - Lists: FM, FA, FL, SC, CharsCheck, RMC
  - Incremental lists: CI, EM, EI, LE
  - Roster: PN, PR, PU
  - Moderation: BD, KB, KK
  - Voice: VS_AUDIO, VS_CAPS, VS_PEERS, VS_JOIN, VS_LEAVE, VS_SPEAK
  - Already had: decryptor, ID, PV, BB, DONE, SM, MC

Deferred to a follow-up:
  - MS — 32-field chat packet with custom enum parsers (Side,
    EmoteModifier, ShoutModifier, TextColor, etc.) and asymmetric
    c2s/s2c shapes (26 vs 32 fields). Needs custom field types.
  - ARUP — area-status update with a discriminated payload that
    varies by update_type; needs a custom field or per-update-type
    union.
  - MM — deprecated music-mode packet, intentionally skipped.

Schema design decisions:

  Asymmetric bidirectional packets (different shape per direction)
  use the established `<HEADER>Request` (c2s) / `<HEADER>Broadcast`
  (s2c) naming, as MC already does. Now applies to CT, VS_JOIN,
  VS_LEAVE, VS_SPEAK.

  Symmetric bidirectional packets (identical shape both ways) get
  a single schema constant registered in both maps. Applies to HP,
  RT, ZZ.

  Legacy-optional fields (BN.position, RT.judgeId, ZZ.target,
  PN.server_description, CT.is_from_server) canonicalise to
  always-present-at-default — emitters always include the slot.
  Decoders on both old and new code handle "missing" and "empty"
  identically, so this is a benign wire-format simplification.

  Nested packets (EI single, LE / CI / EM array-of-nested) use the
  nested() / array(nested()) primitives. Internal `&` separators
  are owned by the field, not by callers. Slight wire-format
  divergence from legacy on EI (trailing `&` dropped); decoders
  tolerate both forms.

packets/index.ts:
  Registries grouped by purpose (handshake / gameplay / lists /
  moderation / voice) for diff readability. All schema constants
  re-exported by name. Direction-keyed maps remain `as const` so
  session's mapped types pick literal keys.

index.ts:
  Re-exports updated to include every new schema constant.

Tests (26 new, 78 aolib total, 452 full suite):
  - Registry shape snapshot: explicit list of expected headers per
    direction. Fails loudly on accidental removal in a future PR.
  - Header-key consistency: every schema's $header matches its
    registry key.
  - Round-trips by shape kind: scalar-only (HP, MA, TI), optional-
    with-default (BN, RT, ZZ, PN), array (FL, VS_PEERS, FA empty),
    nested single (EI), array-of-nested (LE, CI), empty (askchaa,
    CHECK).
  - Session integration: every new c2s/s2c is reachable via the
    expected role's send/on namespace; wrong-direction calls fail
    as before.
  - Bidirectional shape divergence: CT, VS_SPEAK have different
    fields per direction; HP works as one schema in both.
  - Chat-meta escape passes through the registry (BB, CT).

Next: MS and ARUP, which need custom-field plumbing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MS is the largest packet in the protocol (26 fields c2s, 30 fields s2c
including the four paired_* fields). It's also the only packet that
encodes enums and structured offsets, so the design needed two new
pieces:

  1. An enum-as-Field builder using custom() with the legacy parsers'
     fallback defaults baked in. Each enum is exposed as both:
       - a TypeScript enum on the public API (Side.JUDGE, etc.)
       - a CustomField<EnumT> in the schema
     Both ends preserve the underlying value on the wire — string for
     Side ("jud"), number for the rest (1, 2, ...) — and the JSON form
     is the underlying value directly.

  2. A custom offset() field that packs `{x, y}` into one positional
     slot as `x<and>y` with the `&` chat-escaped per the legacy MS
     spec. nested() would have used a raw `&` separator and broken
     wire compatibility, so MS rolls its own codec.

Public API:
  Side, DeskModifier, EmoteModifier, ShoutModifier, Flip, TextColor
  isFullView, Offset
  MSRequest, MSBroadcast

The two schemas share a `HEAD` of 17 fields and a `TAIL` of 8 fields
via spread; only the middle four `paired_*` fields and offset are
direction-specific.

Schema-side leniency: every field except character / message / side /
char_id has an `opt()` wrapper with the legacy fallback default, so
callers can ship a minimal `{character, message, side, char_id}` and
the schema fills in the other 22+ defaults.

Tests (32 new, 110 aolib total, 484 full suite):
  - Enum round-trip: every value of every enum survives both wires.
  - Enum fallbacks: malformed wire (garbage / wrong case / unknown
    int) decodes to the legacy default (SHOWN / WITNESS / NO_PREANIM
    / etc.). ShoutModifier's legacy `4&{name}` form parses to CUSTOM.
  - Minimal input: typing just `{character, message, side, char_id}`
    produces a 26-token wire with every other field at its default.
  - Offset: packs as `x<and>y` on fanta, as `{x,y}` on JSON. Malformed
    offset slot falls back to `{x:0, y:0}` rather than throwing.
  - Asymmetric: MSRequest is 26 slots, MSBroadcast is 30 slots.
    `paired_*` fields round-trip via MSBroadcast.
  - Required-field guard: missing char_id throws at cast.
  - Chat-meta escape: #, &, %, $ in any string field survives fanta
    round-trip.
  - JSON envelope round-trip: all 30 fields preserved with their
    typed enum values.
  - Session integration: server.send.MS uses MSRequest (26 slots),
    server.on.MS receives MSBroadcast (30 slots); mirror for client.

Registry: MS registered in both c2sSchemas (request) and s2cSchemas
(broadcast). The packet count is now 56 of 59:
  - 24 c2s headers (one new: MS)
  - 42 s2c headers (one new: MS)
Remaining: ARUP (discriminated union on update_type), MM (deprecated,
intentionally skipped).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ARUP's payload type depends on its discriminator (`update_type`),
which doesn't fit the standard greedy `array()` primitive. Used a
schema-level `toArgs` / `fromArgs` override:

  - `update_data` declared as a one-slot placeholder CustomField so
    In/Out infer `AreaUpdateData = number[] | string[]`. Its fanta
    codecs throw if invoked — they shouldn't be, the override
    intercepts.
  - The override switches on `update_type`: PLAYER_COUNT maps to
    `number[]` (no escape needed), STATUS / CASE_MANAGER / LOCKED
    map to `string[]` with full fanta escape on encode and unescape
    on decode.
  - Decode tolerates malformed wires the way legacy did: an unknown
    `update_type` falls back to PLAYER_COUNT; a non-numeric entry in
    a PLAYER_COUNT payload becomes 0 rather than NaN.

Enum convention matches MS:
  - `AreaUpdateType` enum exposed at the public API.
  - `AreaUpdateData` payload alias re-exported.
  - `update_type` is a `CustomField<AreaUpdateType>` via the same
    enum-as-field pattern.

JSON behavior is the default-identity path on custom() — the typed
payload is JSON-native (heterogeneous array of primitives), so
encode/decode pass through. No special-casing.

Tests (15 new, 125 aolib total, 498 full suite):
  - Round-trip per update_type, including CM names with chat-meta
    chars (#, &, %, $).
  - Wire format check that `&` in string payloads becomes `<and>`
    on the wire (and survives decode).
  - PLAYER_COUNT payload is not escaped.
  - Empty payload allowed.
  - Unknown update_type and non-numeric PLAYER_COUNT tokens fall
    back to the legacy default.
  - JSON envelope round-trip for every update_type.
  - Session integration: server.on.ARUP delivers the typed packet.

Registry coverage is now 57 of 59 packets:
  - 24 c2s headers, 43 s2c headers.
  - Only MM (deprecated AO1 music-mode) remains intentionally
    unported. AO2 servers gate music mode server-side; the packet
    is documented as no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/client.ts now owns two aolib sessions and registers all handlers
through them; src/packets.ts is reduced to a clean header -> handler
dispatch table.

What changed in the architecture:

  - Client gained `server: aolib.ServerSession` (talks to the remote
    AO server) and `clientSession: aolib.ClientSession` (synthesises
    a server in replay mode). Both are created in `connect()` before
    the WebSocket so handler registration is racing-safe.
  - `server.send.X(packet)` ships outbound. In replay it loops back to
    `clientSession.receive(wire)` instead of the socket.
  - `clientSession.send.X(packet)` is the synthesis-direction sender
    used only in replay; its output always loops to
    `server.receive(wire)`.
  - The old `dispatchFrame` / `clientReceive` / `serverReceive` /
    `clientSend` / `serverSend` registries are deleted. `onMessage`
    just calls `server.receive(msg)` and aolib handles header lookup,
    decoding, defaults, validation, and dispatch — including the five
    observability hooks that route through console by default.
  - `json_mode` / `setJsonMode` are gone. aolib owns wire-mode per
    session and auto-flips on `decryptor({value:"JSON"})`.

src/packets.ts is now the user-facing dispatch table the migration
asked for — one line per packet:

  server.on.MS(handleChatMessage);
  server.on.MC(playMusicChange);
  server.on.BB(showBlockingAlert);
  ...

`registerProtocol(server, clientSession)` is called once from
`client.ts`. The function-call form (`server.on.MS(handleChatMessage)`)
checks the handler signature against the schema at compile time;
wrong-direction registrations are caught both statically and at
runtime.

Per-packet handler renames (domain-action names):

  s2c:
    ARUP    -> applyAreaStatus              MS      -> handleChatMessage
    ASS     -> applyAssetOrigin             PN      -> applyServerInfo
    AUTH    -> applyModAuth                 PR      -> applyPlayerRosterChange
    BB      -> showBlockingAlert            PU      -> applyPlayerFieldUpdate
    BD      -> showBanDialog                PV      -> applyCharacterPick
    BN      -> applyBackgroundChange        RMC     -> applyMusicSeek
    CHECK   -> onServerKeepalive            RT      -> applyTestimonyState
    Cha...  -> applyCharacterAvailability   SC      -> applyFullCharacterList
    CI      -> applyCharacterBatch          SI      -> applyServerCounts
    CT      -> appendOOCMessage             SM      -> applyMusicListBatch
    decr... -> applyEncryptionMode          SP      -> applyCharacterSide
    DONE    -> finishServerJoin             TI      -> applyTimerUpdate
    EI      -> applyEvidenceInfo            VS_AUDIO -> handleVoiceAudio
    EM      -> applyEvidenceListBatch       VS_CAPS  -> applyVoiceCapabilities
    FA      -> applyFullAreaList            VS_JOIN  -> handleVoicePeerJoin
    FL      -> applyFeatureFlags            VS_LEAVE -> handleVoicePeerLeave
    FM      -> applyFullMusicList           VS_PEERS -> applyVoicePeerList
    HP      -> applyHealthBar               VS_SPEAK -> applyVoicePeerSpeak
    ID      -> applyServerIdentity          ZZ       -> showModcallNotice
    JD      -> toggleJudgePanel
    KB      -> showKickAndBanScreen
    KK      -> showKickScreen
    LE      -> applyEvidenceList
    MC      -> playMusicChange

  c2s (replay-mode synthesis only):
    askchaa -> onAreaCharRequest            RC      -> onCharacterListRequest
    CC      -> onCharacterChoose            RD      -> onReady
    CH      -> onClientKeepalive            RM      -> onMusicListRequest
    HI      -> onClientIdentify

Each handler now takes aolib's typed packet (`Out<typeof aolib.X>`)
directly — no more `(body: string)` boilerplate that decoded inside
every handler. aolib decodes once at the dispatch boundary and passes
the typed packet through.

Per-packet wire-format objects are deleted: the legacy `PacketCodec`,
`Packet` base class, `XXPacket` interfaces, and per-file `static
toArgs`/`fromArgs` overrides are gone. Schema + encode + decode all
live in aolib's `packets/`.

Sender call sites collapsed: every `sendXX(packet)` helper is removed.
Calls in `dom/`, `voice/`, and other packet files now go through
`client.server.send.XX(...)` directly. Replay-mode synthesis (CC ack,
RC / RD / RM responders) goes through `client.clientSession.send.X` or
`client.server.receive(...)`.

Updated all remaining consumers of legacy types:
  - `src/viewport/interfaces/ChatMsg.ts` and `defaultChatMsg.ts` now
    type the MS packet as `aolib.Out<typeof aolib.MSBroadcast>`.
  - `src/dom/updateActionCommands.ts` imports `Side` from aolib.
  - `src/dom/onICEnter.ts` rewritten to build the packet against
    `aolib.In<typeof MSRequest>` with aolib's enums.
  - `src/dom/modCallTest.ts` calls `showModcallNotice` directly.
  - `src/dom/changeRoleOOC.ts` uses `aolib.Side` and stopped trying
    to send `SP` (server -> client, not the client's place).

Cleanup:
  - Deleted `src/Packet.ts` (legacy base class).
  - Deleted `src/packets/{AE,AM,AN,MM}.ts` — c2s-only senders / deprecated
    MM had no handler and no remaining importers.
  - Deleted `src/tests/{AUTH,BB,CC,DONE,encoding,MC,packetCodec,PV,
    simpleReceivers}.test.ts` — covered exhaustively by `src/aolib/tests/`.
  - Aolib's SP schema upgraded from `num()` to a `Side`-valued custom
    field so the handler hands a typed enum to `updateActionCommands`.
  - `aolib/index.ts` now also re-exports `ARUP`.

Tests: 355 pass / 0 fail (down from 484 since the legacy duplicates
are removed; aolib's 290 tests still cover the wire surface).
Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the aolib migration, the per-packet handler files in src/packets/
had stale doc comments referencing the old encode/decode flow, dead
`escapeFanta` / `unescapeFanta` imports, vestigial type aliases the
aolib equivalents already cover, and an empty VS_FRAME stub left over
from when it was a sender-only codec file.

Changes:

  - src/packets/MS.ts: deleted the entire vestigial enum + parser
    block (DeskModifier, EmoteModifier, ShoutModifier, Flip,
    TextColor, Side, Offset, parseSide, parseFlip, encodeOffset,
    etc.) — all of these live in aolib now. The file is just the
    handler.
  - Eight call sites that imported the MS enums from
    `../packets/MS` (viewport / dom / client modules) now import
    from `../aolib`.
  - Stripped dead `escapeFanta` / `unescapeFanta` imports from CT,
    ZZ, ARUP, CT, FA, EE, HI, FM, FL — none of them were used in
    the handler bodies. aolib's str field unescaped before the
    handler ever saw the value.
  - addTrack drops its `unescapeFanta` call for the same reason.
  - Trimmed verbose codec-talk doc comments in CI, LE, RT, SC that
    explained the old wire-format dance.
  - Deleted unused `EvidenceData` interface in LE.
  - Deleted `src/packets/VS_FRAME.ts` — empty file with no handler
    and no importers.

src/escaping.ts: dropped `escapeFanta` and `unescapeFanta` since
nothing outside aolib's own internal walker uses them anymore. Kept
`safeHtmlTags` and `unescapeUnicode` — both are genuine handler
utilities (DOM safety + JS-string decoding, not wire-format codec).

src/tests/escaping.test.ts: dropped the two tests that covered the
removed functions; the surviving four tests for `safeHtmlTags` and
`unescapeUnicode` still pass.

Tests: 353 pass / 0 fail (down 2 from the dropped escapeFanta tests).
Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each `src/packets/<HEADER>.ts` wrapper was just a function importing
something from another module and forwarding the typed packet to it.
Moved every handler into the module that already owns its domain, so
the function lives next to the code it actually touches. `src/packets/`
is gone; `src/packets.ts` (the dispatch table) imports from the new
homes.

Handler -> new home:

  Voice (all six VS_*):
    handleVoiceAudio
    applyVoiceCapabilities
    applyVoicePeerList
    handleVoicePeerJoin
    handleVoicePeerLeave
    applyVoicePeerSpeak
                          -> src/voice/voice.ts

  Bans / alerts:
    showBlockingAlert     -> src/client/handleBans.ts
    showBanDialog
    showKickAndBanScreen
    showKickScreen

  Character selection / load:
    applyCharacterPick    -> src/client/changeChar.ts
    applyCharacterBatch   -> src/client/handleCharacterInfo.ts
    applyFullCharacterList
    applyCharacterAvailability
                          -> src/dom/pickChar.ts
    applyCharacterSide    -> src/dom/updateActionCommands.ts
    toggleJudgePanel

  Music:
    applyMusicListBatch   -> src/client/addTrack.ts
    applyFullMusicList
    applyEvidenceListBatch (EM bridges areas + music)
    playMusicChange       -> src/viewport/utils/playMusic.ts (new)
    applyMusicSeek

  Areas:
    applyFullAreaList     -> src/client/createArea.ts
    applyAreaStatus
    applyBackgroundChange -> src/viewport/utils/setSide.ts

  Evidence:
    applyEvidenceInfo     -> src/dom/pickEvidence.ts
    applyEvidenceList

  Player roster:
    applyPlayerRosterChange -> src/dom/renderPlayerList.ts
    applyPlayerFieldUpdate

  OOC log:
    appendOOCMessage      -> src/dom/oocLog.ts (new)
    showModcallNotice

  Handshake:
    applyEncryptionMode   -> src/client/handshake.ts (new)
    applyServerIdentity
    applyServerInfo
    finishServerJoin

  Replay-mode synthesis:
    onClientIdentify      -> src/client/replay.ts (new)
    onAreaCharRequest
    onCharacterChoose
    onCharacterListRequest
    onMusicListRequest
    onReady

  Other servers-pushed UI updates:
    applyServerCounts     -> src/client/fetchLists.ts
    applyAssetOrigin      -> src/client/aoHost.ts
    applyFeatureFlags     -> src/client/featureFlags.ts (new)
    handleChatMessage     -> src/viewport/utils/handleICSpeaking.ts
    applyTestimonyState   -> src/viewport/utils/initTestimonyUpdater.ts
    applyHealthBar        -> src/dom/healthBar.ts (new)
    applyTimerUpdate      -> src/dom/timer.ts (new)
    applyModAuth          -> src/dom/applyModAuth.ts (new)

  Keepalives (truly no-op):
    onServerKeepalive (CHECK)  inlined as `() => {}` in packets.ts
    onClientKeepalive (CH)     inlined as `() => {}` in packets.ts

  Deleted (vestigial empty handlers from c2s-only senders):
    DE, EE, MA, PE -- bodies were comments only; senders go through
    `client.server.send.X(...)` directly, no handler needed.

src/packets/ is deleted entirely. src/packets.ts is now just the
dispatch table — imports from the new homes and registers them via
`server.on.X(handler)` and `clientSession.on.X(handler)`.

modCallTest.ts updated to import showModcallNotice from its new
home (`./oocLog` instead of `../packets/ZZ`).

Tests: 353 pass / 0 fail.
Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The file is no longer a packet directory or a codec registry — it's
the single function that wires every handler into the aolib session.
Renaming it so the filename matches the export (`registerProtocol`)
makes the import line self-documenting and disambiguates from the
unrelated `src/aolib/packets/` directory (which holds the schemas).

The only importer (`src/client.ts`) was updated; aolib's internal
`./packets` references point at `src/aolib/packets/` and are
unrelated.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Handler signatures were leaking the library's internal type machinery:

  (packet: aolib.Out<typeof aolib.MCBroadcast>) => void

That's a public API smell — every handler had to know that `Out` is the
type-level walker, that the schema constant lives behind `typeof`, and
that bidirectional packets pick a `Broadcast` schema. None of that
should leave the library.

Fix: aolib now exports one `XPacket` type alias per schema, derived
from `Out<typeof X>`. Handlers read:

  (packet: aolib.MCPacket) => void
  (packet: aolib.BBPacket) => void
  (packet: aolib.MSPacket) => void

New file: src/aolib/packetTypes.ts. One alias per packet:

  - Unidirectional packets get a single alias: `XPacket = Out<typeof X>`.
  - Symmetric bidirectional packets (HP, RT, ZZ, SP) also get one
    alias since the shape is the same both ways.
  - Asymmetric bidirectional packets (MC, MS, CT, VS_JOIN, VS_LEAVE,
    VS_SPEAK) get two:
      `XPacket` defaults to the Broadcast (s2c) shape — the dominant
      case for client-side code in this repo.
      `XRequestPacket` is the Request (c2s) form.

src/aolib/index.ts re-exports the entire module with `export type *`.

Sweep across handlers (one perl pass): every
`aolib.Out<typeof aolib.X>` got rewritten to `aolib.XPacket`. For the
bidirectional schemas the perl initially produced `XBroadcastPacket`;
a second pass collapsed those to the un-suffixed `XPacket` since the
default is the Broadcast form.

Files touched: addTrack, aoHost, changeChar, createArea, featureFlags,
fetchLists, handleBans, handleCharacterInfo, handshake, replay
(src/client/); applyModAuth, healthBar, oocLog, pickChar, pickEvidence,
renderPlayerList, timer, updateActionCommands (src/dom/);
defaultChatMsg, ChatMsg, handleICSpeaking, initTestimonyUpdater,
playMusic, setSide (src/viewport/); voice (src/voice/).

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`XPacket` covered the receive side; this commit adds `XInput` for the
send side. Same hide-the-mechanic principle: callers who want to type
a packet they're about to send should write

  const p: aolib.MSInput = { ... };
  client.server.send.MS(p);

instead of leaking

  const p: aolib.In<typeof aolib.MSRequest> = { ... };

Aliasing rules now symmetric:

  Unidirectional / symmetric bidirectional packets:
    XPacket = Out<typeof X>     // what we receive
    XInput  = In<typeof X>      // what we send

  Asymmetric bidirectional (MC, MS, CT, VS_JOIN, VS_LEAVE, VS_SPEAK)
  default to the client-side roles:
    XPacket = Out<XBroadcast>   // s2c — what we receive
    XInput  = In<XRequest>      // c2s — what we send
  Server-side roles are also exposed:
    XRequestPacket   = Out<XRequest>   // server-side decode
    XBroadcastInput  = In<XBroadcast>  // server-side send

No existing call sites currently use the In form (the only one,
src/dom/onICEnter.ts, builds the packet inline via send.MS({...})),
so this commit just expands the API surface — no handler signatures
change. The symmetry matters for future code that wants to declare
a typed packet variable for `send`.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit added an XInput alias for every packet. Most were
noise — for unidirectional and symmetric-bidirectional packets,
`client.server.send.X({...})` already infers the input shape; no one
needed `aolib.BBInput` or `aolib.HPInput`.

Trimmed to: XInput aliases only on the six asymmetric bidirectional
packets (MC, MS, CT, VS_JOIN, VS_LEAVE, VS_SPEAK), where the input
shape (Request) genuinely differs from the receive shape (Broadcast)
and a typed-variable declaration could disambiguate. The mirror-
direction aliases (XRequestPacket, XBroadcastInput) are also kept for
server-side users of the library.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`export const handler = (...) => { ... }` was a stylistic miss on my
part during the migration. For top-level named functions there's no
reason to use the arrow form — `export function` is more idiomatic,
hoists, and shows up cleanly in stack traces.

Converted across src/client/, src/dom/, src/viewport/, src/voice/ via
a perl pass that anchors on column-0 `};` (so nested arrow function
terminators inside handler bodies don't get misparsed).

Pre-existing arrow-form setters in src/client.ts (setCHATBOX,
setClient, setSelectedMenu, etc.) are left as-is — not part of the
migration scope and they're trivial state mutators rather than named
behaviors.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same conversion as the prior commit but applied to the pre-existing
setters (setCHATBOX, setClient, setSelectedMenu, setSelectedShout,
setExtraFeatures, setLastICMessageTime) and the `delay` helper. They
weren't part of the migration scope last time around but are
inconsistent with the rest of the codebase now that the handlers use
`export function`.

`delay` was an expression-body arrow (returned a Promise directly);
the block-form perl pattern didn't catch it, so it got a manual edit
to wrap the body in `{ return ... }`.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five exports had multi-line signatures my earlier perl pattern didn't
catch (because the params regex required them on one line). Did these
by hand:

  getFilenameFromPath  (src/utils/paths.ts) -- expression-body arrow
  setAOhost            (src/client/aoHost.ts) -- return-type annotation
  request              (src/services/request.ts) -- async expression-body
  ensureCharIni        (src/client/handleCharacterInfo.ts) -- async
  getFavourites, saveFavourites (src/dom/toggleFavourite.ts)

src/dom/toggleFavourite.ts had broken syntax from a too-permissive
earlier perl pass that matched across function boundaries; rewrote
the whole file to clean function-declaration form.

All export/internal `const X = (...) => {...}` patterns outside the
aolib library are now `function X(...) {...}`.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ver's ID

The handshake is bidirectional on the ID step:
  server -> client: ID#<player_count>#<software>#<version>#%
  client -> server: ID#<software>#<version>#%      (gates the rest)

Most legacy servers (akashi, tsuserver, KFO, Athena) wait for the
client's ID reply before sending anything else. If they don't get it
the join just sits silently after their own ID — no PN, no SI, no
character roster.

The migration to aolib dropped this reply because ID was only
registered as s2c in aolib's schema registry, so `server.send.ID`
didn't exist on the typed surface. webAO worked fine in the meantime
because it never expects the reply (and the handler still synthesises
a local PN for it).

Fix:
  - Add `IDRequest = packet("ID", { software, version })` in
    src/aolib/packets/ID.ts (alongside the existing s2c `ID`).
  - Register `IDRequest` under `c2sSchemas.ID` so `server.send.ID(...)`
    is now typed and dispatched.
  - Move `IDPacket` to the bidirectional-asymmetric section in
    packetTypes.ts and add `IDInput`, `IDRequestPacket`,
    `IDBroadcastInput` to mirror the convention used for MC / MS / CT
    / VS_*.
  - `applyServerIdentity` now sends the c2s ID reply for non-webAO
    servers (mirrors the legacy behavior). webAO branch unchanged.
  - Registry snapshot test updated to include "ID" in c2sSchemas.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ID's first numeric field is the *player slot id* the server is
assigning to the client, not a population count. PN is the packet
that carries the population count. Mislabeling them as the same field
name was misleading — `client.playerID = packet.player_count` read
like a category error.

Field rename across:
  - src/aolib/packets/ID.ts (schema + doc)
  - src/client/handshake.ts (applyServerIdentity now reads
    `packet.player_number`)
  - src/aolib/exampleServer.ts (the example server's `send.ID(...)`)
  - src/aolib/tests/session.test.ts (the send.ID test)

PN's `player_count` field is unchanged — that one's correctly named.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
I picked the wrong name on the previous rename. The spec calls the
ID field `player_id` — that's what server code (akashi/tsuserver) and
the AO2-Client reference implementation both use. Renamed across the
schema, the handler, the example server, and the session test.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sentinel

`applyServerIdentity` had a branch on `serverSoftware === "webAO"` to
decide whether to synthesise PN locally. That was a leak of replay-
mode concerns into a client-side handler — using the server's software
name as a sentinel for "this is our own fake server" only worked
because no real server identifies as "webAO" (that's the client's
name). Tying the handler's behavior to a string the wire happens to
carry is fragile.

Replaced with the same wiring pattern the other replay handlers use:

  - applyServerIdentity always sends our ID reply, no special cases.
    The webAO branch is gone.

  - In replay mode, the outbound ID loops back to
    `clientSession.on.ID`, which now dispatches to a new
    `onClientIdentified` handler in `src/client/replay.ts`. That
    handler synthesises PN exactly like the legacy webAO branch did.

  - `registerProtocol.ts` registers `clientSession.on.ID(
    onClientIdentified)` alongside the other server-side replay
    handlers (HI / askchaa / CC / RC / RD / RM).

In non-replay mode the new c2s ID handler simply doesn't fire — the
outbound goes to the WebSocket and the real server replies with its
own PN. Behavior unchanged on real servers; replay mode arrives at
PN through the registered handler instead of a string check.

Tests: 353 pass / 0 fail. Typecheck: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
session.ts: replace `callHook(...) ?? defaultX(...)` with `if (!callHook(...)) defaultX(...)`. The `??` form was a clever one-liner
but it produces a value that's then discarded, which ESLint flags as
an unused expression. The if/else form makes the intent explicit:
"call the hook if registered; otherwise fall through to the default".

Removed unused eslint-disable directives in session.ts (no-console
isn't an active rule in this codebase, and @typescript-eslint/no-explicit-any
wasn't firing on the `Schema<any>` alias).

Dropped genuinely unused imports:
  - OptionalField in src/aolib/fanta.ts
  - num in src/aolib/packets/ARUP.ts (ARUP uses custom only, not num)
  - bool in src/aolib/tests/decode.test.ts
  - Fields in src/aolib/types.ts

Two indent warnings in fields.ts and three in ARUP.ts were auto-fixed
by `eslint --fix`.

Lint: clean. Typecheck: clean. Tests: 353 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The library was inspecting packet bodies to decide protocol behavior:

  if (header === "decryptor" && packet.value === "JSON") {
    mode = "json";
  }

That's too opaque — the library shouldn't know what `decryptor`
means, and a future protocol change (or a different application of
aolib) shouldn't have to fight the library's hardcoded packet-content
match. Mode is the application's business.

API change:
  - `session.setMode(mode: WireMode): void` on both ServerSession and
    ClientSession. Sets the outbound wire format. Inbound continues
    to auto-detect per packet (first byte `{` = JSON, else fanta) so
    nothing has to coordinate on the receive side.
  - The session's internal `receive` no longer inspects packet
    contents; it only dispatches.

Application change:
  - `applyEncryptionMode` (the decryptor handler) now reads the
    packet's `value` itself and calls `client.server.setMode("json")`
    when the server advertises JSON. Then it kicks off the join with
    HI as before.

Tests:
  - The old `describe("wire-mode flip on decryptor", ...)` block
    became `describe("setMode: explicit outbound-format switching",
    ...)`.
  - New tests cover `setMode('json')`, `setMode('fanta')` (toggle
    back), per-session isolation, and an explicit regression test
    that the library does NOT auto-flip on receiving decryptor.

Tests: 354 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two-state design doesn't justify a stringly-typed argument.
`setJsonMode(true)` reads cleaner than `setMode("json")` and there's
no risk of a typo passing the type-check ("Json" vs "json", etc.).

API change:
  - ServerSession / ClientSession.setMode(mode: WireMode)
    -> ServerSession / ClientSession.setJsonMode(enabled: boolean)
  - Implementation: `mode = enabled ? "json" : "fanta"`.

Call site update:
  - applyEncryptionMode now just does
      client.server.setJsonMode(packet.value === "JSON")
    instead of branching on the string.

Tests / docs / session test block updated to match. Tests:
354 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The abbreviated _cnt suffix was the only place in the aolib registry
using that convention; everything else (player_count on PN,
max_players, etc.) uses the spelled-out _count. Consistency is worth
the rename.

Changes:
  - src/aolib/packets/SI.ts: field names.
  - src/client/fetchLists.ts: applyServerCounts uses packet.char_count
    / .evi_count / .mus_count.
  - src/aolib/tests/packets.test.ts: SI round-trip assertion updated.

Tests: 354 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes for receiving real Athena server traffic in JSON mode:

1. SC: schema was `char_data: array(str())` — modeled after the
   legacy fanta wire where each character was a `&`-packed string.
   Athena (and the modern spec, Packet Reference §SC) sends each
   entry as a typed `{name, desc, evidence}` object, so aolib's
   JSON decoder rejected the array with "expected string, got
   object". Updated to `array(nested({...}))`:

     char_data: array(nested({
       name: str(),
       desc: opt(str(), ""),
       evidence: opt(str(), ""),
     }))

   This is the spec form and works on both wires — fanta packs
   `name&desc&evidence` per slot, JSON carries the object directly.

   `applyFullCharacterList` adapts the typed object back to the
   legacy positional `chargs[]` layout `setupCharacterBasic` expects
   (with an empty blips slot at index 2, since `chargs[3]` is the
   evidence field setupCharacterBasic reads).

2. num() JSON decoder: rejected stringified numbers like `"0"`.
   Athena JSON-stringifies positional values one-to-one, so
   `player_id: num()` would fail with "expected number, got string".
   The decoder now accepts both the native number form and any
   string that parses to a finite Number. Empty string and non-
   numeric strings still throw.

Updated tests:
  - fromJson:number — now asserts ACCEPT for "42", "0", "-3.14";
    new rejects for empty string and non-numeric strings.
  - fromJson:optional and fromJson:nested — sub-field invalid-input
    cases switched from "5" / "3" to "abc" since stringified ints
    are now valid.

Tests: 356 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Athena's JSON envelope sends `music_list` as an array of typed
`{name}` objects — same pattern as the SC fix in the prior commit.
Our schema was still `array(str())`, modeled after the legacy fanta
positional form, so aolib's JSON decoder rejected it with
"expected string, got object".

Spec form (Packet Reference §SM, §FM):

  music_data { name: string }
  SM / FM: music_list = array[music_data]

Both schemas updated to `array(nested({ name: str() }))`. This is the
spec form and works on both wires — fanta carries each entry as one
positional slot with just the name, JSON as an object.

Handlers updated:
  - applyMusicListBatch (SM): iterates `for ({name} of music_list)`
    instead of `tracks[i]`. The legacy trailing-empty workaround
    becomes a simple `if (!name) continue;` skip.
  - applyFullMusicList (FM): same pattern.

The two aolib example files (exampleClient / exampleServer) were
also using the string-list shape; updated to match.

Tests: 356 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same family of legacy-server compat as the num() change two commits
ago. Athena and other servers JSON-stringify positional values
one-to-one, so booleans land as `"1"` / `"0"` strings instead of
native `true` / `false`. aolib's strict bool decode was rejecting
them with "expected boolean, got string", causing the whole packet
to fail and the handler to not fire.

bool() now accepts:
  - native booleans `true` / `false` (as before)
  - string `"1"` -> `true`
  - string `"0"` -> `false`

Everything else still rejects, including:
  - `1` / `0` (native numbers — picky distinct from the string form)
  - `"true"` / `"false"`
  - empty string
  - `null` / other types

Updated the test that previously asserted strict rejection. The
behavior is parallel to num()'s string tolerance, so the two
decoders read the same way for callers debugging a real server.

Tests: 357 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
JSON compat (Athena's string-encoded numbers/booleans, SC/SM/FM as
array-of-object) is still being shaken out, so flipping every client
into JSON when the server advertises it is too aggressive. Adding a
URL-level feature gate so we can roll out per-deployment.

Behavior:
  - Default (no flag): client stays on fanta even if the server
    advertises JSON via decryptor("JSON").
  - With `?json_mode=true`: client flips to JSON on decryptor("JSON")
    the same way as before.

queryParser exposes the new `json_mode: boolean` field.
applyEncryptionMode reads it once at module load and AND-s the flag
into the setJsonMode call.

Once we're confident in the JSON path the gate can come out — and
the diff is small.

Tests: 357 pass / 0 fail. Typecheck + lint: clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@OmniTroid OmniTroid marked this pull request as ready for review May 29, 2026 22:37
@SyntaxNyah SyntaxNyah merged commit 134fd33 into master May 30, 2026
1 check passed
@SyntaxNyah
Copy link
Copy Markdown
Owner

omni the goat @OmniTroid always making the best commits..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants