Skip to content

Core server-health update: MSDP, socket/latency, combat reentrancy, scripting fixes - #276

Open
ahumbert wants to merge 32 commits into
returnoftheshadow:release-frodofrom
ahumbert:core-server-health-update
Open

Core server-health update: MSDP, socket/latency, combat reentrancy, scripting fixes#276
ahumbert wants to merge 32 commits into
returnoftheshadow:release-frodofrom
ahumbert:core-server-health-update

Conversation

@ahumbert

@ahumbert ahumbert commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Core server-health update: MSDP, socket/latency, combat reentrancy, scripting fixes

Summary

A bundled "core server-health" pass covering MSDP, socket/connection
latency, combat reentrancy, and NPC scripting bugs, implemented via
subagent-driven development (fresh implementer + reviewer per task, plus
a final whole-branch review), followed by manual testing and a few
additional bugs found and fixed along the way.

MSDP

  • Fix inverted NOWHERE guard in msdp_room_update() (was a no-op)
  • Sanitize MSDPSendPair/MSDPSendList and the ROOM table's TERRAIN field
  • Populate SERVER_ID's backing storage so SEND SERVER_ID returns data
    instead of an empty string
  • Stop ROOM_EXITS/WORLD_TIME from being double-sent per event (raw
    MSDPSend doesn't clear the dirty flag, so the next per-pulse sweep
    resent them) — switched to MSDPFlush
  • Remove a redundant, unguarded WEATHER push in weather_change() that
    duplicated the already-guarded per-pulse computation in msdp_update()
    and carried a latent out-of-bounds world[] read

Socket / connection latency

  • Enable TCP_NODELAY on the game socket and both proxy legs
  • Don't disconnect on a momentary EAGAIN/EWOULDBLOCK write
  • Cap process_input()'s read-loop iterations per call (one flooding
    connection could otherwise stall every player each pulse)
  • Reap idle pre-login connections, add SO_KEEPALIVE
  • Fix a long-standing bug where the prompt randomly appears prepended to
    game messages under rapid command spam — root cause was prompt_mode
    overloaded for two unrelated purposes; added a dedicated
    bare_prompt_pending field

Combat / scripting

  • Don't skip the element shifted into an erased slot in the skill-timer
    update loop
  • Don't double-fire ON_BEFORE_ENTER on flee/windblast (plus a follow-up
    fix for the IS_RIDDEN branch and an AFF_HAZE re-roll edge case)
  • Don't silently clobber a reentrantly-queued delay (bash-interrupts-cast)
  • Stop get_next_command() from double-advancing past nested
    if/begin/end script blocks, silently skipping content

Test plan

  • Build verified via scripts/rots-docker.sh compile throughout
  • Task-level code review + fix rounds for all 11 tasks, plus a final
    whole-branch review (2 additional issues found and fixed: EAGAIN
    silent buffer-drop, AFF_HAZE re-roll trigger dangle)
  • Full MSDP/telnet protocol structural validation (custom protocol
    monitor: VAR/VAL pairing, TABLE/ARRAY nesting balance) — zero
    structural findings
  • Direct reproduction + fix confirmation for the prompt-race bug via a
    scripted telnet client, byte-for-byte before/after capture
  • MSDP dedup fixes verified on a scratch server: ROOM_EXITS sent
    exactly once per move, WORLD_TIME sent exactly once per mud-hour
    tick
  • msdp_room_update() null-desc guard verified directly (gdb,
    launched as its own child to work around ptrace_scope): forced
    ch->desc to null on a real connected non-NPC character, called
    msdp_room_update() directly, confirmed clean return
  • Manual test pass on a scratch server: MSDP ROOM table (Mudlet),
    LIST/SEND/REPORT/UNREPORT, script vnum spot-checks,
    switch/return (a previously-reported crash source, not reproduced
    after 2 cycles)
  • See docs/superpowers/plans/2026-07-24-core-server-health-update.md
    and the companion manual test checklist for full detail

🤖 Generated with Claude Code

