Skip to content

Packet typing#41

Merged
SyntaxNyah merged 60 commits into
masterfrom
packet-typing
May 28, 2026
Merged

Packet typing#41
SyntaxNyah merged 60 commits into
masterfrom
packet-typing

Conversation

@OmniTroid
Copy link
Copy Markdown
Collaborator

we making it outta skrapegropen with this one

OmniTroid and others added 29 commits May 27, 2026 12:42
Each packet now has a typed interface and codec under src/packets/types/.
The dispatcher in client.ts looks up { codec, handle } per header, decodes
the wire args once into a typed packet, and dispatches to the handler.
Decode and handle are individually try/wrapped so a malformed or buggy
packet can't poison its siblings in the same WebSocket frame.

Senders for ZZ, CT, CC, HP, RT now call codec.encode(...) instead of
hand-building template strings, so FantaCode escape/unescape lives in
exactly one place per packet.

Three no-op stubs (decryptor, CHECK, CH) remain as legacyEntry pending
removal once their behavior is confirmed unused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every packet header now has exactly one file at src/packets/XX.ts holding
its interface, codec, and handler together. The old split between
src/packets/types/XX.ts (interface + codec) and src/packets/handlers/handleXX.ts
(handler) is gone, along with the packetHandler.ts indirection.

The PacketCodec and PacketEntry interfaces live alongside the registry in
src/packets.ts. The three former no-op stubs (CH, CHECK, decryptor) each
get a real per-file packet with a typed codec and noop handler for
consistency; legacyEntry is removed.

