feat(rust): mod configuration from the site, and an editor that will not rewrite a float #9

Merged
whitlocktech merged 1 commits from feat/phase-7b-config into edge 2026-09-22 15:02:20 +00:00
Member

Phase 7b, the website half of R18 — Admin → Rust mod config. As built: PLAN.md §21.

The trap this is mostly about

JavaScript cannot tell 1 from 1.0. JSON.parse('{"Rate":1.0}') is 1, JSON.stringify writes 1, and both mod frameworks deserialize a config into typed C# classes — so a naive read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that throws at load does not come back. Gather rates and multipliers are exactly the fields at risk.

server/configEdit.js never parses, mutates and re-serialises. It records the source span of every value and splices literals into them from the end of the document backwards, so everything a save did not touch is byte-identical — indentation, key order, and the .0 on a float. A number's new value travels as the text an admin typed, from the browser to disk, and never becomes a Number anywhere (D35/D36).

Its suite asserts the failure as well as the fix, and a live walk wrote 2.50 into a real ZoneManager.json while 500.0 and a null stayed exactly as they were.

The rest of the surface

  • Two tiers, one route: edits (the generated form, type-preserving) or text (raw JSON). A version from the host must come back with a save, or it is a 409 with the current file attached so a person can merge rather than retype.
  • The bridge's own config is editable with three keys lockedHost, Port, ServerId — in the form and in the raw tier, which submits a whole document and so is checked by comparing literals (D38).
  • Credentials render masked with a Show; the raw tier shows them (D37); rust_config_writes never records the values, and does record the refused and the rolled-back — an operator asking why a setting is not what they set needs to see that somebody tried.
  • A rollback comes back 200 with report.rolledBack and the server's own log line. It is a round trip that worked carrying bad news, and a 5xx would throw away the only diagnosis there is.

Three defects a browser walk found that 179 green tests did not

  1. Every save of the bridge's own config was refused, with the page saying the opposite. The reload target was seeded from the guessed plugin, RunicGateway — deliberately not in the dropdown — and a <select> whose value matches no <option> displays the first one. The screen read "nothing — just write the file" while the request carried reload: RunicGateway. D38's "editable except three keys" was worth nothing.
  2. btn ghost is not a class this platform defines (.btn-ghost is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable. Fixed on the three pages phases 6 and 7 shipped too.
  3. A save's refusal rendered at the top of a long form, far from the button that caused it.

Walked against the real sidecar with a stand-in plugin over a real directory: the recursive walk incl. a nested Kits/kits.json, a form save that reloaded, a rollback, a reload-self refusal, a write with no reload, a conflict, and the locked keys. 162 server + 39 client tests, check:imports, check:bundle, check:externals and check:swagger green; routes.manifest.json regenerated against the pinned core.

  • AI-assisted: Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM

Phase 7b, the website half of R18 — Admin → **Rust mod config**. As built: `PLAN.md` §21. ## The trap this is mostly about **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` is `1`, `JSON.stringify` writes `1`, and both mod frameworks deserialize a config into typed C# classes — so a naive read-modify-write silently rewrites every whole-numbered float as an integer **on fields nobody touched**, and a plugin that throws at load does not come back. Gather rates and multipliers are exactly the fields at risk. `server/configEdit.js` never parses, mutates and re-serialises. It records the **source span** of every value and splices literals into them from the end of the document backwards, so everything a save did not touch is byte-identical — indentation, key order, and the `.0` on a float. A number's new value travels as the **text an admin typed**, from the browser to disk, and never becomes a `Number` anywhere (D35/D36). Its suite asserts the failure as well as the fix, and a live walk wrote `2.50` into a real `ZoneManager.json` while `500.0` and a `null` stayed exactly as they were. ## The rest of the surface - Two tiers, one route: `edits` (the generated form, type-preserving) or `text` (raw JSON). A version from the host must come back with a save, or it is a **409 with the current file attached** so a person can merge rather than retype. - **The bridge's own config is editable with three keys locked** — `Host`, `Port`, `ServerId` — in the form *and* in the raw tier, which submits a whole document and so is checked by comparing literals (D38). - Credentials render masked with a *Show*; the raw tier shows them (D37); `rust_config_writes` never records the values, and does record the refused and the rolled-back — an operator asking why a setting is not what they set needs to see that somebody tried. - A rollback comes back **200** with `report.rolledBack` and the server's own log line. It is a round trip that worked carrying bad news, and a 5xx would throw away the only diagnosis there is. ## Three defects a browser walk found that 179 green tests did not 1. **Every save of the bridge's own config was refused, with the page saying the opposite.** The reload target was seeded from the guessed plugin, `RunicGateway` — deliberately *not* in the dropdown — and a `<select>` whose value matches no `<option>` displays the first one. The screen read "nothing — just write the file" while the request carried `reload: RunicGateway`. D38's "editable except three keys" was worth nothing. 2. **`btn ghost` is not a class this platform defines** (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one **since phase 7** — here it made the open file and the active tier indistinguishable. Fixed on the three pages phases 6 and 7 shipped too. 3. **A save's refusal rendered at the top of a long form**, far from the button that caused it. Walked against the real sidecar with a stand-in plugin over a real directory: the recursive walk incl. a nested `Kits/kits.json`, a form save that reloaded, a rollback, a `reload-self` refusal, a write with no reload, a conflict, and the locked keys. 162 server + 39 client tests, `check:imports`, `check:bundle`, `check:externals` and `check:swagger` green; `routes.manifest.json` regenerated against the pinned core. - [x] AI-assisted: Claude Code 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
wtclaude added 1 commit 2026-09-22 13:56:58 +00:00
feat(rust): mod configuration from the site, and an editor that will not rewrite a float
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s
e54ae3afb9
R18's two tiers: a form generated from a config file's own values, and raw JSON
for what a form cannot express. Admin → Rust mod config, one live round trip per
action, nothing cached between a browser and a game host's disk.

`configEdit.js` is the part that could not be done naively. JavaScript cannot
tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C#
classes — so a read-modify-write silently rewrites every whole-numbered float as
an integer on fields nobody touched, and a plugin that then throws at load does
not come back. It never parses, mutates and re-serialises: it records the SOURCE
SPAN of every value and splices literals into them, so an untouched `1.0` is
still `1.0` and a number an admin types travels as text the whole way (D35/D36).

The bridge's own config is editable with `Host`, `Port` and `ServerId` locked,
in the form and in the raw tier, because either would cut the link carrying the
edit or strand every row this site holds (D38). Credentials render masked with a
reveal; the raw tier shows them (D37) and the audit trail never does.

`rust_config_writes` records every save including the refused and the rolled
back — an operator asking why a setting is not what they set needs to see that
somebody tried.

Three defects a browser walk found that 179 green tests did not:

* every save of the bridge's own config was refused while the page said the
  opposite — a `<select>` whose value matches no `<option>` shows the first one,
  so the reload guess `RunicGateway` was on the wire and "nothing" was on the
  screen;
* `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every
  secondary button in this module has rendered as a primary one since phase 7 —
  here it made the open file and the active tier indistinguishable;
* a save's refusal rendered at the top of a long form, far from the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
whitlocktech merged commit 47756d392a into edge 2026-09-22 15:02:20 +00:00
whitlocktech deleted branch feat/phase-7b-config 2026-09-22 15:02:21 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-Rust#9
No description provided.