ahumbert and others added 30 commits July 24, 2026 15:19
Guard checked in_room >= 0 (bail on valid rooms) instead of in_room < 0
(bail on the actual invalid case), so the ROOM MSDP table never updated
on normal movement.
MSDPSetString() already ran values through MSDPSanitizeValue(), but
MSDPSendPair/MSDPSendList and msdp_room_update()'s TERRAIN field didn't.
No live caller currently passes attacker/player-controlled text through
these paths, but close the gap for defense-in-depth.
Nagle's algorithm was unconditionally enabled everywhere in the stack
(game accept path, proxy-to-client, proxy-to-game), adding avoidable
latency to small interactive packets.
write_to_descriptor() treated any negative write() return as fatal,
including EAGAIN/EWOULDBLOCK on a non-blocking socket whose send buffer
was momentarily full -- disconnecting players during output bursts on
slow links instead of retrying next pulse.
The do-while read loop had no iteration or byte cap, only exiting on a
newline or EWOULDBLOCK -- a connection that kept sending data with no
newline (flood, huge paste, or a buggy client) could keep one
process_input() call spinning through many read() cycles, stalling
every other player for that pulse (single-threaded select loop).
Connections stuck at the name/password/menu prompts (no char_data yet)
were invisible to check_idling() (which only walks character_list) and
had no SO_KEEPALIVE, so a client that went silently dead at the network
level (dropped wifi, sleep, mobile handoff) held its fd forever.
The restart loop had zero delay between a crash and the next
bin/ageland invocation, so a crash-loop could repeatedly re-trigger the
full boot-time world-load memory spike with no cooldown.
update_skill_timer() erased an expired entry then unconditionally
advanced i via the for loop's own increment, skipping whatever entry
the vector shifted into that slot -- that entry missed a decrement
for one tick.
do_flee() and on_windblast_hit() each call check_simple_move() directly
to test a forced move, then call do_move() for the same transition on
success -- do_move() internally calls check_simple_move() again, firing
ON_BEFORE_ENTER a second time. Any trigger side effect (message,
resource decrement, damage) ran twice per successful flee/windblast.

do_move() is ACMD-macro-generated so its signature can't easily grow a
skip-trigger parameter without touching every command handler; instead
a narrow, self-resetting flag lets the caller's own already-fired
trigger result stand instead of re-firing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
do_flee() sets g_skip_next_before_enter_for = ch unconditionally before
calling do_move(), but do_move()'s IS_RIDDEN(ch) branch short-circuits
into perform_move_mount() without ever calling check_simple_move(ch,
...) again -- perform_move_mount() only re-checks ch's own riders (a
different pointer), never ch itself. When the fleeing character is
itself being ridden, the flag was left set indefinitely and wrongly
suppressed ON_BEFORE_ENTER on a later, unrelated move for the same
character.

Defensively consume the flag right in the IS_RIDDEN branch of
do_move(), rather than only guarding the two call sites that set it
(do_flee(), on_windblast_hit()) -- this centralizes the fix at
do_move()'s other check_simple_move-adjacent entry point so it also
protects any future caller of the flag, not just the two known today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
WAIT_STATE_BRIEF/FULL's interrupt path ran complete_delay(ch) (which
can reentrantly queue a brand-new delay via a follow-up action) then
unconditionally called abort_delay(ch) and overwrote ch->delay with the
interrupting action's data, silently destroying whatever the reentrant
completion had just queued. Detect that case and back off the same way
the existing lower-priority rejection already does, rather than
clobbering it with no indication anything happened.

Minimal correctness patch -- the underlying single-slot ch->delay
design is unchanged; a real fix would replace it with a proper delay
queue, which is out of scope here.
…ed blocks

The for loop's own curr = curr->next increment fired unconditionally
every iteration, including right after the recursive get_next_command()
call for a nested SCRIPT_BEGIN had already advanced curr past that
block's own matching END -- skipping one extra command and sometimes
running past the current level's real terminating END/END_ELSE_BEGIN,
read by players as script sections being silently ignored.
Two review findings against the core-server-health-update bundle:

- comm.cpp (Task 4 EAGAIN fix): write_to_descriptor()'s EAGAIN/sofar==0
  case returned 0, indistinguishable from a normal successful write, so
  process_output() fell through to its buffer-reset code regardless and
  silently dropped that pulse's output instead of retrying it as the
  comment claimed. write_to_descriptor() now returns a distinct -2
  sentinel for that case; process_output() checks for it explicitly and
  returns early (0) without touching t->output/bufptr/bufspace, so the
  exact same buffered text is retried whole next pulse. The main loop's
  process_output() call site and process_input()'s "line too long"
  notice write (the only other call site that inspected the return
  value) were both updated so a deferred write is treated as
  non-fatal/no-op rather than a disconnect.