The registry and import block are alphabetical (case-insensitive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test:
- New src/__tests__/packetCodec.test.ts dynamically iterates the registry
  and asserts decode -> encode -> decode is a fixpoint for every codec.
  No per-packet fixtures: synthetic args produce some decoded packet, the
  test then proves that packet survives the codec round-trip unchanged.
  Catches field-index mismatches, asymmetric escape/unescape, and most
  inconsistent number/string coercions.
- src/__tests__/isLowMemory.test.ts: expand the `../client` mock to expose
  the full named surface. mock.module() persists across the whole `bun
  test` run and was causing downstream files that statically import
  `client`, `clientState`, etc. to fail at module evaluation.

Spec fixes (from packet/docs audit):
- packets/RT.ts: handle `RT#testimony1#1#%` (since AO 2.9 -- hides the
  testimony indicator) by calling viewport.disposeTestimony.
- packets/MC.ts: doc comment now describes both wire forms correctly per
  the Packet Reference (server-receiver vs client-receiver).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleMS:
- Collapse the nested cccc/2.7/2.8 default branches into a single
  straight-line chatmsg builder. The codec now always populates every
  field with its documented default, so the handler no longer dispatches
  on whether optional groups are present.
- Replace four try/catch blocks around `client.chars[char_id]` with one
  optional-chained lookup + `??` defaults.
- Fix latent type bugs: self_offset/other_offset are now Number()'d to
  match ChatMsg's `number[]` declaration, looping_sfx is Boolean()'d to
  match its `boolean` declaration. Object.assign in the old code hid both
  mismatches.
- Empty offsets with cccc present no longer produce NaN; destructure with
  "0"/"0" defaults so missing axes degrade gracefully.

MS codec:
- Field names now match the spec verbatim (snake_case): `desk_mod`,
  `sfx_name`, `emote_modifier`, `char_id`, `other_charid`, `self_offset`,
  `sfx_looping`, `frames_shake`, etc. Decode/encode field order mirrors
  the spec table.
- Add missing 2.10.2 fields: `blips` (string), `slide` (number).
- Encode emits the full form via a field list + `join("#")`, replacing
  the cascading template-literal concatenation.

Two wire variants:
- `MSPacket` + `MS` codec model the Client-as-receiver form (Server →
  Client). This is what the dispatcher uses.
- `MSPacketServer` (= `Omit<MSPacket, "other_name" | "other_emote">`) +
  `MSServer` codec model the Server-as-receiver form (Client → Server).
  Not registered with the dispatcher; ready for sendIC to migrate onto
  when convenient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…MSPacketClient

handle_ic_speaking now takes MSPacketClient directly. A private
buildChatMsg() helper in handleICSpeaking.ts converts the packet to the
viewport's internal ChatMsg state in one place, replacing the parallel
construction in handleMS.

handleMS shrinks to a pure gatekeeper (duplicate / iniedit / muted / own-
message-reset), then hands the packet to handle_ic_speaking. No more
~35-line struct assembly in the dispatcher's handler.

Drive-by cleanups:
- Drop chatmsg fields nothing read: charid, other_charid,
  frame_screenshake, frame_realization, frame_sfx. The old code set them
  via Object.assign but no consumer ever accessed them.
- Add `additive?: boolean` to ChatMsg. The consumer in
  handle_ic_speaking:181 reads getChatmsg().additive but the field was
  missing from the interface; Object.assign type-widening hid this.
- Type looping_sfx and additive as boolean to match the interface
  declarations and the truthy-check call sites.

Also rename MSPacket → MSPacketClient to complete the symmetry with
MSPacketServer (Omit<MSPacketClient, "other_name" | "other_emote">).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
desk_mod and side are well-defined small sets per the MS spec, but were
typed as `string` and parsed/compared via numeric coercion and string
literals scattered across viewport/handlers. Both are now proper named
enums declared in packets/MS.ts:

  enum DeskMod {
    HIDDEN, SHOWN, HIDE_DURING_PREANIM, SHOW_DURING_PREANIM,
    HIDE_AND_CENTER_DURING_PREANIM, SHOW_DURING_PREANIM_THEN_CENTER,
  }
  enum Side {
    DEFENSE, PROSECUTION, DEFENSE_HELPER, PROSECUTION_HELPER,
    WITNESS, JUDGE, JURY, SEANCE,
  }

The codec parses wire values into the enum (parseDeskMod, parseSide) so
consumers no longer do `Number(safeTags(packet.desk_mod).toLowerCase())`
or `packet.side.toLowerCase()`. Unknown wire values fall back to a
sensible default (DESK_SHOWN / WITNESS) matching the previous default-
case behavior.

`MSPacketClient.desk_mod` / `MSPacketServer.desk_mod` are now `DeskMod`.
`MSPacketClient.side` / `MSPacketServer.side` / `SPPacket.side` are now
`Side`. `ChatMsg.deskmod` / `ChatMsg.side` follow. parseSide is exported
for non-MS consumers (changeRoleOOC parses the <select> value).

Side/DeskMod literals scrubbed from:
- viewport/viewport.ts, viewport/utils/setSide.ts, viewport/utils/
  handleICSpeaking.ts
- client/setEmote.ts, client/setEmoteFromUrl.ts, client/
  handleCharacterInfo.ts
- dom/updateActionCommands.ts, dom/changeRoleOOC.ts, dom/window.ts,
  global.d.ts
- packets/BN.ts, packets/SP.ts

Drive-by bug fixes in setSide.ts: replaced two `String.prototype.
includes()` checks against flat comma-lists ("def,pro,wit".includes(pos))
with an `isFullView(s)` exact-equality helper and an `if (position in
positions)` check. The old form returned true for any substring (e.g.
"f," would match), giving subtly wrong full-view layout in edge cases.

Init-order note: `setSide.ts`, `setEmote.ts`, `setEmoteFromUrl.ts` are in
a circular import with `packets/MS.ts`. Reading `Side.DEFENSE` at module
top-level crashes with "Side is undefined" because the enum isn't bound
yet when the consumer is evaluated. Enum-value access is wrapped in
`isFullView(s)` functions so it only happens at call time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with the existing `emote_modifier` and `shout_modifier` spec field
names. The rename propagates through every place the value flows:

  - Enum: DeskMod -> DeskModifier
  - Codec parser: parseDeskMod -> parseDeskModifier
  - Packet field: MSPacketClient.desk_mod / MSPacketServer.desk_mod ->
    desk_modifier (deliberate deviation from the spec's `desk_mod` in
    exchange for in-codebase consistency).
  - ChatMsg field: deskmod -> desk_modifier
  - char.ini emote object field (built in PV.ts): deskmod -> desk_modifier
  - sendIC parameter / ISender interface: deskmod -> desk_modifier

The full data path -- char.ini → emote object → sendIC → wire → MS packet
→ ChatMsg state -- now uses one name end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
emote_modifier is the next spec field in line for the same treatment as
desk_modifier and side: small well-defined value set, was typed as raw
`number` with `=== 1` / `=== 5` literal checks scattered across the
viewport and preloader.

  enum EmoteModifier {
    NO_PREANIM = 0,
    PREANIM = 1,
    PREANIM_AND_OBJECTION = 2,
    ZOOM = 5,
    OBJECTION_ZOOM = 6,
  }

Spec values 3 and 4 are documented unused; parseEmoteModifier falls back
to NO_PREANIM for any value not in {0,1,2,5,6}, preserving the previous
default-case fall-through behavior.

Field typing:
- MSPacketClient.emote_modifier / MSPacketServer.emote_modifier: EmoteModifier
- ChatMsg.type renamed to ChatMsg.emote_modifier (matches packet name,
  consistent with the deskmod -> desk_modifier rename)
- sendIC / ISender.sendIC emote_modifier param: EmoteModifier

Consumer cleanups: handleICSpeaking, viewport.ts and preloadMessageAssets
now compare against EmoteModifier.PREANIM / .ZOOM / .PREANIM_AND_OBJECTION /
.OBJECTION_ZOOM instead of raw 1/2/5/6.

onEnter initializes emote_mod via parseEmoteModifier(String(myemo.zoom))
to safely coerce arbitrary char.ini values, and uses enum members in the
preanim-toggle branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continues the desk_modifier / emote_modifier / side pattern.

  enum ShoutModifier {
    NONE = 0,
    HOLD_IT = 1,
    OBJECTION = 2,
    TAKE_THAT = 3,
    CUSTOM = 4,
  }

parseShoutModifier strips the optional `&{name}` suffix on the
since-2.8 custom-shout form before parsing. Unknown values fall back to
NONE. (The named-custom-shout part of the wire format isn't honored by
any consumer yet -- preserving the previous lossy behavior.)

Field typing:
- MSPacketClient.shout_modifier / MSPacketServer.shout_modifier:
  ShoutModifier
- ChatMsg.objection renamed to ChatMsg.shout_modifier (consistent with
  the packet/spec name).
- sendIC / ISender.sendIC param objection_modifier: number ->
  shout_modifier: ShoutModifier
- client.selectedShout / setSelectedShout: ShoutModifier
- toggleShout(shout): ShoutModifier (incl. window.toggleShout decl)

Consumer cleanups:
- handleICSpeaking: chatmsg.objection === 4 ->
  chatmsg.shout_modifier === ShoutModifier.CUSTOM.
- preloadMessageAssets: the opaque range arithmetic
  (chatmsg.objection > 0 && chatmsg.objection < 4) is now an explicit
  isStandardShout (HOLD_IT/OBJECTION/TAKE_THAT) flag, with
  isCustomShout for the CUSTOM branch.

Drive-by: defaultChatMsg.ts had stale fields (`objection: 0`, `side:
null`) and was missing `blips`; the `as ChatMsg` cast hid the gaps.
Replaced with a properly typed `ChatMsg` populated from the new enums.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  enum Flip {
    NONE = 0,
    HORIZONTAL = 1,
    VERTICAL = 2,
    HORIZONTAL_AND_VERTICAL = 3,
  }

Spec only documents NONE / HORIZONTAL. VERTICAL and
HORIZONTAL_AND_VERTICAL are non-spec extensions; parseFlip accepts 0-3
and defaults out-of-range to NONE.

Field typing:
- MSPacketClient.flip / .other_flip, MSPacketServer same: Flip
- ChatMsg.flip / .other_flip: Flip
- sendIC / ISender.sendIC flip param: boolean -> Flip
- onEnter converts the flip button-bool to Flip.HORIZONTAL / Flip.NONE

Handler upgrade: the old `flip === 1 ? "scaleX(-1)" : "scaleX(1)"`
hardcoded horizontal-only mirroring. Replaced with a flipTransform(flip)
helper that emits scale(x, y) per-axis, so all 4 enum values render
correctly:
  NONE                    -> scale(1, 1)
  HORIZONTAL              -> scale(-1, 1)
  VERTICAL                -> scale(1, -1)
  HORIZONTAL_AND_VERTICAL -> scale(-1, -1)

Behavior delta: incoming MS with flip=2 or flip=3 now actually mirrors
on the other axis instead of silently falling through to no-flip. No
known AO server emits those values today.

Drive-by: dropped now-redundant Number(flip)/Number(shout_modifier) wraps
in sendIC's wire-string assembly -- both are numeric enums.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec defines it as 0/1, with `1` = realization flash. Modeled as boolean
on MSPacketClient / MSPacketServer; the codec parses `args[14] === "1"`
on decode and emits `Number(p.realization)` (0 or 1) on encode.

ChatMsg.flash renamed to ChatMsg.realization (matches the packet field,
continuing the consistency renames). The viewport's `chatmsg.flash === 1`
check is now a plain `if (chatmsg.realization)`.

sendIC.realization was already boolean -- sender side unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  enum TextColor {
    WHITE = 0, GREEN = 1, RED = 2, ORANGE = 3, BLUE = 4,
    YELLOW = 5, PINK = 6, CYAN = 7, GREY = 8, RAINBOW = 9,
  }

parseTextColor accepts 0-9 and defaults to WHITE for out-of-range or
non-numeric input. Indices align with the existing COLORS array, so
`COLORS[chatmsg.text_color]` keeps working.

Field typing:
- MSPacketClient.text_color / MSPacketServer.text_color: TextColor
- ChatMsg.color renamed to ChatMsg.text_color: TextColor (matches packet)
- sendIC / ISender.sendIC text_color param: TextColor
- defaultChatMsg.color: 0 -> text_color: TextColor.WHITE

Consumer updates: buildChatMsg, viewport.ts, handleICSpeaking lines 444 &
456 all use the new field name. onEnter.ts replaces the raw
Number(<select>.value) coercion with parseTextColor for safe wire-string
-> enum mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The field was string-typed in the codec but always coerced to a number
at consumer sites. Per spec, `0` means no evidence and IDs effectively
start at `1`, so a plain `number` with `0` default matches the semantics
without needing a separate "is evidence present" check.

- MSPacketClient.evidence_id / MSPacketServer.evidence_id: number,
  decoded via num(args[12]) (defaults to 0 for missing/garbage), emitted
  as a number on the wire.
- ChatMsg.evidence renamed to ChatMsg.evidence_id; buildChatMsg drops
  the Number(safeTags(...)) coercion.
- sendIC / ISender.sendIC param renamed; the now-redundant Number(evi)
  wrap in the wire-string assembly is gone.
- viewport.ts and handleICSpeaking.ts consumers updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  export interface Offset {
    x: number;
    y: number;
  }

The wire is the spec form `{x}&{y}`, but `&` is a FantaCode control
character (escape `<and>`), so the actual bytes are `{x}<and>{y}`. The
codec now does the spec-correct dance: unescape (<and> -> &) on decode,
split on the logical `&`, parse to numbers; on encode reverse it
(`x&y` -> escapeChat -> `x<and>y` on wire). Downstream consumers no
longer split on a literal `<and>` -- they just read `.x` / `.y`.

The file-level comment explains the wire-level weirdness so the dance
lives entirely inside the codec.

Field typing:
- MSPacketClient.self_offset / other_offset (and Server form): Offset
- ChatMsg.self_offset? / other_offset?: Offset (was number[])

Consumers in handle_ic_speaking:
- buildChatMsg forwards packet.self_offset / packet.other_offset directly
  (the local `"<and>"`-split with "0"/"0" defaults is gone).
- The horizontal/vertical offset application reads .x / .y instead of
  [0] / [1] with Number() wrapping. Side-based baseLeft (200 / 400 / 0)
  collapsed to one ternary instead of three near-identical switch
  branches.

sendIC.ts's hand-built wire still appends literal `<and>` between the
two separate self_hoffset / self_yoffset params -- it bypasses the
codec. Migration of sendIC to MSServer.encode is out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as the realization conversion: codec decodes via
`args[N] === "1"`, emits via `Number(p.field)` on the wire.

- MSPacketClient.noninterrupting_preanim / MSPacketServer.same: boolean
- ChatMsg.noninterrupting_preanim?: boolean
- viewport.ts: `=== 1` becomes `=== true`

sendIC was already typed boolean -- sender chain unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  MSPacketClient.sfx_looping / MSPacketServer.sfx_looping: number -> boolean

Decode is `args[N] === "1"` (both codecs at their respective indices);
encode is `Number(p.sfx_looping)` to emit 0/1 on the wire.

ChatMsg.looping_sfx renamed to ChatMsg.sfx_looping for consistency with
the packet / spec name. buildChatMsg drops the Boolean() coercion since
both sides are already boolean.

sendIC / ISender.sendIC param renamed, onEnter local var renamed.

Note: the FL "looping_sfx" feature-flag string in sendIC, FL.ts, and
HI.ts is the wire-level capability name and stays unchanged -- it's an
external constant, not our field name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decode: `args[N] === "1"` (both codecs at their respective indices).
Encode: `Number(p.screenshake)` -> 0/1 on the wire.

- MSPacketClient.screenshake / MSPacketServer.screenshake: boolean
- ChatMsg.screenshake?: boolean
- viewport.ts: `=== 1` becomes plain truthy check

sendIC.screenshake was already boolean; unrelated frame_screenshake
(string list) untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decode: `args[N] === "1"`. Encode: `Number(p.additive)` -> 0/1.

- MSPacketClient.additive / MSPacketServer.additive: boolean
- buildChatMsg drops the now-redundant Boolean(packet.additive) wrap

ChatMsg.additive was already typed boolean from the earlier MS refactor.
Sender side was already boolean. Consumer truthy-checks unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decode: `args[N] === "1"`. Encode: `Number(p.slide)` -> 0/1.

slide is a 2.10.2 spec field that no handler / chatmsg / sender consumes
yet; this change is codec-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing `num` helper does `Number(v) || 0`, which collides "char 0"
with "not set" -- a real bug for other_charid where 0 is a valid pair
target and -1 is the AO "no pair" sentinel.

New helper:

  intOr(v: string | undefined, def: number): number

Returns `def` for undefined / "" / non-integer input; preserves 0 as a
real value otherwise. Used for other_charid in both MS and MSServer
codecs with `def = -1`.

After this change:
  "5"        -> 5     (paired with char 5)
  "0"        -> 0     (paired with char 0)
  "-1"       -> -1    (no pair)
  "" / undef -> -1    (defaults to no-pair, was 0)
  "garbage"  -> -1    (was 0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same fix as other_charid: the previous `num` helper conflated char 0
with "not set". `intOr(args[9], -1)` preserves 0 as a real character and
returns -1 for missing/garbage/empty input. The handler's existing
`char_id >= 0` guard already treats negative ids as "not a real
character" -- so this just makes the codec produce the right sentinel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both codecs now use `intOr(args[10], 0)` instead of `num(args[10])`.
Same default behavior in the common case, but `intOr` rejects non-integer
input (e.g. "100.5" -> 0 instead of 100.5), which matches the spec's
"integer milliseconds" definition for the field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
evidence_id: num(args[12]) -> intOr(args[12], 0). Same default (0 = no
evidence) but stricter input rejection: non-integers and empty strings
fall back to 0 instead of being coerced through Number().

After this migration the `num` helper has no callers, so it's deleted.
The remaining MS.ts wire-parsing helpers are `str` (unescape + empty
default) and `intOr` (strict-integer + caller-chosen default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related changes lumped into one commit since they're inseparable:

(1) Wire sendIC into the codec. The old hand-built wire string is gone;
sendIC now constructs an MSPacketServer (or MSPacketClient in replay
mode), calls MSServer.encode / MS.encode, and forwards the result via
sendServer. ISender.sendIC signature gets type upgrades along the way
(DeskModifier; frames_* spelling matches the spec).

(2) Realign MSPacketServer with what real implementations expect, not
what the AO spec docs claim:

  MSPacketServer = Omit<MSPacketClient,
    "other_name" | "other_emote" | "other_offset" | "other_flip">

The spec docs say only other_name / other_emote are absent on incoming,
but KFO-Server (server/network/aoprotocol.py:493-549) and Nyathena
(internal/packet/mspacket.go:21-37, with a comment "Client packet jumps
from OtherCharID directly to SelfOffset") both expect a 26-field
incoming layout. KFO validates strict arg count and drops mismatched
packets with an OOC error -- sending the spec-form 30-field shape would
have broken every IC message against KFO.

Also drop `blips` and `slide` from MSPacketClient (and therefore from
MSPacketServer) entirely. They're 2.10.2+ extensions that no server
consumes via packet.blips/slide today -- ChatMsg.blips comes from
char.ini, not the wire. Outgoing parity: AO2-Client and Nyathena don't
include them in the 30-field Client-receiver shape. Wire shrinks to
exactly 30 args on Server->Client and 26 on Client->Server, matching
the field-by-field layouts the dominant Python (KFO) and Go (Nyathena)
servers document.

Notable side-effects:
- Feature-gated truncation (`extrafeatures.includes("cccc_ic_support")`
  etc.) in the old sendIC is gone. Modern AO servers accept the full
  baseline form; older pre-2.6 servers may not. Worth knowing.
- y_offset is always emitted as `{x}&{y}` via the codec (escaped to
  `<and>` on wire by escapeChat). Old code hard-coded `<and>`.
- Replay branch still uses MS.encode (Client-receiver form) for the
  self-loopback, with `other_*` fields filled in as zero/empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  export const MSClient: PacketCodec<MSPacketClient>  // was MS
  export const MSServer: PacketCodec<MSPacketServer>  // unchanged

The bare name `MS` was ambiguous between "the codec" and "the wire
header"; the codec now names its receiver explicitly. The registry key
in packets.ts is still `MS` because that's the wire-protocol header.

Added a comment near the registry documenting the convention for any
future packet whose wire format differs by direction:

  types:  XXPacketClient (incoming), XXPacketServer (outgoing)
  codecs: XXClient (in the registry), XXServer (imported directly by
          sendXX.ts for outbound)

MS is currently the only packet using the split. CT and ZZ have
direction-conditional wire forms too but their codecs cover both.

Updates: packets/MS.ts (export + doc), packets.ts (import + registry
entry + comment), client/sender/sendIC.ts (replay branch's MS.encode ->
MSClient.encode).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  other_charid -> paired_charid
  other_name   -> paired_name
  other_emote  -> paired_emote
  other_offset -> paired_offset
  other_flip   -> paired_flip

`paired_` reads better than `other_` -- these fields describe the
character being paired *with*, not just "the other one". Wire format
is unchanged (positional); the rename is code-side only, so servers
see no difference.

Touched: packets/MS.ts, viewport/interfaces/ChatMsg.ts, viewport/
viewport.ts, viewport/utils/handleICSpeaking.ts, viewport/utils/
preloadMessageAssets.ts, dom/pairPlayer.ts (prose comment only),
client/sender/index.ts, client/sender/sendIC.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the standalone duplicating-fields interface with an
intersection type:

  export type ChatMsg = MSPacketClient & {
    // Display-safe transforms (shadow shared field names)
    content: string;        // safeTags(decodeChat(packet.message))
    name: string;           // safeTags(packet.character)
    sprite: string;         // safeTags(packet.emote.toLowerCase())
    sound: string;          // safeTags(packet.sfx_name.toLowerCase())
    preanim: string;        // shadows packet.preanim w/ safeTags+lower
    showname: string;       // shadows
    paired_name: string;    // shadows
    paired_emote: string;   // shadows
    effects: string[];      // packet.effect.split("|")

    // Character-derived
    nameplate: string;
    chatbox: string;
    blips: string;          // blip-sound name from char.ini

    // Render-loop state
    parsed?, preloadedAssets?, startpreanim?, startspeaking?,
    preanimdelay?, speed
  };

Every packet field (shout_modifier, side, flip, paired_offset, etc.) is
inherited via the intersection -- no more duplication. buildChatMsg
becomes `{ ...packet, ...extras }`.

Drive-by: chatmsg.snddelay -> chatmsg.sfx_delay (the field was just a
rename of packet.sfx_delay; the intersection makes the packet name
directly accessible).

defaultChatMsg now provides a full MSPacketClient base + the extras --
the type system requires every packet field to be set even at startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old handle_ic_speaking interleaved data mapping, async asset
preloading, markdown parsing, and ~30 lines of DOM mutation in one
function. Split into three single-purpose functions matching the
intended flow:

  handleMS -> prepareICMessage -> renderICMessage

buildChatMsg(packet)         sync, pure mapping
  Spreads MSPacketClient + adds display-safe transforms
  (safeTags/decodeChat/lowercase), char-derived fields
  (nameplate/chatbox/blips), and blankpost handling.

prepareICMessage(packet)     async, all I/O
  Calls buildChatMsg, awaits preloadMessageAssets, computes startpreanim
  / startspeaking flags, strips the `~~` center-align prefix from
  chatmsg.content, applies the sentinel-sound -> effects[2] fallback,
  and parses content into HTMLSpanElements via the AO markdown system
  (with a plain-spans fallback). No DOM mutation.

renderICMessage(chatmsg)     sync, all DOM
  setChatmsg, reset tick state, update every DOM element (eviBox,
  chatBox, nameBox, shoutSprite, charLayers, pairLayers, fg overlay),
  apply flip/offset transforms via flipTransform, run set_side, set
  blip URL, chat_tick().

handle_ic_speaking is now four lines:

  const chatmsg = await prepareICMessage(packet);
  renderICMessage(chatmsg);

Behavior fixes caught during the split:
- `~~` center-align preserved by checking `chatmsg.message.startsWith("~~")`
  at render time (the unstripped raw message is on chatmsg via the
  MSPacketClient intersection). My first cut accidentally collapsed both
  branches to "inherit".
- Dead `if (desk_modifier === undefined)` branch removed -- the field is
  typed DeskModifier (required), so it can't be undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SyntaxNyah
Copy link
Copy Markdown
Owner

omnigoaten wielding his claude prowess once again

OmniTroid and others added 27 commits May 28, 2026 00:37
Drop the wire-format mimicry of building "name&iniediter" only to
split it back into an array. Reorder the range check to read forward.
Remove the dead error-log-without-return for missing chars and inline
the muted check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hoist per-message recreated arrays (sound sentinels, bad-effect names)
to module-level Set constants. Collapse the let charLayers / pairLayers
plus conditional reassignment into single conditional consts.

Share isFullView between handleICSpeaking and setSide -- move it next to
the Side enum in MS.ts so both consumers go through one definition.

Extract applyShout and applyEffectsOverlay (with an applyRainEffect
sub-helper) out of renderICMessage so the orchestrator reads as a
sequence of named steps instead of inline DOM scripting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ISender was a hand-maintained shadow of the sender const that had to
be updated alongside every new sendXX function. Replace with a single
type alias `Sender = typeof sender` so signatures live only in their
own sendXX.ts files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each header now owns its outbound send in the same file as the codec
and receive handler. Renames to match the sendXX-where-XX-is-the-header
convention: sendCharacter -> sendCC, sendCheck -> sendCH,
sendOOC -> sendCT, sendMusicChange -> sendMC. New files created for
outgoing-only headers EE, DE, PE, MA.

client/sender/ now holds only the transport-layer senders that aren't
tied to a single header: sendIC, sendSelf, sendServer. The Sender type
moves into packets.ts so the file is the single home for the public
packet API (codecs + receive registry + send signatures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sender consolidation:
- sendIC moves into packets/MS.ts as sendMS, matching the
  sendXX-where-XX-is-the-header convention used elsewhere.
- sendSelf and sendServer move into client.ts as module-level
  function declarations (hoisted so the cycle with packets.ts
  resolves at module init).
- The full sender aggregator now lives in packets.ts; client/sender/
  is gone entirely.

Low-memory mode removal:
- isLowMemory.ts + its test, the oldLoading state, the SI askchar2
  fallback, and the ID setOldLoading branches are all deleted. The
  whole feature was guarding against problems that don't exist at
  modern packet sizes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every sendXX now takes its typed packet (mirror of receiveXX which
takes one too), so the registry becomes uniform: PacketBinding<T> =
{ codec, receive?, send? }. Send-only headers (DE, EE, PE, MA) get
proper codecs and join the registry; the codec round-trip test
extends to cover them.

Call sites build typed packet literals instead of passing positional
args. The OOC slash-command interception that was buried in sendCT
moves out to onOOCEnter where it belongs as a UI concern.

Transport-level senders move onto the Client class as methods:
client.sender.sendServer/sendSelf -> client.sendToServer/sendToSelf.
They aren't tied to a packet header so the sender aggregator no
longer carries them.

Also fixes a pre-existing bug: sendRT for judgeruling was passing
"judgeruling#1" as the animation field, which the codec then escaped
to "judgeruling<num>1" -- the judge_id was lost in transit. Callers
now pass judgeId separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `client.sender.sendXX` indirection bought nothing once each
sendXX lived next to its codec in packets/XX.ts. Call sites now
import the specific sendXX they need, the same way iniEdit.ts already
imports receivePV directly. Restores symmetry with the receive side
(no `client.receiver` either) and lets the bundler tree-shake unused
senders.

Removes Client.sender field, the sender aggregator object, and the
Sender type. The registry in packets.ts still holds send refs in its
PacketBinding entries -- that's the canonical send/receive index --
but it's no longer reflected onto client.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DOM → TS plumbing is now driven by data attributes:

  <button data-action="addHPP">+</button>
  <button data-action="toggleShout" data-shout="1">!</button>
  <input data-action="onICEnter" data-event="keypress">

One delegated listener per event type (capture phase, so non-bubbling
error/volumechange events work the same as click). The action map in
dom/dispatch.ts maps names to typed handlers; args come from
element-specific dataset reads (data-shout, data-menu, etc).

What this kills:
- Window interface declarations (global.d.ts + dom/window.ts gone)
- Every `window.X = X` registration line in dom files
- HTML inline `onclick="X()"` (92 sites converted to data-action)
- The whole "expose to window so HTML can find it" pattern

Bonus: handlers are now tree-shakeable in principle, and unknown
data-action values log a console warning instead of silently failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
client.html loaded four <script type="module"> entries (client.ts,
ui.ts, dom-bundle.ts, components-bundle.ts) for historical reasons --
none of them needed independent loading. Replace with a single
src/main.ts that imports each module for its side effects.

Drops the one-line wrapper components-bundle.ts and the redundant
dom-bundle.ts (dispatch.ts already pulls in the transitive handlers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ESLint 8 + .eslintrc.js -> ESLint 9 + flat config (eslint.config.js).
@typescript-eslint plugin+parser pair (v6) collapse into the
typescript-eslint umbrella package (v8). Drops the redundant
--ext .ts script flag now that tseslint.configs.recommended handles
the glob.

Two strict rules introduced by v8's recommended preset are turned
off to keep behavior net-zero: no-unsafe-function-type matches the
old v6 default, and no-unused-vars caughtErrors: "none" matches the
old behavior of leaving catch-error names alone. Renamed one stale
ban-types disable comment to its replacement rule name and dropped
one dead bare expression.

Side cleanup: deleted unused root files (.gitattributes, CNAME,
Dockerfile, MENU_THEME_IDEAS.md) and moved happydom.ts into src/
with the bunfig preload path updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On localhost dev (page loaded via http://), prefer the server's
ws/http ports over wss/https even when both are advertised. Mixed
content rules don't apply on plain http, and going through the local
proxy is often only set up on the plain port.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SI.ts builds the character grid at runtime and was still emitting
setAttribute("onclick", "pickChar(N)") strings -- those resolve via
window globals at click time, which no longer exist after the
data-action conversion. Switch to dataset.action/data-char (and
similarly for the favourite button, reading data-charid from the
parent .char-slot).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The three audio categories (sfxaudio/shoutaudio/testimonyAudio) need
to remain as separate <audio> elements -- they're independent
playback channels so a shout can ring out under the next message's
SFX and a testimony fanfare can layer on top. But the user only
needs one knob for "sound effect volume", styled like the existing
Music and Blip sliders.

Drops the three <audio controls> visible widgets in favor of one
<input type="range" id="client_svolume"> that fans out to all three
audio elements. One localStorage key (sfxVolume) replaces three.
The audio elements stay in the DOM (without controls) for playback.

Bonus side-fix: the previous HTML had duplicate data-action
attributes on the same <audio> (changeXXXVolume + opusCheck), which
is invalid HTML -- the second was silently dropped. opusCheck now
fires correctly on audio load errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
href="/favicon.ico" resolved to the deployment domain root, missing
the /packet-typing/ subpath. The browser kept retrying the wrong URL
and the tab spinner never settled. Use the relative path to match
the apple-touch-icon link next to it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hCaptcha-based "2T" enrollment was never wired up to anything
modern -- enableCaptcha was hardcoded false, so the secondfactor div
only existed as a dormant code path. Drops the captcha div + iframe,
the external https://js.hcaptcha.com/1/api.js script tag, the
hcaptcha allowances in the CSP meta, the related field on Client,
and the twofactor.ts hcallback handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chrome holds the tab in "loading" state (spinner, no favicon
rendered) as long as a WebSocket opened before window.load is still
connected. The HAR showed onLoad: null with a 50-second-old WS
entry. Connect() now waits for the load event before opening the
socket, so the spinner resolves once the page itself is done loading.

Bundle the goldenlayout dark theme locally and drop the related
hcaptcha+golden-layout entries from the CSP at the same time -- both
were external requests that contributed to slow loads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deferring client.connect() to the window.load event broke the app:
the original symptom (onLoad: null in the HAR) showed that some other
resource was preventing load from firing at all, so my listener never
ran and the WS never opened. The page sat stuck on the loading screen.

The tab-spinner cosmetic issue is the lesser evil; bringing back
immediate connect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Puppeteer probe traced the spinner-forever bug to an infinite
error-event loop:

  HTML had  <img src="" data-action="imgError" data-event="error">
  ->  empty src fires the `error` event
  ->  the document-level capture-phase listener in dispatch.ts
      catches it and calls imgError(image)
  ->  imgError did `image.src = ""` -- which re-fires `error`
  ->  loop, readyState stuck at "interactive", load never fires

The old inline `onerror="imgError(this)"` got away with it because
`image.onerror = null` neutered the inline handler; the delegated
listener can't be killed that way.

Fixes:
- imgError: use removeAttribute("src") with a guard so it's idempotent.
- charError: skip if the image is already the transparent placeholder.

Surrounding cleanup that surfaced during the bisect:
- Move runtime-swapped <link> stylesheet placeholders out of HTML
  (Bun's bundler strips empty-href links during build) and create
  them in main.ts with an empty `data:text/css,` href so they're
  in a known-loaded state from the start.
- Lazy-load character icons via IntersectionObserver
  (observeCharIcons.ts): setting src on thousands of icons up front
  leaves them all .complete=false forever, contributing to the same
  load-blocking issue.
- Drop the redundant auto-install at the bottom of voiceUI.ts;
  client.ts already calls installVoiceUI() explicitly during boot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Distilled from the headless-Chrome bisect that found the imgError
infinite-loop bug. Loads a URL in puppeteer, reports whether
window.load fires, and if it doesn't, dumps the document state:
incomplete <img> tags, unloaded stylesheets, in-flight network
requests. Faster than guessing next time the spinner sticks.

  bun run probe "<url>"

puppeteer added as a dev dep (only used by this script).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main.ts creates <link> placeholders with `data:text/css,` hrefs so
they settle into a "loaded" state immediately. Without `data:` in
the style CSP directives Chrome logs 5 CSP-violation errors on
every page load (one per placeholder). Functionally harmless --
load still fires -- but it's noise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@SyntaxNyah SyntaxNyah marked this pull request as ready for review May 28, 2026 10:56
@SyntaxNyah
Copy link
Copy Markdown
Owner

Seems to work on your test branch looks good to merge thanks @OmniTroid

@SyntaxNyah SyntaxNyah merged commit 90150df into master May 28, 2026
1 check passed
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