fix(#817): serve player.hold from GET /v1/observe/debug - #844
Conversation
player.hold was computed, mirrored into GameState every controller-stepped frame, and covered by tests since #724 landed, but reached no response body: PlayerState is an internal projection that no handler serialises whole, and observe::get_debug never patched it in the way it does for levitating/run_mode/afloat_stall. An agent grepping /v1/observe/debug for hold found nothing and could only conclude it was not stuck -- the confident-falsehood shape this project ranks above a crash. Adds the missing player.insert("hold", ...) beside run_mode/afloat_stall, a dedicated hold_reaches_the_debug_json_817 test that drives the real router (not serde_json::to_value on the struct), and rewrites the afloat_stall test's vacuous "hold is absent" assertion into one that can actually fail. Corrects the prose that claimed hold was reachable/served before it was: PlayerState::hold's doc, PlayerHoldView's doc, two GET /v1/observe (missing /debug) citations in movement.rs, and the two docs/http-api.md caveats that said hold was not served (now stale). Sweep: no other PlayerState field has this shape; mem_spells, skills, trainer_open/skills, book_text, last_consider, etc. are all reachable via existing routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
|
Paused at wind-down — the first independent review never got underway. A reviewer was dispatched against The branch is clean and unchanged; a reviewer mutation left in a separate scratch worktree |
BLOCKING FINDINGS — first independent review of
|
…laim to what was measured
Comment-only. Zero non-comment lines changed (verified mechanically: every added
and removed line is a `///`/`//` line or blank).
B1 — "mirrored into `GameState` every controller-stepped frame" was false in two
tracked files (`eqoxide-http/src/lib.rs` on `PlayerState::hold`, and the rustdoc
of `hold_reaches_the_debug_json_817`). The second named
`ActionLoop::stream_position`, which contradicts it: that is a NET-thread
function whose own rustdoc says "Runs every tick", and whose write copies a
published `ControllerView` snapshot under a mutex. There are two cadences — the
render thread republishes the view per rendered frame, the net thread copies it
per net tick — and the sentence collapsed them into one that matches neither.
It matters beyond pedantry: it implied the served value is at most one
controller step old, when an idle render loop leaves the net thread faithfully
re-copying a stale view that LOOKS freshly maintained. That is the exact mode
`PlayerHoldView`'s own rustdoc and `docs/http-api.md` warn about, so the new
prose was weakening a correct existing disclosure. `src/movement.rs` already
said "every net tick" correctly; the other sites now agree with it, and the
`get_debug` insert-site comment gained the one missing word ("every NET tick")
for the same reason.
N1 — the insert-site comment claimed an omitted key "would read as 'this client
is too old to report the state'". This PR's own measurement bounds that: only
`contains_key` or a raw-body grep distinguishes absent from null, because
`serde_json` returns `Value::Null` for both — which is precisely what made the
original `is_null()` assertion vacuous (#810 round 2) and why the test has to
use `contains_key`. The comment now says so, and scopes the guarantee to a
presence-checking or grepping reader, matching `docs/http-api.md`.
N2 — the stale `GET /v1/observe/state` citation on the camera-`yaw` field was a
genuine one-line correction, so it is made rather than deferred: `heading_ccw`
is emitted by `get_debug` (`"heading_ccw": player.heading_ccw`), and
`docs/http-api.md`'s own `yaw` row already cites `/v1/observe/debug`. The doc
now asserts twice that no `/v1/observe/state` route exists; this was the last
in-tree contradiction of that.
No doc file touched, so `eqoxide-net`'s `include_str!("../../../docs/http-api.md")`
input is unchanged. Blast-radius crates re-run anyway: `eqoxide-http` 270 passed
and `eqoxide-net` 383 passed, byte-identical to the pre-change baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
Round 2 applied — head
|
| before | after | |
|---|---|---|
eqoxide-http lib |
ok. 270 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out |
identical |
eqoxide-net lib |
ok. 383 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out |
identical |
eqoxide-http doc-tests |
ok. 0 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out |
identical |
eqoxide-net doc-tests |
ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out |
identical |
diff of the two runs' test result: lines (finish times stripped) is empty. ^running [0-9]+ tests?$ headers = 4, ^test result: lines = 4 in both runs — equal, so no binary was lost.
^error/^warning lines in stderr: 0. The after-run shows 2 Compiling eqoxide-(http|net)
lines, so it is not a cached artifact. Compile sentinel (compile only, NOT run-completion):
Finished `test` profile [unoptimized + debuginfo] target(s) in 10.99s.
bash scripts/check-no-local-detail.sh → OK — no forbidden patterns in tracked files.
Not established
- No live run, and none added: nothing here changes runtime behaviour, and your review already
recorded that the E2E leg was not discharged by you either. That gap is unchanged by this round. - I did not run the full workspace for this PR, deliberately — the change is comment-only and
the two figures above are before/after on the same tree. I am not repeating any workspace number
I did not measure in this round. - Your open honesty question about
PlayerHoldView's "never stale-because-idle" (a#summon
landing on the net thread while the render loop idles, versus the hold being cleared only in
CharacterController::step) is untouched here and I did not attempt to settle it. It is
pre-existing prose and it deserves its own issue; I have not filed one. - The local branch ref still points at
4f19b5bbecause it is checked out in another worktree; I
pushed by refspec (HEAD:fix-817-hold-observable).originis correct.
|
Correction to my comment above — one figure was wrong, and I am not leaving it standing. I wrote that The third is the corrected |
Closes #817.
The defect
player.holdwas computed by the movement controller, mirrored intoGameStateon every nettick by
ActionLoop::stream_position(that function's own rustdoc: "Runs every tick"), from aControllerViewsnapshot the render thread republishes on every rendered frame — so the mirroris as fresh as the last published frame, not as fresh as the last net tick — and covered by
unit tests since #724 landed — and reached no HTTP
response body anywhere in the workspace.
PlayerStateis an internal projection;get_debugin
crates/eqoxide-http/src/observe.rshand-builds itsplayerobject with aserde_json::json!literal and patches extras in via
player.insert(...)— that mechanism is howlevitating,run_mode, andafloat_stallreach a client. Nothing did that forhold. An agent that greps/v1/observe/debugforholdfound nothing and could only conclude it was not stuck — a confidentfalsehood produced by a 200.
The fix
One
player.insert("hold".into(), serde_json::json!(player_hold))inobserve::get_debug,next to the existing
levitating/run_mode/afloat_stallinserts, following the exact same"always present, explicit
nullwhen healthy" contract.crates/eqoxide-http/src/observe.rs,crates/eqoxide-http/src/lib.rs.Per the issue's acceptance bar: the workspace suite was already green with the field unreachable,
so green tests are not evidence here, and a test that does
serde_json::to_value(&player_state)proves nothing about what a client can actually receive.
Launched the built PR binary on my allocated character/port (
config-fayverify.yaml,API port 8796) and hit the real, running
GET /v1/observe/debug. The character turned out toalready be genuinely embedded in world geometry (unrelated pre-existing state, not staged for this
PR), so this is an authentic non-null
holdfrom a live client, not a synthetic fixture:{ "camera": { "azimuth_deg": 90.0, "elevation_deg": 20.0002384185791, "focus": [-2190.5, 902.125, 2.75], "mode": "auto_follow", "radius": 80.0 }, "common_assets_failed": null, "frame_profile": { "egui_ms": 0.0, "frame_ms": 0.0, "render_ms": 0.0, "scene_ms": 0.0, "smooth_ms": 0.0, "submit_ms": 0.0, "total_ms": 0.0, "update_ms": 0.0 }, "last_consider": null, "model_sync_dead": null, "nav_blocked_by": null, "nav_declined_pads": null, "nav_goal": null, "nav_goal_id": 0, "nav_local": null, "nav_local_planner_dead": false, "nav_support": null, "nav_tier": null, "nav_tight": null, "net_thread_dead": null, "player": { "afloat_stall": null, "casting": null, "class": "Ranger", "coin_verified": true, "connected": true, "crossing_pending_ms": null, "currency": { "copper": 0, "gold": 0, "platinum": 0, "silver": 0 }, "dead": false, "died_ago_secs": null, "guild": "", "guild_id": 0, "guild_rank": 0, "heading_ccw": 180.0, "heading_cw": 180.0, "hold": { "detail": "the character is EMBEDDED in world geometry. The client's push-out search found nowhere it can legally stand, and it has no recovery position to fall back to (a position discontinuity — a summon, a large server correction — supersedes that history, #724). Physics is frozen: every movement command will be accepted and produce NO motion, in any direction. This will not clear on its own — the client keeps streaming this position and the server agrees with it, so no further correction is coming. Ask a GM to move the character (#goto/#summon), or zone out.", "held_secs": 41.995487213134766, "reason": "embedded_no_recovery" }, "hp": 97, "hp_max": 97, "hp_pct": 100.0, "killed_by": null, "last_cast": null, "last_packet_age_ms": 1304, "last_send_error": null, "last_send_error_age_ms": null, "last_world_response_ms": 1304, "level": 4, "levitating": false, "link_age_ms": 1304, "mana": 60, "mana_max": 60, "mana_pct": 100.0, "name": "<redacted>", "nav_reason": "zoned", "nav_state": "idle", "pos": [-2190.5, 902.125, 2.75], "position_provisional": false, "race": "ELF", "reliable_abandoned": 0, "run_mode": true, "send_deferred": 0, "send_failures": 0, "send_failures_unretried": 0, "send_starved": false, "send_wouldblock_rescued": 0, "server_corrections": 0, "session_drop": null, "snapshot_age_ms": 5, "spawn_id": 433, "target_attitude": null, "target_con": null, "target_hp_pct": null, "target_id": null, "target_level": null, "target_name": null, "world_responsive": true, "xp_pct": 100.0, "zone": "steamfont", "zone_in_failed": false }, "zone_assets": { "collision_loaded": true, "detail": "terrain meshes are uploaded and the collision grid is built: what the client reports about this zone's geometry is the real zone.", "player_zone": "steamfont", "reason": null, "state": "ready", "status": null, "terrain_meshes": 24, "zone": "steamfont" }, "zone_cross_best_effort": null, "zone_cross_stopped": null }player.holdis present, non-null, and correct:reason: "embedded_no_recovery",held_secsadvancing (measured 41.995487213134766s at capture time — I polled twice a few seconds apart and
watched it advance in wall-clock proportion, confirming the render loop is live and stepping, not
a frozen stale value), and
detailnaming the actual predicament.playernow carries 57 keys(was 55 in the issue's original measurement — the +2 is
holdplusafloat_stall, which landed onthe same review chain slightly after that count was taken). Client exited cleanly via
POST /v1/lifecycle/exit; port 8796 confirmed free afterward.What this establishes and what it does not: this proves
holdis reachable, present, andcorrectly valued through the real router on a real running client, for the
embedded_no_recoverycase observed live. It does not itself fix, and is not claimed to fix, the fact that this
particular character is currently stuck — that is unrelated pre-existing state on my allocated
test character and is out of scope for this issue.
Test changes
hold_reaches_the_debug_json_817incrates/eqoxide-http/src/observe.rs, modeled directlyon
afloat_stall_reaches_the_debug_json_801: drives the real router viadebug_json(notserde_json::to_valueon the struct), checks the key is present-and-null when healthy, andchecks both
ControllerHoldReasonvariants round-trip correctly (reason,held_secs,detail).afloat_stall_reaches_the_debug_json_801. Corrected in review round 2 —the original wording here inverted which assertion was vacuous. The check that had been
measured vacuous (fix(#801): publish the trapped-swimmer stall to the agent, not to the log #810 round 2) was the ORIGINAL
assert!(v["player"]["hold"].is_null()): anabsent key and an explicit
nullboth read asValue::Nullthroughserde_json, andholdwasnot a key in this body at all, so that assertion could never fail. What fix(#801): publish the trapped-swimmer stall to the agent, not to the log #810 replaced it with —
assert!(!player.contains_key("hold"), …)— was a deliberate, non-vacuous tripwire set to goRED exactly when player.hold is never served by any endpoint — the field is maintained, tested, and invisible to every agent (#724) #817 landed; its message said so verbatim ("this test deliberately trips on it,
because the claim it exists to pin only becomes checkable now"). It worked as designed, and this
PR is what trips it. Replaced with
contains_key("hold")(must be present) +is_null()(astall alone must not read as a hold).
Mutation check (remote builder,
-p eqoxide-http --lib, restored from an md5sum-verified copybetween runs):
270 passed; 0 failed(up from the pre-PR baseline of269 passed— the new test).player.insert("hold".into(), …)line fromget_debug→ 268 passed, 2 failed:hold_reaches_the_debug_json_817(at thecontains_keyassertion, exactly as designed) andafloat_stall_reaches_the_debug_json_801(at its rewritten disjointness check, which also nowdepends on
holdbeing served). Restored the file and verified the restore against thepre-mutation md5sum before re-running.
Full workspace suite
rbuild <worktree> test --workspace --locked --no-fail-fast:Finished \test` profile [unoptimized + debuginfo] target(s) in 24m 04s` (compile sentinel,not the run time — the run itself was fast; the box had a cold cache for this worktree).
running [0-9]+ tests?headers: 55.test result:lines: 55. Equal — no lost binary.test result:line.test result: FAILEDlines; zeroerror[lines.Sweep — other
PlayerStatefields with the same shapeChecked every field on
PlayerStateagainst whatget_debugand the other/v1/observe/*and/v1/trainer/*routes actually read off it:mem_spellsGET /v1/observe/spellsskillsGET /v1/observe/skillstrainer_open,trainer_skillsGET /v1/trainer/list(open/skills)book_textGET /v1/observe/item_textlast_considerlast_considerinGET /v1/observe/debugplayer.insert/literal listGET /v1/observe/debug'splayerobjectFound no other field with the "maintained + tested + unreachable" shape.
holdwas the onegap. Not expanding this PR's scope beyond it.
Prose audit — doc/comment claims that
holdis observable/servedGrepped the tracked tree for
player.hold,player_hold,PlayerHoldView, andGET /v1/observenear
hold. Found and fixed:crates/eqoxide-http/src/lib.rs—PlayerState::hold's doc claimed "Noskip_serializing_if:the key is ALWAYS present" without qualifying serialization of what — true only of a direct
serialization of the struct, which nothing in the tree does. Rewrote to state the distinction
explicitly and point at this PR's insert as what actually makes the key reachable.
crates/eqoxide-http/src/lib.rs—PlayerHoldView's doc namedplayer.hold (#724)withoutsaying it was served anywhere. Added the "served as
player.holdbyGET /v1/observe/debug"sentence, matching the pattern already used on
PlayerAfloatStallView.src/movement.rs— two comments cited the route as bareGET /v1/observe, which is not aregistered route (confirmed 404 in the issue). Corrected both to
GET /v1/observe/debug.docs/http-api.md— theholdsection's "The key is always present" sentence had no routescoping (afloat_stall's equivalent sentence does). Scoped it to
GET /v1/observe/debug'splayerobject, matching the afloat_stall pattern, and noted player.hold is never served by any endpoint — the field is maintained, tested, and invisible to every agent (#724) #817 as what made it true.docs/http-api.md— twoafloat_stallcaveats explicitly said "holdis not currently servedby any handler — player.hold is never served by any endpoint — the field is maintained, tested, and invisible to every agent (#724) #817" / "not served by any handler today — player.hold is never served by any endpoint — the field is maintained, tested, and invisible to every agent (#724) #817". Both are now stale (that's
what this PR fixes); updated them to state the current, corrected relationship instead of
deleting the history outright.
I did not find any doc/comment asserting
holdwas reachable that turned out to still be falseafter this PR — the remaining
player.hold/player_holdreferences I checked (incrates/eqoxide-core/src/game_state.rs,crates/eqoxide-ipc/src/lib.rs,crates/eqoxide-net/src/action_loop.rs,src/zone_in.rs) either cite the correctGET /v1/observe/debugroute already or don't make a reachability claim at all (internalplumbing comments).
Not established by this PR
PlayerStatefor the same shape (out of scope per theissue).
(
embedded_no_recovery) — that is pre-existing state on my allocated account, unrelated to thischange, and not investigated further here.
Do not merge — awaiting independent review per the agent-fleet process.