- act_move.cpp (Task 9 double-fire fix): g_skip_next_before_enter_for
  was only consumed inside do_move()'s check_simple_move() call sites,
  after the AFF_HAZE dizzy-reroll could already change cmd to a
  different direction, or after an early return (no exit, closed door,
  wrong mount) that never reaches check_simple_move() at all. Either
  case left do_flee()/on_windblast_hit()'s suppression flag dangling
  (wrongly suppressing the real destination's trigger, or bleeding into
  a later unrelated move). The flag is now consumed once, unconditionally,
  at function entry, and only re-armed immediately before each
  check_simple_move(ch, cmd, ...) call site, gated on the direction
  still matching what was originally requested pre-haze. The now-
  redundant defensive consume in the IS_RIDDEN early-return branch was
  removed since the entry-point consume already covers it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
…TE_FULL log

Two minor review findings, both against code that only started actually
executing once Tasks 1/2 fixed msdp_room_update()'s inverted NOWHERE guard:

- act_move.cpp: msdp_room_update() dereferenced ch->desc (checking
  ->pProtocol) with no prior null check, even though it's called with a
  spell victim as ch from mage.cpp -- a victim with no descriptor would
  crash. Added an early `if (!ch->desc) return;` guard. Also fixed an
  internal inconsistency: two lines read
  world[ch->desc->character->in_room] for ROOM_NAME/ROOM_VNUM while the
  rest of the function uses world[ch->in_room] for VNUM/NAME/EXITS/
  TERRAIN -- for a switched immortal these can differ, producing
  inconsistent MSDP data in one update. Standardized on ch->in_room.

- utils.h: WAIT_STATE_FULL's new reentrant-delay diagnostic (Task 10)
  used printf() instead of log(), unlike its WAIT_STATE_BRIEF sibling,
  so the message wouldn't reach the syslog operators actually watch.
  Changed to log() to match. The macro's other, pre-existing
  printf("double delay?\n") line is untouched (out of scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
Found and confirmed during manual testing of the core-server-health
bundle (not one of its original 11 tasks) -- a years-old bug where the
prompt randomly appears prepended to a game message, on any line
(chat, combat, room name, etc.), worse under rapid command spam.

Three mechanisms, all fixed:

1. select() reporting a descriptor's socket not currently writable
   (FD_ISSET false) -- process_output() was skipped entirely that
   pulse, but prompt_mode (set moments earlier by command processing)
   was never cleared, so the immediate prompt write fired anyway,
   ahead of the still-buffered, unflushed text. Pre-existing, present
   as long as this select()-loop has existed.

2. The EAGAIN-deferral path added earlier in this same bundle (Task 4)
   -- write_to_descriptor() returning early on a momentarily-full
   kernel send buffer left prompt_mode set too, for the same reason.
   This path didn't exist before Task 4 (hitting it used to disconnect
   the player instead).

3. The actual root cause, confirmed by direct byte-level reproduction
   with a scripted test client, not just static analysis:
   process_output() used prompt_mode for two unrelated purposes at
   once -- "print a prompt after this flush" (set by command
   processing) and "was there a still-unbroken bare prompt from a
   prior pulse, needing a leading newline before new content" (read
   by process_output()). Processing a new command sets prompt_mode=1
   for the first purpose before process_output() reads it for the
   second, masking a real dangling prompt and skipping the leading
   break. This needs no EAGAIN or full send buffer at all -- it
   reproduces on a perfectly healthy connection from ordinary
   successive commands, which is why it's been happening for years.

Fixed (1) and (2) by clearing prompt_mode when a flush is skipped or
deferred. Fixed (3) properly by adding a new, genuinely separate
descriptor_data field, bare_prompt_pending, set only when a bare
prompt is actually written (the three write_to_descriptor() prompt
sites in the "give the people some prompts" block) and consumed
(checked + cleared) by process_output() to decide the leading-newline
break -- fully decoupled from prompt_mode, which keeps its original,
narrower meaning.

Verified via a scripted Python telnet client against a scratch server
instance: captured raw bytes showing the prompt glued to the next
line before the fix, and a proper break after, across repeated rapid
look/score/inventory commands. User separately confirmed against
their live session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
Found during a full MSDP/telnet protocol monitoring pass (not part of
the original core-server-health bundle) -- SEND SERVER_ID returned an
empty string instead of the mud's name.

SERVER_ID is announced once via a raw MSDPSendPair() call inside
PerformHandshake()'s TELOPT_MSDP/DO branch, but that call only writes
to the wire -- it never populates the variable's own backing storage,
which only MSDPSetString()/MSDPSetNumber() do. A later client-issued
SEND SERVER_ID reads that storage via MSDPSend() and got nothing.

Deeper wrinkle hit while fixing this: bMSDP defaults to true at
connection creation, unlike every sibling protocol flag (bMSSP/bATCP/
bMSP/bMXP/bMCCP, all false by default) -- so the
"if (!pProtocol->bMSDP)" guard around the whole announcement block is
dead in practice on a normal DO/WILL exchange. An initial attempt to
add MSDPSetString() inside that guard silently did nothing, since the
guard itself essentially never fires. Also confirmed MSDPSend()
requires a logged-in character with PRF_MSDP set -- this announcement
fires during telnet negotiation, before login, so it can't be used as
a drop-in replacement for the existing pre-login send.

Fixed by moving MSDPSetString(apDescriptor, eMSDP_SERVER_ID, MUD_NAME)
outside/above the dead bMSDP guard so it runs unconditionally on every
DO MSDP, leaving the existing MSDPSendPair() wire announcement
untouched. The sibling ATCP-branch call site already got the same fix
applied correctly, since bATCP genuinely defaults false there and its
guard does fire on first negotiation.

Verified via a scripted MSDP protocol monitor against a scratch server
instance: SEND SERVER_ID now correctly returns "Return of the Shadow",
both via explicit SEND and via the natural per-pulse report sweep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
…ATHER push

ROOM_EXITS and WORLD_TIME were each sent immediately via a raw MSDPSend(),
which doesn't clear the dirty flag, so the trailing MSDPUpdate()/msdp_update()
sweep sent them a second time. Switched both to MSDPFlush(), which sends and
clears dirty in one call.

WEATHER's own immediate push in weather_change() is removed entirely: it
duplicated comm.cpp's msdp_update(), which already recomputes and sends
WEATHER every pulse with a proper in_room bounds check that this call site
lacked (a latent out-of-bounds world[] read if in_room were ever invalid at
the moment weather ticked). Since weather_and_time() runs earlier in the same
pulse as msdp_update(), removing the push adds no observable latency.

Verified on a scratch server: ROOM_EXITS sent exactly once per move across 6
moves, WORLD_TIME sent exactly once per mud-hour tick, zero MSDP structural
(VAR/VAL/TABLE/ARRAY) regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
…rn test)

Marks the MSDP section's NPC-body/null-desc item as verified: initial
wording was inaccurate twice over (possessing a mob via switch never
exercises the guard since is_npc() short-circuits first; ordinary
link-death doesn't null ch->desc either, that assignment is deliberately
commented out in close_socket()). Corrected to the real scenario and
verified it directly by launching the server as gdb's own child (works
around the ptrace_scope restriction on attaching to an already-running
process without root), forcing ch->desc to null on a real connected
non-NPC character, and calling msdp_room_update() directly -- confirmed
it returns cleanly.

Also documents a live switch/return test: the long-standing "return
crashes after switch" report didn't reproduce (tested twice, server
stayed up and kept accepting connections). Notes a plausible-but-unproven
connection to the msdp_room_update() null-desc guard already merged in
this bundle, since the abandoned original body is left in the same
null-desc, non-NPC state that guard protects against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJcAWNHUoaGJEsrz9vduhN
autorun is an environment-specific launcher script that was never meant
to be checked in -- this version isn't compatible with the live
deploy environments. Untrack it (keeping the local copy on disk) and
add it alongside the other runtime-generated entries already ignored
here (autorun4000, switchserver.sh, crashes, syslog, .killscript).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYbvB1G8LzoPCQPSvg1Ljm
…ated moves

do_flee()/on_windblast_hit() set this flag immediately before calling
do_move(), which re-arms it right before its own check_simple_move()
call so that call's ON_BEFORE_ENTER trigger is skipped for the room
they already validated. But check_simple_move() only consumed the
flag at the trigger-check point, and five different early returns --
a special() intercept (mode is SCMD_FLEE, not SCMD_MOVING, so
special() runs before the consume point), GET_POS/PLR_WRITING,
missing dir_option, a nonzero wait_value, or no destination room --
all sit before that point. Any of them firing left the flag armed on
the global, to be silently consumed by the character's next,
completely unrelated move and wrongly suppress its ON_BEFORE_ENTER
trigger (letting them walk past a guard-room check once).

Reachable in practice via do_flee() alone: special() runs twice per
flee (once in do_flee()'s own validation call, once in do_move()'s
internal call), so a stateful or randomized special proc intercepting
the second call is enough to trigger the dangle.

Fixed by consuming the flag unconditionally as the first statement of
check_simple_move(), captured into a local bool and branched on at the
trigger site -- a dangle across early returns is now structurally
impossible, regardless of how the function exits.

Found via an adversarial review of the core-server-health-update PR
(docs/superpowers/plans/2026-07-27-pr-276-adversarial-review-handoff.md),
traced and confirmed against source before fixing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5FB9ut43RMb9oL5bXgcSG
…-circuited prompt writes

Two related gaps in bare_prompt_pending (added in 8ae32c9 to fix the
prompt-race bug) let it get out of sync with what was actually written
to the wire, both reintroducing the glued-prompt bug on the exact
paths that fix was meant to cover:

1. process_output() checks and clears bare_prompt_pending up front,
   placing the leading "\n\r" break only in the local output buffer.
   If write_to_descriptor() then returns -2 (EAGAIN, nothing written),
   the existing code correctly leaves t->output untouched for a retry
   next pulse -- but the flag was already cleared. The retry rebuilds
   output without the leading break, gluing the still-dangling prior
   prompt to the next line. EAGAIN happens under exactly the bursty
   send-buffer pressure where this bug matters most. Fixed by
   restoring the flag in the -2 branch before returning.

2. The three prompt-write sites in the "give the people some prompts"
   block set bare_prompt_pending = true unconditionally, without
   checking write_to_descriptor()'s return. If a prompt write itself
   silently drops (-2) or fails (-1), the flag would still claim a
   bare prompt reached the wire, causing a spurious leading blank line
   on the next flush. Fixed by only setting the flag when the write
   returns 0 (write_to_descriptor()'s actual full-success value here --
   not the >0 a first pass assumed, which never happens for this
   function and would have silently disabled the flag entirely).

Found via an adversarial review of the core-server-health-update PR
(docs/superpowers/plans/2026-07-27-pr-276-adversarial-review-handoff.md),
traced and confirmed against source before fixing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5FB9ut43RMb9oL5bXgcSG
All three set_nodelay(true)? call sites (GameAddr::connect(),
handle_tcp(), handle_ws()) propagated a nodelay failure via ? and
aborted the connection entirely -- disproportionate for what should
be a best-effort latency optimization, and inconsistent with the C
server side, which just perror()s and continues when the equivalent
setsockopt() fails. Changed all three to log a warning and keep
going instead.

Found via an adversarial review of the core-server-health-update PR
(docs/superpowers/plans/2026-07-27-pr-276-adversarial-review-handoff.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5FB9ut43RMb9oL5bXgcSG
…eshadow#276 review doc

The manual test checklist's debug-account section pointed at a
session-scoped /tmp/claude-1000/... path from another machine, which
won't survive between sessions and reads oddly out of context (a
friend's adversarial review of PR returnoftheshadow#276 flagged it, alongside two other
claims about this account -- that it "apparently exists on real
servers" and that the email-verification-bypass steps documented here
are a meaningful exposure -- that don't hold up: lib/accounts/ is
fully gitignored, so the account itself was never committed, and this
was always throwaway local-only test infrastructure for a scratch
server, not a real credential). Replaced the tmp path with a note
pointing back to the durable login-sequence description already in
the doc, and added framing up front so a future review doesn't
re-flag it as leaked credentials.

Also adds the review doc itself (docs/superpowers/plans/2026-07-27-pr-276-adversarial-review-handoff.md),
relocated here from the repo root to sit alongside its companion plan
doc and checklist, same naming convention. Three of its findings are
fixed and pushed (508bec4, a837cd8, 3c865a3); the autorun-backoff
claim (finding 6) was resolved by editing the PR description directly
since the script isn't tracked in this repo; findings 3-5 are open
questions for the author, not yet acted on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5FB9ut43RMb9oL5bXgcSG
…finding 3 correction

Marks findings 1, 2, and both minor items [FIXED] with their commit
hashes; findings 6 and 7 [RESOLVED] with how each was actually closed.

Finding 3 (MSDPSend -> MSDPFlush requiring REPORT) gets a substantive
rewrite, not just a status tag: the original claim -- that clients
which never REPORT would go silent on ROOM_EXITS/WORLD_TIME/WEATHER --
doesn't hold on this codebase. bReport defaults to true for every
variable on every connection (protocol.cpp:323, a separate, already
pre-existing, catalogued bug -- REPORT is a no-op because everything's
already "reported" from connect), so MSDPFlush's bReport gate isn't
actually restricting anything today. Confirmed live, not just
re-derived from source: a scratch-server test client that never
negotiated MSDP and never sent REPORT still received ROOM_EXITS and
the full ROOM table unsolicited, on login and on the first move.
Reclassified RESOLVED / not an issue.

Findings 4 and 5 remain open, needing the author's judgment call, not
further investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5FB9ut43RMb9oL5bXgcSG
do_wizset's field-lookup loop used strncmp (case-sensitive) instead of
this codebase's own strn_cmp, which is used two lines above for the
file/player/mob prefix check. "OB" was the one mixed-case entry in the
fields table, so `wizset <victim> ob <value>` silently failed with
"Can't set that!" while `OB` worked. Swapping to strn_cmp fixes the
comparison generally rather than special-casing the table entry.

Confirmed via a scratch server: ob/OB/Ob all now set and persist
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLFtfZkDgrjesvdeeqd4MQ
Bind-mounted build/runtime artifacts (bin/ageland, *.o, lib/accounts/*,
lib/account_characters/*, etc.) were being created as root inside the
container, showing up root-owned on the host. rots-docker.sh now
exports the current host uid/gid, and docker-compose.yml's `user:`
directive uses them so the container runs as the host user instead of
the image default (root).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLFtfZkDgrjesvdeeqd4MQ
…adow#276 finding 4)

Consolidates the WAIT_STATE_BRIEF/FULL + complete_delay() reentrancy
mechanism, the distinction between the clean-override branch (24/24
live-confirmed) and the untested reentrant-collision branch, and the
open design question of which delay should win when a force-completed
action reentrantly queues its own follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLFtfZkDgrjesvdeeqd4MQ
Author confirmed the design intent: casting should be interruptible by
any attack, resisted only by Battle Mage spec. That system already
exists (does_spell_get_interrupted()/break_spell(), wired into
damage()) but do_bash (and ranger.cpp trap/ambush, olog_hai.cpp
apply_victim_delay) call WAIT_STATE_FULL before damage(), so
complete_delay() always force-fires the interrupted spell before the
AFF_WAITWHEEL interrupt check in damage() ever runs. Reframes the fix
target from WAIT_STATE_FULL's branch logic to call ordering, and notes
the 24/24 "clean" test samples were actually all wrong outcomes under
this reading (spell force-cast instead of interrupted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLFtfZkDgrjesvdeeqd4MQ
do_bash forced complete_delay() on any pending action with lower
priority (a spellcast is 30, bash is 80), which clears AFF_WAITWHEEL
before damage() ever reaches the does_spell_get_interrupted() roll
that ordinary weapon hits and mental attacks already respect
(fight.cpp, clerics.cpp). Bash was the one attack that always broke a
battle mage's concentration regardless of tactics/levels.

Skip the WAIT_STATE_FULL clobber when the victim is mid-cast and their
battle-mage resistance roll succeeds, mirroring the same silent-skip
pattern used at the other two call sites. Bash still lands its own
damage; it just doesn't override a successfully-defended cast.

Live-confirmed with a fresh Wood Elf battle-mage test character: of 6
bash landings caught mid-cast, 4 resisted and the cast completed
normally, 2 didn't and interrupted as before -- matching the existing
resistance-roll behavior already proven at the melee/mental-attack
call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LsV3dGQREFk5uixtFtTzr
Adds the 2026-07-29 re-investigation results to Task 10: confirms
999c167's reentrancy fix is sound (not a regression), and documents
Bonus fix returnoftheshadow#6 -- the separate battle-mage cast-interrupt bypass bug
found, fixed, and live-confirmed the same day (both the resist and
non-resist branches). Also flags the 3 commits now sitting on top of
the pushed PR returnoftheshadow#276 branch that aren't part of it yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LsV3dGQREFk5uixtFtTzr
ahumbert and others added 2 commits July 29, 2026 21:29
WAIT_STATE_BRIEF's copy of the reentrant-delay guard (999c167) has an
explanatory comment; WAIT_STATE_FULL's copy didn't. Minor item from
the PR returnoftheshadow#276 review (finding 4), flagged as fix-without-asking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LsV3dGQREFk5uixtFtTzr
…cope, all resolved

Finding 5 (script.cpp get_next_command nested if/else mis-skip) is a
real, pre-existing limitation, but fixing it risks changing execution
for live scripts -- not a fit for a PR that's now only taking safe
corrections rather than new intended changes. Marked out of scope,
to be picked up separately with its own testing pass if ever done.

With finding 4 already resolved by deferral, this closes out every
item in the PR returnoftheshadow#276 adversarial review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LsV3dGQREFk5uixtFtTzr
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.

1 participant