From 4a37889b11f1a52a95596bde6d8420fbc6523a15 Mon Sep 17 00:00:00 2001 From: Thomas Snyder <127151050+tom-snyder@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:23:56 -0400 Subject: [PATCH] P2.3a handling preview: weight/ADS composition with bit-level runtime parity (#22) * feat: P2.3a handling preview (weight/ADS composition + domains) Renderer: partHandling.ts (binary32 weight_kg/ads_time_add composition, movement/ADS preview with server-configurable policy, typed refusal codes), partOrdering.ts (canonical local part-type identity + P2.2a sort ordering, signed-int32 sort_order domain), rollCard handling section, RollPanel/Library/PartPanel/OptionalNumberField editing surfaces, libraryValidation canonical-id duplicate/slot checks + handling scalar and sortOrder domain issues. Sidecar: raw-import preflight rejecting lossy sort_order before the legacy loader, save_part/save_part_type domain refusals, validate_project handling/sort_order hard issues. Docs: PLAN/CONTEXT/GUNRNG_CHANGES updated for the P2.3a preview contract. Co-Authored-By: Claude Fable 5 * fix: validate both handling scalars before either addition (re-grill NIT-1) A part with both an overflowing weight aggregate and an invalid ads_time_add now picks the same refusal code as the runtime's ResolvedGunHandling.resolveCanonical. Also corrects the prior commit's loose wording: libraryValidation gained only sortOrder/handling-scalar issues; duplicate/slot checks are main's shipped alias-aware bareId path (preserved), and the canonical-id collision check lives in partHandling.ts (re-grill NIT-2). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- PLAN.md | 12 + docs/CONTEXT.md | 15 ++ docs/GUNRNG_CHANGES.md | 56 +++- sidecar/bridge.py | 114 +++++++- sidecar/server.py | 1 + sidecar/test_bridge.py | 80 +++++- sidecar/test_server.py | 109 ++++++++ .../src/core/bridge/entityWire.test.ts | 8 +- .../core/presets/presetSerialization.test.ts | 4 +- .../src/core/project/fromSidecar.test.ts | 4 + .../core/project/libraryValidation.test.ts | 75 ++++++ .../src/core/project/libraryValidation.ts | 27 ++ .../src/core/project/partHandling.test.ts | 249 ++++++++++++++++++ src/renderer/src/core/project/partHandling.ts | 186 +++++++++++++ .../src/core/project/partOrdering.test.ts | 78 ++++++ src/renderer/src/core/project/partOrdering.ts | 71 +++++ .../src/core/project/projectFile.test.ts | 8 +- .../src/modeller/panels/PartPanel.tsx | 3 + src/renderer/src/styles.css | 16 +- .../definitions/OptionalNumberField.test.ts | 37 +++ .../ui/definitions/OptionalNumberField.tsx | 50 +++- src/renderer/src/ui/library/Library.tsx | 50 +++- .../src/ui/library/PartTypeRow.test.tsx | 31 +++ src/renderer/src/ui/roll/RollPanel.tsx | 32 ++- src/renderer/src/ui/roll/rollCard.test.ts | 76 ++++-- src/renderer/src/ui/roll/rollCard.ts | 27 +- 26 files changed, 1348 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/core/project/partHandling.test.ts create mode 100644 src/renderer/src/core/project/partHandling.ts create mode 100644 src/renderer/src/core/project/partOrdering.test.ts create mode 100644 src/renderer/src/core/project/partOrdering.ts create mode 100644 src/renderer/src/ui/definitions/OptionalNumberField.test.ts create mode 100644 src/renderer/src/ui/library/PartTypeRow.test.tsx diff --git a/PLAN.md b/PLAN.md index 9c40b84..34cab0b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -761,6 +761,10 @@ opus fallback. Roll math (multishot DPS) unit-tested before UI. | 2026-07-05 | E10 final grill + fold | Multi-track on the post-sweep tree: **(A) wire-contract packet** — mimo died mid-exploration (free route unusable even on small packets today) → orchestrator ran the enumeration DIRECTLY (mechanical: types.ts fields vs mapper emissions, all 7 mappers): **SOUND, exhaustive** — every field emitted, every optional null-clears. **(B) validation-totality packet** — mimo DELIVERED (single-module): 8 findings adjudicated → F7b REFUTED (`max(ammo_per_shot,1)` already guards the DPS division; 0 = "consumes no ammo", legal), F3 NOT-FIXABLE (withPartTag.tag has no DesignRNG-side vocabulary — documented gap, same status as F7-union), 6 FIXED: gunType + compatibleGunTypes dangling refs (vocabulary-gated on `catalog` presence so fresh/sample projects don't false-flag — sidecar-gate parity), synergy withStyle dangling ref, negative damageMultiplier (heals on hit)/headshotMultiplier/ricochet.minAngleDeg, **per-collection duplicate-id detection ×9** (same-id entities silently collapse on upsert) — +5 regression tests. **(C) 3-lens workflow panel w/ adversarial verify** (ultracode): 7 confirmed / 0 refuted, all fixed — ADR 0006 misdescribed the extras bag as DENY-list when upserts are WHITELIST-only (`_EXTRAS_CAMEL_TO_SNAKE`; the load path IS deny-list — the doc would have made silent field loss look intermittent) → corrected w/ the register-your-field instruction; ADR envelope omitted the mandatory `format:"designrng.preset"` magic → documented; ADR "all five collections default to []" wrong for hard-required partTypes → corrected; **effectCatalog cited a nonexistent `model/effects.py` AND its per-effect params had drifted from gunModeller's real `effect_parameters.py`** — FUNCTIONAL fix, not just docs: catalog resynced verbatim (chain_lightning radius/damage/max_targets, status_effect duration_ticks/amplifier, vampiric heal/splash_heal, + 8 more entries; identifier vocabulary gains `entity`; projectile_entity corrected particle→entity) — the old lists FALSE-FLAGGED legal params in the typo guard and omitted them from the schema forms; verified live in the mirror; GUNRNG_CHANGES dead "see below" pointer + "other two[=three] dataclass fields" count fixed; `.claude/launch.json` (session-scratchpad path) gitignored before any blanket `git add`. **Coverage debt recorded: the flow-lens reviewer died on Anthropic session capacity (259k tokens in)** — mitigation: every per-chunk grill already walked its own seams and every create/edit/save flow was preview-verified live during E4–E8 folds; the lens was a redundancy pass. Cross-family debt from E6/E7 partially discharged via packet B + the deterministic packet A | vitest **512/512** · pytest **113/113** · compat **19/19** · typecheck/lint/build ✅ · mirror probe of resynced forms ✅ | **E10 grill folded — EXPANSION E1-E10 COMPLETE** | ship (commit/push/PR) | | 2026-07-05 | E10 ship | 5 thematic commits on `plan/expansion-ammo-parts-presets` (core domain E1/E9 · sidecar pipeline E2-E3/E7 · editor UI E4-E6/E7 · presets E8 · docs E10) pushed; PR #5 retitled + rebodied to the full implementation (`gh` permitted this round). CI: **web PASS** (1m0s), sidecar skipping (cross-repo matrix, by design). Tree clean; `.claude/launch.json` gitignored | PR #5 · CI green | **EXPANSION SHIPPED — awaiting owner merge** | owner merge of PR #5; post-merge follow-ups live in GUNRNG_CHANGES status table + ADR 0006 open items | +| 2026-07-16 | P2.3a handling mirror | DesignRNG-native P2.2a part ordering + binary32 handling aggregate; hard editor/sidecar/export domains; precision-safe optional-number drafts; signed-int32 part-type order with pre-legacy raw-import refusal; native Roll handling card using the selected Roll parts; typed identity/slot/scalar/overflow diagnostics; high-precision numeric-value persistence/export pins. Arms NG consumer `dfb76e5` is shipped/green. The preview labels `.01`/`.65`/`.25s` as server-configurable defaults and explicitly leaves timed ADS to P3.2. Whole-profile P2.P1 quantization remains open. Namespace-exact export parity is gated on rebasing the `canonical-v3-identifiers` prerequisite. Node-only tests cover reducers/SSR; real OptionalNumberField/PartTypeRow/RollPanel DOM interactions remain explicitly recorded CDP/harness debt. | typecheck/lint/build ✅ · vitest **570/570** · sidecar pytest **168/168 + 2 platform skips** · isolated fixture verifier **18/18 + 2 platform skips** · legacy-v3 compat oracle **19/19** | implementation + HOLD fixes complete and fully gated in working tree, not yet shipped | rebase identifier prerequisite → independent re-grill → PR to `dev` | + +| 2026-07-18 | P2.3a rebase onto canonical identifiers | `codex/p23a-handling-preview` rebased onto `main` `1c0a790` (ADR 0008 canonical v3 identifier boundary, #20/#21). Conflict resolutions keep main's boundary authoritative: bridge docstring + load-preflight order take main's shipped form (identity preflight, then P2.3a raw sort_order preflight); `libraryValidation.ts` reverts to main's alias-aware `bareId` comparisons (its `bareId` now folds `armsng:` too, so the P2.3a alias-duplicate tests hold) and keeps only the new sortOrder/handling-scalar domains; `partOrdering.ts`/`partHandling.ts` drop the private `canonicalProjectPartTypeId` canonicalizer and consume the shipped `identifierIdentity` — no parallel identity implementation survives. P2.3a's sidecar hooks (`save_part` handling refusal, `save_part_type`/`validate_project` sort_order domain, raw-import preflight) composed onto main's `_new_doc_id`/oracle-view rewrite without semantic loss. TS `orderSelectedParts` omits main's third-key part-id tie-break by design: `resolvePartHandling` refuses duplicate selected slots before ordering, so the tie is unreachable in the preview path. | typecheck/lint/build ✅ · vitest **588/588** · sidecar pytest **709 passed + 4 skips (41 subtests)** | rebase complete, fully gated on the canonical-identifier main | independent re-grill → PR to `dev` | + # CURRENT ROADMAP — PART-SURFACE V4 COORDINATION (2026-07-11) This is DesignRNG's authoring-side P4 plan, coordinated with Arms NG `PLAN.md` P4 @@ -805,6 +809,14 @@ not a v4 owner or release gate. See ADR 0007. legacy-oracle commit. **P4.D1 remains open:** the redistributable fixture and its owner-selected license have not yet been published, so the shipped verifier foundation alone does not complete the slice. +- **P2.3a handling mirror IMPLEMENTED (2026-07-16; rebased onto canonical identifiers + 2026-07-18; pending independent grill):** Arms NG `dfb76e5` is the shipped/green consumer. + DesignRNG now owns native part ordering, binary32 handling composition, hard + authoring/export validation, and the Roll handling preview. The `canonical-v3-identifiers` + prerequisite (ADR 0008, `main` `1c0a790`) has landed and this branch sits on it: the + handling layer consumes the shipped `identifierIdentity` helper instead of a private + canonicalizer. This closes only R1.10 handling parity; whole-profile P2.P1 quantization + and P3.2 timed ADS remain open. - The existing `snapPoints` / `snapLocation` editors and sidecar derivation are **schema-v3-only compatibility surfaces**. Schema v4 replaces part-docking snaps with base-owned surface bindings while retaining typed semantic locators. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 7295325..3c83089 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -122,6 +122,13 @@ and the pack's **rarity curve**. DesignRNG's job is to author and curate that po and is computed **sidecar-side** (`bridge.py sustained_dps_v2`) and carried on the roll payload, not recomputed in TS (`docs/GUNRNG_CHANGES.md` §E7 is canonical; ADR 0004's "pellets=1, display-only" is superseded-in-part). *Avoid:* DPS-per-shot, burst DPS. +- **Handling preview** — the Roll card's DesignRNG-native R1.10 view over the exact selected + parts: total `weightKg` and `adsTimeAdd`, folded in canonical part-type order with Java-float/ + IEEE-754 binary32 rounding after every addition. It also shows movement and effective-ADS + values under clearly labelled Arms NG server-configurable defaults. It is not a timed ADS + animation: P3.2 owns consuming that duration. Typed composition errors (canonical part-id + collision, missing selected part, selected-part slot mismatch, duplicate slot, invalid scalar, + or overflow) are author-facing refusals, not zero/default data. ### Authored definitions (EXPANSION, 2026-07-05) - **Authored definitions** — the collective term for the EXPANSION's newly-authorable @@ -170,6 +177,14 @@ and the pack's **rarity curve**. DesignRNG's job is to author and curate that po - The **Library** tab browses the pool; the **Modeller** edits one base/part's geometry + snaps at a time (**Apply changes** writes it back); the **Roll** tab previews what the generator actually produces from the pool. +- The Roll card's **handling preview** resolves from that roll's selected part ids, never from + sidecar map iteration order. Its native resolver compares bare, `gunrng:`, and `armsng:` + spellings as one local project identity while foreign namespaces remain exact — via the + shipped ADR 0008 `identifierIdentity` helper (`identifiers.ts`), never a second alias + implementation. The `canonical-v3-identifiers` prerequisite has landed on `main` and this + branch is rebased onto it. Persistence retains the parsed IEEE-754 binary64 numeric value + (not the author's lexical decimal spelling); only handling-preview arithmetic deliberately + converts to binary32. - **DesignRNG** is the authority for the authored model, validation, migration/report, and export contract. The independent-operation transition is not complete until P4.D1–D6. The **Export sidecar** currently executes an import-backed schema-v3 compatibility path; diff --git a/docs/GUNRNG_CHANGES.md b/docs/GUNRNG_CHANGES.md index 784c086..cbab474 100644 --- a/docs/GUNRNG_CHANGES.md +++ b/docs/GUNRNG_CHANGES.md @@ -14,19 +14,19 @@ The v4 model, validation, migration/report, fixture, and export live in DesignRN Arms NG as the coordinated runtime consumer. **Operational qualification:** the transition is not complete. Current DesignRNG export -is import-backed schema-v3 compatibility; project-file v2 is not self-contained, and -P4.D0 must close the session-identity/hidden-state/destination-overwrite hazards before -the export path is accepted. P4.D6 is the clean no-token independence gate. +is import-backed schema-v3 compatibility and project-file v2 is not self-contained. +P4.D0 has closed the immediate session-identity and destination-safety hazards; P4.D6 +remains the clean no-token independence gate. -## Status (authoring baseline 2026-07-05; authority updated 2026-07-12) +## Status (handling parity updated 2026-07-16) The EXPANSION (E1–E9) is code-complete and grilled; DesignRNG now has authoring UI for every R1.x item below **except** the pure-semantics/verification items, which remain runtime-only obligations no authoring UI can discharge (they're rules the mod's code must follow, not fields an author fills in). Every field is on the wire today as an ordinary flat key (own authoring UI) or an existing gunModeller field DesignRNG now edits directly -(part types) — nothing here is blocked on DesignRNG-side work; everything below is a -GunRNG (mod-side) TODO. +(part types). Runtime status is tracked row by row; completed Arms NG consumers are no +longer described as open mod-side work. | R1.x | Item | DesignRNG authoring UI | Status | |---|---|---|---| @@ -39,7 +39,7 @@ GunRNG (mod-side) TODO. | R1.7 | Per-ammo falloff curve | AmmoEditor (E4) | **Authoring shipped** | | R1.8 | Armor interaction (`armorIgnore`, `headshotMultiplier`) | AmmoEditor (E4) | **Authoring shipped** | | R1.9 | Ammo economy (`craftCost`) | AmmoEditor (E4) | **Authoring shipped** (mod-side recipe generation still TODO) | -| R1.10 | Part weight & handling | PartPanel Advanced group (E6) | **Authoring shipped** | +| R1.10 | Part weight & handling | PartPanel Advanced group + native Roll handling preview | **Runtime shipped/green at Arms NG `dfb76e5`; DesignRNG mirror implemented 2026-07-16** | | R1.11 | Muzzle silence + recoil split; new part types | PartPanel Advanced (silence/recoil, E6) + Library "Part types" strip (any custom type, E6) | **Authoring shipped** (mechanism); specific `muzzle`/`grip`/`underbarrel` seed content was **not** bundled — reconciled below | | R1.12 | Part synergies (conditional modifiers) | PartPanel synergy editor (E6) | **Authoring shipped**, catalog-vocabulary only (imported-pack condition ids not yet reachable — F7, tracked below) | | R1.13 | Modifier stacking + application order | Roll preview consumes authored part-type `sortOrder` | **Runtime + semantic/order preview parity implemented 2026-07-12**; whole-profile numeric quantization remains P2.P1 | @@ -169,6 +169,48 @@ gates recipes from it (exact recipe policy is a mod decision; the field is the c `weight_kg: float` (movement/handling penalty while equipped — TaCZ attachments) and `ads_time_add: float` (seconds, additive ADS-in time). Applies to ALL part types. +Arms NG `dfb76e5` consumes both fields. DesignRNG's native preview converts every +authored scalar to IEEE-754 binary32 first, folds in ascending authored part-type +`sort_order` (legacy missing-definition fallbacks 10/20/30/40/50 for receiver/barrel/ +stock/magazine/sight, then 1000; ordinal canonical-id tie), and rounds after every +addition. `weight_kg` must become finite and non-negative; `ads_time_add` must become +finite. Invalid authored scalars block validation/export through the DesignRNG-owned +sidecar gate. Missing selected parts, duplicate selected slots, and either aggregate +overflow are typed Roll-composition refusals surfaced on the card. Canonical gun-part-id +collisions and a Roll-selected part whose reported slot disagrees with its authored slot +also refuse with dedicated error codes, before any colliding map entry can overwrite another. + +Part-type `sort_order` is a signed 32-bit integer (`-2147483648..2147483647`), matching +Arms NG's `Codec.INT`. The Library draft/parser, numeric-input bounds, renderer hard gate, +`savePartType` RPC, and direct validate/export gate enforce the same domain. The DesignRNG +raw importer scans `part_types/*.json` before the legacy loader can call `int(...)`, so +fractional, one-beyond, unsafe-integer, and boolean values refuse with their source path and +cannot mutate the active session. + +The card labels policy values as **server-configurable defaults**: movement +`max(0.65, 1 - total_weight_kg × 0.01)` and effective ADS +`clamp(0.25s + ads_time_add, 0.05s, 3s)`. It does not claim a timed ADS transition is +active; P3.2 owns that consumption. Persistence preserves the parsed binary64 numeric value, +not lexical JSON spelling (trailing zeroes/exponent form may normalize); only preview arithmetic +uses `Math.fround`. The pinned `64.88005` value survives editor/save/export/reload numerically. + +The native handling resolver applies the local-alias comparison through the shipped ADR 0008 +identity boundary (`identifiers.ts` `identifierIdentity` renderer-side; `v3_identifiers.py` +sidecar-side). The `canonical-v3-identifiers` prerequisite has landed on `main` and this branch +is rebased onto it, so sidecar collision/export assertions stay owned by that boundary — the +handling slice adds no second alias implementation. + +**UI coverage note:** this repository's Vitest environment is Node-only and currently installs +no jsdom, happy-dom, React test renderer, or Testing Library. The slice pins +`OptionalNumberField`'s focus/blur contract through its public draft reducer plus SSR accessibility, +and `PartTypeRow`'s min/max/diagnostic markup through SSR. A real RollPanel overlapping-request +interaction (including newest-request/project snapshot pairing) and an actual focus/blur DOM event +remain CDP/DOM-harness coverage debt; no P2.3a CDP interaction run is claimed by this branch. + +This closes R1.10 handling-aggregate parity only. It does **not** close named debt +P2.P1: the rest of the schema-v3 profile still contains Python-binary64 and integer +rounding differences described under R1.13. + ### R1.11 Muzzle silence + recoil split (NEW part fields, with new part types) `silence {sound_radius_blocks, sound_event?}` (shrinks gunshot propagation, optional sound swap — TaCZ silencer) and `recoil_pitch_mult`/`recoil_yaw_mult` (Arma suppressor bundles diff --git a/sidecar/bridge.py b/sidecar/bridge.py index ae3e030..9554f01 100644 --- a/sidecar/bridge.py +++ b/sidecar/bridge.py @@ -11,19 +11,69 @@ * ``assemble_roll`` — one generated gun's composed model + resolved stats, the way the runtime rolls them (RC5 Roll tab). -DPS is intentionally NOT computed here — the TS side computes it from the raw -resolved stats (ADR 0004). +The bridge also computes legacy-compatible Roll statistics. New validation and +wire policy originate in DesignRNG, never in gunModeller. """ from __future__ import annotations import copy +import json +import math import random import re +import struct import sys from pathlib import Path from typing import Any + +MIN_PART_TYPE_SORT_ORDER = -2147483648 +MAX_PART_TYPE_SORT_ORDER = 2147483647 + + +def is_valid_part_type_sort_order(value: object) -> bool: + """Return whether ``value`` is representable by Arms NG's Codec.INT. + + JSON has one number category while Python distinguishes ``int``/``float`` + (and, unhelpfully, makes ``bool`` an ``int`` subclass). Integer-valued + JSON numbers are accepted; booleans and values outside signed int32 are + not. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()): + return False + return MIN_PART_TYPE_SORT_ORDER <= value <= MAX_PART_TYPE_SORT_ORDER + + +def _part_type_sort_order_error(path: str, value: object) -> str: + return ( + f"{path} must be a signed 32-bit integer " + f"({MIN_PART_TYPE_SORT_ORDER}..{MAX_PART_TYPE_SORT_ORDER}); got {value!r}" + ) + + +def preflight_raw_part_type_sort_orders(project_root: Path) -> None: + """Reject lossy legacy ``sort_order`` inputs before gunModeller loads. + + gunModeller's v3 loader calls ``int(raw)``. That conversion would erase a + fractional value and preserve arbitrary-width Python integers that Arms NG + later truncates through ``Codec.INT``. DesignRNG owns this raw import guard + and runs it before the legacy loader or any session mutation. + """ + root = Path(project_root) + folder = root / "part_types" + if not folder.is_dir(): + return + for path in sorted(folder.glob("*.json"), key=lambda item: item.name): + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict) and "sort_order" in raw: + value = raw["sort_order"] + if not is_valid_part_type_sort_order(value): + relative = path.relative_to(root).as_posix() + raise ValueError(_part_type_sort_order_error(f"{relative}.sort_order", value)) + # gunModeller's headless layers live next to this repo; add src to the path. _GUNMODELLER_SRC = Path(__file__).resolve().parents[2] / "gunModeller" / "src" if _GUNMODELLER_SRC.is_dir() and str(_GUNMODELLER_SRC) not in sys.path: @@ -919,6 +969,46 @@ def _part_extras_to_snake(payload_extras: dict[str, Any]) -> dict[str, Any]: return out +def _binary32(value: Any) -> float | None: + """Emulate Java/JS binary32 conversion without mutating authored JSON.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + converted = struct.unpack("!f", struct.pack("!f", float(value)))[0] + except (OverflowError, struct.error, TypeError, ValueError): + return None + return converted if math.isfinite(converted) else None + + +def _part_handling_issues(part_id: str, extras: dict[str, Any]) -> list[str]: + issues: list[str] = [] + if "weight_kg" in extras: + value = extras["weight_kg"] + converted = _binary32(value) + if converted is None or converted < 0: + issues.append( + f"gun_part '{part_id}' weight_kg must become a finite binary32 value >= 0" + ) + if "ads_time_add" in extras: + value = extras["ads_time_add"] + if _binary32(value) is None: + issues.append( + f"gun_part '{part_id}' ads_time_add must become a finite binary32 value" + ) + return issues + + +def _validate_part_handling_payload(part_id: str, payload: dict[str, Any]) -> None: + extras = { + snake: payload[camel] + for camel, snake in (("weightKg", "weight_kg"), ("adsTimeAdd", "ads_time_add")) + if camel in payload and payload[camel] is not None + } + issues = _part_handling_issues(part_id, extras) + if issues: + raise ValueError("; ".join(issues)) + + def _ammo_stat_multipliers_json(mults: AmmoStatMultipliers) -> dict[str, Any]: return { "spreadMult": float(mults.spread_mult), @@ -1335,6 +1425,9 @@ def save_part(project: ContentPackProject, part_json: dict[str, Any]) -> dict[st "none", never an omitted key), after which the legacy ``anchor_offset`` keeps positioning the part.""" raw_id = part_json["id"] + # DesignRNG owns these P2.3a domains. Refuse before creating or mutating a + # doc so direct RPC clients cannot bypass the renderer pre-gate. + _validate_part_handling_payload(str(raw_id), part_json) part = lookup_with_namespace(project.gun_parts, raw_id) if part is None: part_id = _new_doc_id(raw_id, project.gun_parts) @@ -1401,6 +1494,10 @@ def save_part_type(project: ContentPackProject, part_type_json: dict[str, Any]) harmless, and `seed_snap_points`/`libraryValidation`'s slot↔part_type checks key off `id` alone, which is always present.""" raw_id = part_type_json["id"] + if "sortOrder" in part_type_json: + value = part_type_json["sortOrder"] + if not is_valid_part_type_sort_order(value): + raise ValueError(_part_type_sort_order_error(f"part type {raw_id!r} sortOrder", value)) pt = lookup_with_namespace(project.part_types, raw_id) if pt is None: pt_id = _new_doc_id(raw_id, project.part_types) @@ -2010,6 +2107,19 @@ def _advisory(issue: Any) -> bool: hard = [i.message for i in report.issues if not _advisory(i)] warnings = [i.message for i in report.issues if _advisory(i)] hard += derived_anchor_gaps(project) + # P2.3a: handling fields live in DesignRNG's extras bag, outside the + # legacy validator's vocabulary. Validate them here so validate/export + # remain authoritative even when a caller bypasses the renderer. + for part_id, part in project.gun_parts.items(): + hard += _part_handling_issues(part_id, _extras_of(part)) + for part_type_id, part_type in project.part_types.items(): + if not is_valid_part_type_sort_order(part_type.sort_order): + hard.append( + _part_type_sort_order_error( + f"part_types[{part_type_id}].sort_order", + part_type.sort_order, + ) + ) return { "exportBlocked": bool(hard), "issues": hard, diff --git a/sidecar/server.py b/sidecar/server.py index e2286b8..9578545 100644 --- a/sidecar/server.py +++ b/sidecar/server.py @@ -539,6 +539,7 @@ def handle(self, method: str, params: dict) -> object: source_path = source_input.resolve() before_scan = strict_tree_scan(source_path) v3_identifiers.preflight_source_identifiers(source_path) + bridge.preflight_raw_part_type_sort_orders(source_path) project = load_project(source_path) # Refuse ambiguous aliases before snap seeding or any other # compatibility step mutates the candidate project. The active diff --git a/sidecar/test_bridge.py b/sidecar/test_bridge.py index 8362a1f..9ddfa1e 100644 --- a/sidecar/test_bridge.py +++ b/sidecar/test_bridge.py @@ -127,6 +127,74 @@ def test_validate_project_clean(): assert report["exportBlocked"] is False +@pytest.mark.parametrize( + ("key", "value"), + [ + ("weight_kg", -0.01), + ("weight_kg", float("nan")), + ("weight_kg", 3.5e38), + ("ads_time_add", float("inf")), + ("ads_time_add", 3.5e38), + ], +) +def test_validate_project_blocks_invalid_design_owned_part_handling_extras(key, value): + project = _minimal_pack() + bridge._stash_extras(project.gun_parts["voltspoke_barrel"], {key: value}) + report = bridge.validate_project(project) + assert report["exportBlocked"] is True + assert any(key in issue for issue in report["issues"]) + + +def test_save_part_refuses_invalid_handling_before_mutating_the_project(): + project = _minimal_pack() + before = dict(bridge._extras_of(project.gun_parts["voltspoke_barrel"])) + with pytest.raises(ValueError, match="weight_kg"): + bridge.save_part(project, {"id": "voltspoke_barrel", "weightKg": 3.5e38}) + assert bridge._extras_of(project.gun_parts["voltspoke_barrel"]) == before + + +def test_design_owned_handling_validation_emulates_binary32_without_rewriting_authored_decimal(): + assert bridge._binary32(64.88005) == 64.88005065917969 + project = _minimal_pack() + out = bridge.save_part(project, { + "id": "voltspoke_barrel", + "weightKg": 64.88005, + "adsTimeAdd": -0.075, + }) + assert out["weightKg"] == 64.88005 + assert out["adsTimeAdd"] == -0.075 + + +@pytest.mark.parametrize( + "value", + [12.75, 2147483648, -2147483649, 9007199254740992, True], + ids=["fractional", "positive-one-beyond", "negative-one-beyond", "unsafe-int", "boolean"], +) +def test_save_part_type_refuses_non_int32_sort_order_before_mutation(value): + project = _minimal_pack() + before = project.part_types["barrel"].sort_order + with pytest.raises(ValueError, match="sortOrder.*signed 32-bit integer"): + bridge.save_part_type(project, {"id": "barrel", "sortOrder": value}) + assert project.part_types["barrel"].sort_order == before + + +@pytest.mark.parametrize("value", [-2147483648, 2147483647]) +def test_save_part_type_accepts_signed_int32_boundaries(value): + project = _minimal_pack() + saved = bridge.save_part_type(project, {"id": "barrel", "sortOrder": value}) + assert saved["sortOrder"] == value + assert project.part_types["barrel"].sort_order == value + + +@pytest.mark.parametrize("value", [2147483648, -2147483649, True]) +def test_validate_project_blocks_non_int32_sort_order(value): + project = _minimal_pack() + project.part_types["barrel"].sort_order = value + report = bridge.validate_project(project) + assert report["exportBlocked"] is True + assert any("part_types[barrel].sort_order" in issue for issue in report["issues"]) + + # --------------------------------------------------------------------------- # RC2: library serialization + the snap↔anchor contract # --------------------------------------------------------------------------- @@ -1572,7 +1640,8 @@ def test_extras_round_trip_through_export_and_reload(tmp_path): }) bridge.save_part(p, { "id": "voltspoke_barrel", - "weightKg": 2.0, + "weightKg": 64.88005, + "adsTimeAdd": -0.075, "silence": {"soundRadiusBlocks": 3.0}, }) @@ -1592,7 +1661,8 @@ def test_extras_round_trip_through_export_and_reload(tmp_path): assert ammo_written["item"] == "minecraft:gunpowder" part_written = json.loads((dest / "gun_parts" / "barrel" / "voltspoke_barrel.json").read_text("utf-8")) - assert part_written["weight_kg"] == 2.0 + assert part_written["weight_kg"] == 64.88005 + assert part_written["ads_time_add"] == -0.075 assert part_written["silence"] == {"sound_radius_blocks": 3.0} assert part_written["slot"] == "barrel" # untouched gunModeller-known field @@ -1606,13 +1676,17 @@ def test_extras_round_trip_through_export_and_reload(tmp_path): } assert ammo_extras["craft_cost"] == {"item": "minecraft:iron_nugget", "count": 4} part_extras = bridge._extras_of(reloaded.gun_parts["voltspoke_barrel"]) - assert part_extras["weight_kg"] == 2.0 + assert part_extras["weight_kg"] == 64.88005 + assert part_extras["ads_time_add"] == -0.075 assert part_extras["silence"] == {"sound_radius_blocks": 3.0} # And project_to_design_json serves them back to the UI as camelCase. data = bridge.project_to_design_json(reloaded) reloaded_ammo = next(a for a in data["ammoTypes"] if a["id"] == "bullet") assert reloaded_ammo["falloffCurve"] == [[10.0, 1.0]] + reloaded_part = next(part for part in data["gunParts"] if part["id"] == "voltspoke_barrel") + assert reloaded_part["weightKg"] == 64.88005 + assert reloaded_part["adsTimeAdd"] == -0.075 def test_rehydrate_extras_noop_when_no_extras_authored(tmp_path): diff --git a/sidecar/test_server.py b/sidecar/test_server.py index 35e31ff..39ab68b 100644 --- a/sidecar/test_server.py +++ b/sidecar/test_server.py @@ -127,6 +127,115 @@ def test_save_part_dispatch_updates_session_project(tmp_path): assert doc.snap_location is not None # authored snap survives a full-state save +def test_save_part_dispatch_rejects_binary32_invalid_handling(tmp_path): + s, _data, _identity = _loaded_project(tmp_path, _pack_with_geometry()) + with pytest.raises(ValueError, match="weight_kg"): + s.handle( + "savePart", + _params(s, part={"id": "voltspoke_barrel", "weightKg": 3.5e38}), + ) + + +def test_save_part_type_dispatch_refuses_fractional_sort_order(tmp_path): + s, _data, _identity = _loaded_project(tmp_path, _pack_with_geometry()) + with pytest.raises(ValueError, match="sortOrder.*integer"): + s.handle( + "savePartType", + _params(s, partType={"id": "barrel", "sortOrder": 12.75}), + ) + + +@pytest.mark.parametrize( + "raw_value", + [12.75, 2147483648, -2147483649, 9007199254740992, True], + ids=["fractional", "positive-one-beyond", "negative-one-beyond", "unsafe-int", "boolean"], +) +def test_load_preflights_raw_part_type_sort_order_before_legacy_coercion_and_session_mutation( + tmp_path, raw_value +): + current_source = tmp_path / "current" + save_project(_minimal_pack(), current_source) + session = server.Session() + _load(session, current_source) + previous_project = session.project + previous_identity = dict(session.identity) + previous_generation = session._generation + + candidate_source = tmp_path / "candidate" + save_project(_minimal_pack(), candidate_source) + path = candidate_source / "part_types" / "barrel.json" + raw = json.loads(path.read_text("utf-8")) + raw["sort_order"] = raw_value + path.write_text(json.dumps(raw), encoding="utf-8") + + with pytest.raises( + ValueError, + match=r"part_types[/\\]barrel\.json.*sort_order.*signed 32-bit integer", + ): + session.handle("load", {"packPath": str(candidate_source)}) + + assert session.project is previous_project + assert session.identity == previous_identity + assert session._generation == previous_generation + + +@pytest.mark.parametrize("raw_value", [-2147483648, 2147483647]) +def test_load_accepts_raw_part_type_sort_order_int32_boundaries(tmp_path, raw_value): + source = tmp_path / "candidate" + save_project(_minimal_pack(), source) + path = source / "part_types" / "barrel.json" + raw = json.loads(path.read_text("utf-8")) + raw["sort_order"] = raw_value + path.write_text(json.dumps(raw), encoding="utf-8") + + project, _identity = _load(server.Session(), source) + barrel = next(part_type for part_type in project["partTypes"] if part_type["id"] == "barrel") + assert barrel["sortOrder"] == raw_value + + +def test_export_blocks_invalid_design_owned_handling_even_without_renderer_gate(tmp_path): + import bridge + + s, _data, _identity = _loaded_project(tmp_path, _pack_with_geometry()) + bridge._stash_extras(s.project.gun_parts["voltspoke_barrel"], {"ads_time_add": float("inf")}) + dest = tmp_path / "resources" + result = s.handle("export", _params(s, destPath=str(dest))) + assert result["success"] is False + assert any("ads_time_add" in error for error in result["errors"]) + assert not (dest / "manifest.json").exists() + + +def test_export_blocks_non_int32_part_type_sort_order_without_renderer_gate(tmp_path): + s, _data, _identity = _loaded_project(tmp_path, _pack_with_geometry()) + s.project.part_types["barrel"].sort_order = 2147483648 + dest = tmp_path / "resources" + result = s.handle("export", _params(s, destPath=str(dest))) + assert result["success"] is False + assert any("part_types[barrel].sort_order" in error for error in result["errors"]) + assert not (dest / "manifest.json").exists() + + +def test_save_part_dispatch_preserves_high_precision_handling_through_export(tmp_path): + s, _data, _identity = _loaded_project(tmp_path, _pack_with_geometry()) + saved = s.handle( + "savePart", + _params(s, part={ + "id": "voltspoke_barrel", + "weightKg": 64.88005, + "adsTimeAdd": -0.075, + }), + ) + assert saved["weightKg"] == 64.88005 + assert saved["adsTimeAdd"] == -0.075 + + dest = tmp_path / "resources" + result = s.handle("export", _params(s, destPath=str(dest))) + assert result["success"] is True, result["errors"] + written = json.loads((dest / "gun_parts" / "barrel" / "voltspoke_barrel.json").read_text("utf-8")) + assert written["weight_kg"] == 64.88005 + assert written["ads_time_add"] == -0.075 + + def test_save_styles_dispatch_then_export_writes_renamed_style(tmp_path): """RC13 grill F1 end-to-end: a style renamed in the UI must reach the exported StyleDef wire — before this, export shipped stale style names.""" diff --git a/src/renderer/src/core/bridge/entityWire.test.ts b/src/renderer/src/core/bridge/entityWire.test.ts index d2b7bd3..12dac71 100644 --- a/src/renderer/src/core/bridge/entityWire.test.ts +++ b/src/renderer/src/core/bridge/entityWire.test.ts @@ -111,8 +111,8 @@ describe('entityWire (RC13 export rework)', () => { it('partToWire carries authored new-schema fields', () => { const withExtras: GunPart = { ...part(), - weightKg: 1.5, - adsTimeAdd: 0.1, + weightKg: 64.88005, + adsTimeAdd: -0.075, silence: { soundRadiusBlocks: 4, soundEvent: 'gunrng:silenced_shot' }, recoilPitchMult: 0.8, recoilYawMult: 0.9, @@ -128,8 +128,8 @@ describe('entityWire (RC13 export rework)', () => { ], } const wire = partToWire(withExtras) - expect(wire.weightKg).toBe(1.5) - expect(wire.adsTimeAdd).toBe(0.1) + expect(wire.weightKg).toBe(64.88005) + expect(wire.adsTimeAdd).toBe(-0.075) expect(wire.silence).toEqual({ soundRadiusBlocks: 4, soundEvent: 'gunrng:silenced_shot' }) expect(wire.recoilPitchMult).toBe(0.8) expect(wire.recoilYawMult).toBe(0.9) diff --git a/src/renderer/src/core/presets/presetSerialization.test.ts b/src/renderer/src/core/presets/presetSerialization.test.ts index ce1e151..0a3aaa1 100644 --- a/src/renderer/src/core/presets/presetSerialization.test.ts +++ b/src/renderer/src/core/presets/presetSerialization.test.ts @@ -76,8 +76,8 @@ describe('preset envelope round-trip (E8a)', () => { it('round-trips the EXPANSION optional part fields on a part preset (weightKg/silence/synergyModifiers/etc.)', () => { const authoredPart: GunPart = { ...samplePart, - weightKg: 1.5, - adsTimeAdd: 0.1, + weightKg: 64.88005, + adsTimeAdd: -0.075, silence: { soundRadiusBlocks: 4, soundEvent: 'gunrng:silenced_shot' }, recoilPitchMult: 0.8, recoilYawMult: 0.9, diff --git a/src/renderer/src/core/project/fromSidecar.test.ts b/src/renderer/src/core/project/fromSidecar.test.ts index 1aebb78..fb53447 100644 --- a/src/renderer/src/core/project/fromSidecar.test.ts +++ b/src/renderer/src/core/project/fromSidecar.test.ts @@ -56,6 +56,8 @@ describe('fromSidecarProject', () => { baseStats: { damage: 2, fireRateRpm: 0, spread: 0, range: 12, recoil: 0, magazineSize: 0, reloadTime: 0 }, modelRef: 'gunrng:item/coil_barrel', weight: 1, + weightKg: 64.88005, + adsTimeAdd: -0.075, snapLocation: { position: [8, 8, 0], rotation: [0, 0, 0] }, model: { elements: [{ from: [7, 7, 0], to: [9, 9, 6], faces: {} }] }, }, @@ -73,6 +75,8 @@ describe('fromSidecarProject', () => { const part = project.gunParts[0] expect(part.slot).toBe('barrel') expect(part.baseStats.damage).toBe(2) + expect(part.weightKg).toBe(64.88005) + expect(part.adsTimeAdd).toBe(-0.075) expect(part.snapLocation).toEqual({ position: [8, 8, 0], rotation: [0, 0, 0] }) expect(part.model.elements[0].from).toEqual([7, 7, 0]) diff --git a/src/renderer/src/core/project/libraryValidation.test.ts b/src/renderer/src/core/project/libraryValidation.test.ts index a81abe7..5d4597c 100644 --- a/src/renderer/src/core/project/libraryValidation.test.ts +++ b/src/renderer/src/core/project/libraryValidation.test.ts @@ -31,6 +31,8 @@ describe('validateLibrary (RC1, grill F4)', () => { base.snapPoints['gunrng:barrel'] = { position: [8, 8, 14], rotation: [0, 0, 0], sizeMultiplier: 1 } const part = makeGunPart([], 'gunrng:stock', 'voltspoke', 'core') expect(validateLibrary(lib({ gunBases: [base], gunParts: [part] }))).toEqual([]) + const nativeAlias = makeGunPart([], 'armsng:stock', 'voltspoke', 'core') + expect(validateLibrary(lib({ gunParts: [nativeAlias] }))).toEqual([]) // Cross-mod namespaces do NOT collapse. const foreign = makeGunPart([], 'mymod:stock', 'voltspoke', 'core') expect(validateLibrary(lib({ gunParts: [foreign] }))).toHaveLength(1) @@ -125,6 +127,68 @@ describe('validateLibrary (RC1, grill F4)', () => { }) }) +describe('validateLibrary (P2.3a) — handling and part-order domains', () => { + it('requires authored weightKg to be finite binary32 and non-negative', () => { + for (const weightKg of [-0.01, Number.NaN, Number.POSITIVE_INFINITY, 3.5e38]) { + const part = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), weightKg } + expect(validateLibrary(lib({ gunParts: [part] })).map((issue) => issue.path)).toContain( + `gunParts[${part.id}].weightKg`, + ) + } + const valid = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), weightKg: 64.88005 } + expect(validateLibrary(lib({ gunParts: [valid] }))).toEqual([]) + }) + + it('requires authored adsTimeAdd to be finite binary32 while allowing negative values', () => { + for (const adsTimeAdd of [Number.NaN, Number.NEGATIVE_INFINITY, 3.5e38]) { + const part = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), adsTimeAdd } + expect(validateLibrary(lib({ gunParts: [part] })).map((issue) => issue.path)).toContain( + `gunParts[${part.id}].adsTimeAdd`, + ) + } + const valid = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), adsTimeAdd: -0.075 } + expect(validateLibrary(lib({ gunParts: [valid] }))).toEqual([]) + }) + + it('requires part-type sortOrder to be an integer and catches local-alias duplicate ids', () => { + const fractional = { id: 'receiver', displayName: 'Receiver', defaultRequired: true, sortOrder: 1.5 } + expect(validateLibrary(lib({ partTypes: [fractional] })).map((issue) => issue.path)).toContain( + 'partTypes[receiver].sortOrder', + ) + + const aliases = [ + { id: 'receiver', displayName: 'Receiver', defaultRequired: true, sortOrder: 10 }, + { id: 'armsng:receiver', displayName: 'Alias', defaultRequired: true, sortOrder: 20 }, + ] + expect(validateLibrary(lib({ partTypes: aliases })).map((issue) => issue.path)).toContain( + 'partTypes[armsng:receiver]', + ) + }) + + it('rejects local-alias gun-part id collisions while preserving foreign namespace identity', () => { + const localOne = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), id: 'gunrng:shared' } + const localTwo = { ...makeGunPart([], 'stock', 'voltspoke', 'core'), id: 'armsng:shared' } + expect(validateLibrary(lib({ gunParts: [localOne, localTwo] })).map((issue) => issue.path)).toContain( + 'gunParts[armsng:shared]', + ) + + const foreignOne = { ...localOne, id: 'one:shared' } + const foreignTwo = { ...localTwo, id: 'two:shared' } + expect(validateLibrary(lib({ gunParts: [foreignOne, foreignTwo] }))).toEqual([]) + }) + + it('rejects null/malformed handling scalars instead of defaulting them', () => { + const nullWeight = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), weightKg: null } as unknown as GunPart + const stringAds = { ...makeGunPart([], 'barrel', 'voltspoke', 'core'), adsTimeAdd: '0.25' } as unknown as GunPart + expect(validateLibrary(lib({ gunParts: [nullWeight] })).map((issue) => issue.path)).toContain( + `gunParts[${nullWeight.id}].weightKg`, + ) + expect(validateLibrary(lib({ gunParts: [stringAds] })).map((issue) => issue.path)).toContain( + `gunParts[${stringAds.id}].adsTimeAdd`, + ) + }) +}) + // --- EXPANSION (E1) validation additions -------------------------------------- describe('validateLibrary (E1) — ammo / element / effect references', () => { @@ -552,6 +616,17 @@ function zeroModifiers() { } describe('validateLibrary (final grill packet B) — reference + numeric totality', () => { + it('enforces the runtime signed-int32 sortOrder domain at the hard library gate', () => { + const valid = (sortOrder: number) => ({ id: `slot_${sortOrder}`, displayName: 'Slot', defaultRequired: false, sortOrder }) + expect(validateLibrary(lib({ partTypes: [valid(-2147483648), valid(2147483647)] }))).toEqual([]) + + for (const sortOrder of [-2147483649, 2147483648, Number.MAX_SAFE_INTEGER, 12.75, Number.NaN]) { + const issues = validateLibrary(lib({ partTypes: [valid(sortOrder)] })) + expect(issues.map((issue) => issue.path)).toContain(`partTypes[slot_${sortOrder}].sortOrder`) + expect(issues.find((issue) => issue.path.endsWith('.sortOrder'))?.message).toContain('signed 32-bit integer') + } + }) + it('validates gunType refs ONLY when the imported catalog provides a vocabulary (F1/F2)', () => { const base = makeGunBase([], 'ghost_type', 'voltspoke') // No catalog -> skipped (fresh/sample projects must not false-flag). diff --git a/src/renderer/src/core/project/libraryValidation.ts b/src/renderer/src/core/project/libraryValidation.ts index 01754e0..4387126 100644 --- a/src/renderer/src/core/project/libraryValidation.ts +++ b/src/renderer/src/core/project/libraryValidation.ts @@ -7,6 +7,8 @@ import { SYNERGY_CONDITION_TYPES, } from './effectCatalog' import { bareId } from './identifiers' +import { isValidPartTypeSortOrder } from './partOrdering' +import { handlingScalarValidationMessage } from './partHandling' import type { AmmoType, EffectDef, @@ -134,6 +136,15 @@ export function validateLibrary(lib: LibraryCollections): GeometryIssue[] { issues.push(...validateModel(base.model.elements, `${at}.model.elements`)) } + for (const partType of lib.partTypes ?? []) { + if (!isValidPartTypeSortOrder(partType.sortOrder)) { + issues.push({ + path: `partTypes[${partType.id}].sortOrder`, + message: `sortOrder ${partType.sortOrder} must be a signed 32-bit integer (-2147483648..2147483647)`, + }) + } + } + for (const part of lib.gunParts ?? []) { const at = `gunParts[${part.id}]` if (!hasPartType(part.slot)) { @@ -150,6 +161,22 @@ export function validateLibrary(lib: LibraryCollections): GeometryIssue[] { } issues.push(...validateModel(part.model.elements, `${at}.model.elements`)) + if (part.weightKg !== undefined) { + const message = handlingScalarValidationMessage('weightKg', part.weightKg) + if (message !== undefined) { + issues.push({ + path: `${at}.weightKg`, + message, + }) + } + } + if (part.adsTimeAdd !== undefined) { + const message = handlingScalarValidationMessage('adsTimeAdd', part.adsTimeAdd) + if (message !== undefined) { + issues.push({ path: `${at}.adsTimeAdd`, message }) + } + } + part.synergyModifiers?.forEach((synergy, i) => { const conditionType: string = synergy.when.type if (!(SYNERGY_CONDITION_TYPES as readonly string[]).includes(conditionType)) { diff --git a/src/renderer/src/core/project/partHandling.test.ts b/src/renderer/src/core/project/partHandling.test.ts new file mode 100644 index 0000000..b427df5 --- /dev/null +++ b/src/renderer/src/core/project/partHandling.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_HANDLING_PREVIEW_CONFIG, + handlingPreviewPolicySummary, + handlingScalarValidationMessage, + PartHandlingCompositionError, + resolvePartHandling, +} from './partHandling' +import type { GunPart, PartType } from './types' + +const stats = { + damage: 0, + fireRateRpm: 0, + spread: 0, + range: 0, + recoil: 0, + magazineSize: 0, + reloadTime: 0, +} + +function part(id: string, slot: string, weightKg?: number, adsTimeAdd?: number): GunPart { + return { + id, + slot, + style: '', + partGroup: '', + baseStats: stats, + modelRef: '', + weight: 1, + model: { textures: {}, elements: [] }, + ...(weightKg !== undefined ? { weightKg } : {}), + ...(adsTimeAdd !== undefined ? { adsTimeAdd } : {}), + } +} + +const type = (id: string, sortOrder: number): PartType => ({ + id, + displayName: id, + defaultRequired: false, + sortOrder, +}) + +function floatBits(value: number): string { + const view = new DataView(new ArrayBuffer(4)) + view.setFloat32(0, value, false) + return `0x${view.getUint32(0, false).toString(16).padStart(8, '0')}` +} + +describe('resolvePartHandling', () => { + it('derives author-facing policy copy from the effective preview config', () => { + expect(handlingPreviewPolicySummary()).toBe( + 'Server-configurable preview policy: -1% movement/kg (floor x0.65), 0.25 s base ADS, clamped to 0.05-3 s.', + ) + expect(handlingPreviewPolicySummary({ + weightPenaltyPerKg: 0.02, + minimumEquippedMovementMultiplier: 0.75, + baseAdsTimeSeconds: 0.5, + })).toContain('-2% movement/kg (floor x0.75), 0.5 s base ADS') + }) + + it('provides the same scalar diagnostics consumed by the Part editor', () => { + expect(handlingScalarValidationMessage('weightKg', -1)).toBe('Weight must be non-negative.') + expect(handlingScalarValidationMessage('weightKg', 3.5e38)).toContain('finite binary32') + expect(handlingScalarValidationMessage('adsTimeAdd', 3.5e38)).toContain('finite binary32') + expect(handlingScalarValidationMessage('adsTimeAdd', -1)).toBeUndefined() + expect(handlingScalarValidationMessage('weightKg', null)).toBe('Weight must be numeric.') + expect(handlingScalarValidationMessage('adsTimeAdd', '0.25')).toBe('ADS time addition must be numeric.') + }) + + it('rounds authored values and every canonical-order addition to binary32', () => { + const gunParts = [ + part('receiver-part', 'receiver', 64.88005, -0.075), + part('barrel-part', 'barrel', 96.25082, 0.03125), + part('stock-part', 'stock', 4.5383987), + ] + const resolved = resolvePartHandling({ + selectedParts: [ + { id: 'stock-part', slot: 'stock' }, + { id: 'receiver-part', slot: 'armsng:receiver' }, + { id: 'barrel-part', slot: 'gunrng:barrel' }, + ], + gunParts, + partTypes: [type('receiver', 10), type('barrel', 20), type('stock', 30)], + }) + + expect(resolved.totalWeightKg).toBe(165.66925048828125) + expect(floatBits(resolved.totalWeightKg)).toBe('0x4325ab54') + expect(resolved.adsTimeAddSeconds).toBe(-0.04375000298023224) + expect(floatBits(resolved.adsTimeAddSeconds)).toBe('0xbd333334') + expect(resolved.movementMultiplier).toBe(Math.max(0.65, 1 - 165.66925048828125 * 0.01)) + expect(resolved.effectiveAdsTimeSeconds).toBe(0.25 - 0.04375000298023224) + expect(resolved.previewConfig).toEqual(DEFAULT_HANDLING_PREVIEW_CONFIG) + }) + + it('makes the canonical order observable and pins the intermediate binary32 sum', () => { + const gunParts = [ + part('receiver-part', 'receiver', 64.88005), + part('barrel-part', 'barrel', 96.25082), + part('stock-part', 'stock', 4.5383987), + ] + const firstTwo = resolvePartHandling({ + selectedParts: gunParts.slice(0, 2).map((p) => ({ id: p.id, slot: p.slot })), + gunParts, + partTypes: [type('receiver', 10), type('barrel', 20)], + }) + expect(firstTwo.totalWeightKg).toBe(161.130859375) + expect(floatBits(firstTwo.totalWeightKg)).toBe('0x43212180') + + const reverse = resolvePartHandling({ + selectedParts: gunParts.map((p) => ({ id: p.id, slot: p.slot })), + gunParts, + partTypes: [type('stock', 10), type('barrel', 20), type('receiver', 30)], + }) + expect(floatBits(reverse.totalWeightKg)).toBe('0x4325ab55') + }) + + it('refuses an invalid authored scalar after binary32 conversion', () => { + for (const badPart of [ + part('negative', 'barrel', -0.01), + part('nan-weight', 'barrel', Number.NaN), + part('f32-infinite-weight', 'barrel', 3.5e38), + part('f32-infinite-ads', 'barrel', 0, 3.5e38), + ]) { + expect(() => resolvePartHandling({ + selectedParts: [{ id: badPart.id, slot: badPart.slot }], + gunParts: [badPart], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'invalid-scalar' })) + } + }) + + it('defaults only undefined scalars and refuses null or malformed raw values', () => { + const absent = part('absent', 'barrel') + expect(resolvePartHandling({ + selectedParts: [{ id: absent.id, slot: absent.slot }], + gunParts: [absent], + partTypes: [], + }).totalWeightKg).toBe(0) + + for (const malformed of [ + { ...part('null-weight', 'barrel'), weightKg: null }, + { ...part('string-ads', 'barrel'), adsTimeAdd: '0.25' }, + { ...part('boolean-weight', 'barrel'), weightKg: false }, + ] as unknown as GunPart[]) { + expect(() => resolvePartHandling({ + selectedParts: [{ id: malformed.id, slot: malformed.slot }], + gunParts: [malformed], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'invalid-scalar' })) + } + }) + + it('refuses canonical gun-part id collisions before selection lookup while foreign ids stay distinct', () => { + const localOne = part('gunrng:shared', 'barrel', 1) + const localTwo = part('armsng:shared', 'barrel', 100) + expect(() => resolvePartHandling({ + selectedParts: [{ id: 'gunrng:shared', slot: 'barrel' }], + gunParts: [localOne, localTwo], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'canonical-part-id-collision' })) + + const foreignOne = part('one:shared', 'barrel', 1) + const foreignTwo = part('two:shared', 'barrel', 100) + expect(resolvePartHandling({ + selectedParts: [{ id: 'one:shared', slot: 'barrel' }], + gunParts: [foreignOne, foreignTwo], + partTypes: [], + }).totalWeightKg).toBe(1) + }) + + it('refuses a Roll selection whose reported slot disagrees with the selected part definition', () => { + const selected = part('receiver-part', 'receiver', 3) + expect(() => resolvePartHandling({ + selectedParts: [{ id: selected.id, slot: 'barrel' }], + gunParts: [selected], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'selected-part-slot-mismatch' })) + + expect(resolvePartHandling({ + selectedParts: [{ id: selected.id, slot: 'armsng:receiver' }], + gunParts: [selected], + partTypes: [], + }).totalWeightKg).toBe(3) + }) + + it('refuses missing selected parts and duplicate local slot spellings with typed errors', () => { + expect(() => resolvePartHandling({ + selectedParts: [{ id: 'missing', slot: 'barrel' }], + gunParts: [], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'missing-selected-part' })) + + expect(() => resolvePartHandling({ + selectedParts: [{ id: 'one', slot: 'barrel' }, { id: 'two', slot: 'armsng:barrel' }], + gunParts: [part('one', 'barrel'), part('two', 'barrel')], + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'duplicate-selected-slot' })) + }) + + it('refuses binary32 aggregate overflow separately for weight and ADS', () => { + const maxFloat = 3.4028234663852886e38 + const weightParts = [part('a', 'receiver', maxFloat), part('b', 'barrel', maxFloat)] + expect(() => resolvePartHandling({ + selectedParts: weightParts.map((p) => ({ id: p.id, slot: p.slot })), + gunParts: weightParts, + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'weight-overflow' })) + + const adsParts = [part('a', 'receiver', 0, maxFloat), part('b', 'barrel', 0, maxFloat)] + expect(() => resolvePartHandling({ + selectedParts: adsParts.map((p) => ({ id: p.id, slot: p.slot })), + gunParts: adsParts, + partTypes: [], + })).toThrowError(expect.objectContaining({ code: 'ads-overflow' })) + }) + + it('clamps effective ADS to 0.05..3 seconds and accepts server-policy overrides', () => { + const selected = [{ id: 'part', slot: 'barrel' }] + const fast = resolvePartHandling({ + selectedParts: selected, + gunParts: [part('part', 'barrel', 10, -10)], + partTypes: [], + }) + expect(fast.effectiveAdsTimeSeconds).toBe(0.05) + + const slow = resolvePartHandling({ + selectedParts: selected, + gunParts: [part('part', 'barrel', 10, 10)], + partTypes: [], + previewConfig: { + weightPenaltyPerKg: 0.02, + minimumEquippedMovementMultiplier: 0.75, + baseAdsTimeSeconds: 0.5, + }, + }) + expect(slow.movementMultiplier).toBe(0.8) + expect(slow.effectiveAdsTimeSeconds).toBe(3) + }) + + it('exposes a concrete error class for prominent composition diagnostics', () => { + try { + resolvePartHandling({ selectedParts: [{ id: 'missing', slot: 'barrel' }], gunParts: [], partTypes: [] }) + throw new Error('expected resolver to refuse') + } catch (error) { + expect(error).toBeInstanceOf(PartHandlingCompositionError) + expect((error as PartHandlingCompositionError).message).toContain('missing') + } + }) +}) diff --git a/src/renderer/src/core/project/partHandling.ts b/src/renderer/src/core/project/partHandling.ts new file mode 100644 index 0000000..8592210 --- /dev/null +++ b/src/renderer/src/core/project/partHandling.ts @@ -0,0 +1,186 @@ +import { identifierIdentity } from './identifiers' +import { orderSelectedParts } from './partOrdering' +import type { GunPart, PartType } from './types' + +export const MIN_ADS_TIME_SECONDS = 0.05 +export const MAX_ADS_TIME_SECONDS = 3 + +export interface HandlingPreviewConfig { + weightPenaltyPerKg: number + minimumEquippedMovementMultiplier: number + baseAdsTimeSeconds: number +} + +/** Arms NG defaults only. Server operators may configure different values. */ +export const DEFAULT_HANDLING_PREVIEW_CONFIG: Readonly = Object.freeze({ + weightPenaltyPerKg: 0.01, + minimumEquippedMovementMultiplier: 0.65, + baseAdsTimeSeconds: 0.25, +}) + +/** Human-readable policy copy derived from the same values used by the preview. */ +export function handlingPreviewPolicySummary( + config: HandlingPreviewConfig = DEFAULT_HANDLING_PREVIEW_CONFIG, +): string { + return `Server-configurable preview policy: -${config.weightPenaltyPerKg * 100}% movement/kg (floor x${config.minimumEquippedMovementMultiplier}), ${config.baseAdsTimeSeconds} s base ADS, clamped to ${MIN_ADS_TIME_SECONDS}-${MAX_ADS_TIME_SECONDS} s.` +} + +export type PartHandlingErrorCode = + | 'invalid-scalar' + | 'canonical-part-id-collision' + | 'missing-selected-part' + | 'selected-part-slot-mismatch' + | 'duplicate-selected-slot' + | 'weight-overflow' + | 'ads-overflow' + +export class PartHandlingCompositionError extends Error { + readonly code: PartHandlingErrorCode + + constructor(code: PartHandlingErrorCode, message: string) { + super(message) + this.name = 'PartHandlingCompositionError' + this.code = code + } +} + +export interface SelectedRollPart { + id: string + slot: string +} + +export interface ResolvedPartHandling { + totalWeightKg: number + adsTimeAddSeconds: number + movementMultiplier: number + effectiveAdsTimeSeconds: number + previewConfig: HandlingPreviewConfig +} + +export interface ResolvePartHandlingInput { + selectedParts: readonly SelectedRollPart[] + gunParts: readonly GunPart[] + partTypes: readonly PartType[] + previewConfig?: HandlingPreviewConfig +} + +export function handlingScalarValidationMessage( + field: 'weightKg' | 'adsTimeAdd', + value: unknown, +): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'number') { + return field === 'weightKg' ? 'Weight must be numeric.' : 'ADS time addition must be numeric.' + } + const binary32 = Math.fround(value) + if (!Number.isFinite(binary32)) { + return field === 'weightKg' + ? 'Weight must become a finite binary32 value.' + : 'ADS time addition must become a finite binary32 value.' + } + if (field === 'weightKg' && binary32 < 0) return 'Weight must be non-negative.' + return undefined +} + +function authoredBinary32(part: GunPart, field: 'weightKg' | 'adsTimeAdd'): number { + const raw: unknown = part[field] + if (raw === undefined) return Math.fround(0) + if (handlingScalarValidationMessage(field, raw) !== undefined) { + const wireField = field === 'weightKg' ? 'weight_kg' : 'ads_time_add' + throw new PartHandlingCompositionError( + 'invalid-scalar', + `Part ${part.id} has invalid ${wireField}: ${String(raw)}`, + ) + } + return Math.fround(raw as number) +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)) +} + +/** + * Resolve DesignRNG's native P2.3a Roll-preview aggregate. Values are parsed + * as Java floats (`Math.fround`) and every addition rounds again after the + * canonical P2.2a part ordering step. + */ +export function resolvePartHandling(input: ResolvePartHandlingInput): ResolvedPartHandling { + const selectedSlots = new Set() + for (const selected of input.selectedParts) { + const slot = identifierIdentity(selected.slot) + if (selectedSlots.has(slot)) { + throw new PartHandlingCompositionError( + 'duplicate-selected-slot', + `Roll selects more than one part for slot ${slot}`, + ) + } + selectedSlots.add(slot) + } + + // Refuse before building the lookup map: Map(array) would silently retain + // the later local-alias spelling and make a Roll select the wrong part. + const canonicalPartIds = new Set() + for (const part of input.gunParts) { + const id = identifierIdentity(part.id) + if (canonicalPartIds.has(id)) { + throw new PartHandlingCompositionError( + 'canonical-part-id-collision', + `DesignRNG project contains more than one gun part with canonical id ${id}`, + ) + } + canonicalPartIds.add(id) + } + const byId = new Map(input.gunParts.map((part) => [identifierIdentity(part.id), part])) + const resolvedSelections = input.selectedParts.map((selected) => { + const part = byId.get(identifierIdentity(selected.id)) + if (part === undefined) { + throw new PartHandlingCompositionError( + 'missing-selected-part', + `Roll-selected part ${selected.id} is missing from the DesignRNG project`, + ) + } + const selectedSlot = identifierIdentity(selected.slot) + const authoredSlot = identifierIdentity(part.slot) + if (selectedSlot !== authoredSlot) { + throw new PartHandlingCompositionError( + 'selected-part-slot-mismatch', + `Roll-selected part ${selected.id} reports slot ${selected.slot}, but its DesignRNG definition uses ${part.slot}`, + ) + } + return { selected, part, slot: selected.slot } + }) + + let totalWeightKg = Math.fround(0) + let adsTimeAddSeconds = Math.fround(0) + for (const { part } of orderSelectedParts(resolvedSelections, input.partTypes)) { + // Re-grill NIT-1: validate BOTH scalars before performing either addition, + // matching the runtime's ResolvedGunHandling.resolveCanonical order so a + // doubly-broken part picks the same refusal code on both sides. + const weightKg = authoredBinary32(part, 'weightKg') + const adsTimeAdd = authoredBinary32(part, 'adsTimeAdd') + totalWeightKg = Math.fround(totalWeightKg + weightKg) + if (!Number.isFinite(totalWeightKg)) { + throw new PartHandlingCompositionError('weight-overflow', 'Part weight_kg aggregate overflows binary32') + } + adsTimeAddSeconds = Math.fround(adsTimeAddSeconds + adsTimeAdd) + if (!Number.isFinite(adsTimeAddSeconds)) { + throw new PartHandlingCompositionError('ads-overflow', 'Part ads_time_add aggregate overflows binary32') + } + } + + const config = input.previewConfig ?? DEFAULT_HANDLING_PREVIEW_CONFIG + return { + totalWeightKg, + adsTimeAddSeconds, + movementMultiplier: Math.max( + config.minimumEquippedMovementMultiplier, + 1 - totalWeightKg * config.weightPenaltyPerKg, + ), + effectiveAdsTimeSeconds: clamp( + config.baseAdsTimeSeconds + adsTimeAddSeconds, + MIN_ADS_TIME_SECONDS, + MAX_ADS_TIME_SECONDS, + ), + previewConfig: { ...config }, + } +} diff --git a/src/renderer/src/core/project/partOrdering.test.ts b/src/renderer/src/core/project/partOrdering.test.ts new file mode 100644 index 0000000..80d04af --- /dev/null +++ b/src/renderer/src/core/project/partOrdering.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { identifierIdentity } from './identifiers' +import { + comparePartTypeIds, + isValidPartTypeSortOrder, + MAX_PART_TYPE_SORT_ORDER, + MIN_PART_TYPE_SORT_ORDER, + parseAuthoredPartTypeSortOrder, + orderSelectedParts, + partTypeSortOrder, +} from './partOrdering' +import type { PartType } from './types' + +const type = (id: string, sortOrder: number): PartType => ({ + id, + displayName: id, + defaultRequired: false, + sortOrder, +}) + +describe('partOrdering', () => { + it('uses authored integer sortOrder verbatim, including the default-looking value 1000', () => { + const types = [type('receiver', 1000), type('barrel', 1001)] + expect(partTypeSortOrder('armsng:receiver', types)).toBe(1000) + expect(orderSelectedParts([{ slot: 'barrel' }, { slot: 'gunrng:receiver' }], types).map((p) => p.slot)).toEqual([ + 'gunrng:receiver', + 'barrel', + ]) + }) + + it('uses the legacy receiver/barrel/stock/magazine/sight fallback, then 1000 for other missing types', () => { + expect(['receiver', 'barrel', 'stock', 'magazine', 'sight', 'underbarrel'].map((id) => partTypeSortOrder(id, []))).toEqual([ + 10, 20, 30, 40, 50, 1000, + ]) + }) + + it('rides the shipped ADR 0008 identity rule (bare/gunrng/armsng are one local id)', () => { + expect(identifierIdentity('receiver')).toBe('gunrng:receiver') + expect(identifierIdentity('gunrng:receiver')).toBe('gunrng:receiver') + expect(identifierIdentity('armsng:receiver')).toBe('gunrng:receiver') + expect(identifierIdentity('othermod:receiver')).toBe('othermod:receiver') + expect(partTypeSortOrder('armsng:receiver', [type('gunrng:receiver', 73)])).toBe(73) + expect(partTypeSortOrder('gunrng:receiver', [type('armsng:receiver', 74)])).toBe(74) + }) + + it('breaks equal-order ties by ordinal UTF-16 canonical id, never locale collation', () => { + const types = [type('part_b', 5), type('part-a', 5), type('part.c', 5)] + const ordered = orderSelectedParts( + [{ slot: 'gunrng:part_b' }, { slot: 'armsng:part.c' }, { slot: 'part-a' }], + types, + ) + expect(ordered.map((p) => p.slot)).toEqual(['part-a', 'armsng:part.c', 'gunrng:part_b']) + expect(comparePartTypeIds('part-a', 'part.c', types)).toBeLessThan(0) + }) + + it('accepts only signed-int32 Part type editor drafts', () => { + expect(parseAuthoredPartTypeSortOrder('1000')).toBe(1000) + expect(parseAuthoredPartTypeSortOrder('-3')).toBe(-3) + expect(parseAuthoredPartTypeSortOrder(String(MIN_PART_TYPE_SORT_ORDER))).toBe(MIN_PART_TYPE_SORT_ORDER) + expect(parseAuthoredPartTypeSortOrder(String(MAX_PART_TYPE_SORT_ORDER))).toBe(MAX_PART_TYPE_SORT_ORDER) + expect(parseAuthoredPartTypeSortOrder('1.5')).toBeUndefined() + expect(parseAuthoredPartTypeSortOrder('-2147483649')).toBeUndefined() + expect(parseAuthoredPartTypeSortOrder('2147483648')).toBeUndefined() + expect(parseAuthoredPartTypeSortOrder(String(Number.MAX_SAFE_INTEGER))).toBeUndefined() + expect(parseAuthoredPartTypeSortOrder('')).toBeUndefined() + expect(parseAuthoredPartTypeSortOrder('Infinity')).toBeUndefined() + }) + + it('exposes one strict signed-int32 predicate for parsed and loaded values', () => { + expect(isValidPartTypeSortOrder(MIN_PART_TYPE_SORT_ORDER)).toBe(true) + expect(isValidPartTypeSortOrder(MAX_PART_TYPE_SORT_ORDER)).toBe(true) + expect(isValidPartTypeSortOrder(2147483648)).toBe(false) + expect(isValidPartTypeSortOrder(-2147483649)).toBe(false) + expect(isValidPartTypeSortOrder(Number.MAX_SAFE_INTEGER)).toBe(false) + expect(isValidPartTypeSortOrder(12.75)).toBe(false) + expect(isValidPartTypeSortOrder(true)).toBe(false) + }) +}) diff --git a/src/renderer/src/core/project/partOrdering.ts b/src/renderer/src/core/project/partOrdering.ts new file mode 100644 index 0000000..a622c52 --- /dev/null +++ b/src/renderer/src/core/project/partOrdering.ts @@ -0,0 +1,71 @@ +import { identifierIdentity, LOCAL_NAMESPACE } from './identifiers' +import type { PartType } from './types' + +/** Arms NG decodes PartTypeDefinition.sort_order through Codec.INT. */ +export const MIN_PART_TYPE_SORT_ORDER = -2147483648 +export const MAX_PART_TYPE_SORT_ORDER = 2147483647 + +const LEGACY_SORT_ORDER: Readonly> = { + receiver: 10, + barrel: 20, + stock: 30, + magazine: 40, + sight: 50, +} + +export function isValidPartTypeSortOrder(value: unknown): value is number { + return typeof value === 'number' + && Number.isInteger(value) + && value >= MIN_PART_TYPE_SORT_ORDER + && value <= MAX_PART_TYPE_SORT_ORDER +} + +export function parseAuthoredPartTypeSortOrder(raw: string): number | undefined { + if (raw.trim() === '') return undefined + const value = Number(raw) + return isValidPartTypeSortOrder(value) ? value : undefined +} + +// Local part-type identity is ADR 0008's project-wide rule: bare, legacy +// `gunrng:` and native-runtime `armsng:` spellings name the same authored +// entity, foreign namespaces remain exact. `identifiers.ts` owns that rule +// (`identifierIdentity`); this module only layers ordering on top of it. + +function localPath(ref: string): string | null { + const canonical = identifierIdentity(ref) + const prefix = `${LOCAL_NAMESPACE}:` + return canonical.startsWith(prefix) ? canonical.slice(prefix.length) : null +} + +function authoredType(ref: string, partTypes: readonly PartType[]): PartType | undefined { + const canonical = identifierIdentity(ref) + return partTypes.find((candidate) => identifierIdentity(candidate.id) === canonical) +} + +/** Resolve the runtime's P2.2a part application order for one slot. */ +export function partTypeSortOrder(ref: string, partTypes: readonly PartType[]): number { + const authored = authoredType(ref, partTypes) + if (authored !== undefined) return authored.sortOrder + const path = localPath(ref) + return path === null ? 1000 : (LEGACY_SORT_ORDER[path] ?? 1000) +} + +/** Ordinal UTF-16 comparison, matching Java String ordering for wire ids. */ +function ordinalCompare(left: string, right: string): number { + if (left === right) return 0 + return left < right ? -1 : 1 +} + +export function comparePartTypeIds(left: string, right: string, partTypes: readonly PartType[]): number { + const byOrder = partTypeSortOrder(left, partTypes) - partTypeSortOrder(right, partTypes) + if (byOrder !== 0) return byOrder + return ordinalCompare(identifierIdentity(left), identifierIdentity(right)) +} + +/** Return a new canonically ordered selection; the input remains untouched. */ +export function orderSelectedParts( + parts: readonly T[], + partTypes: readonly PartType[], +): T[] { + return [...parts].sort((left, right) => comparePartTypeIds(left.slot, right.slot, partTypes)) +} diff --git a/src/renderer/src/core/project/projectFile.test.ts b/src/renderer/src/core/project/projectFile.test.ts index dbbb823..74df3de 100644 --- a/src/renderer/src/core/project/projectFile.test.ts +++ b/src/renderer/src/core/project/projectFile.test.ts @@ -52,8 +52,8 @@ describe('project file v2 (RC13 persistence)', () => { gunParts: [ { ...sampleProject.gunParts[0], - weightKg: 1.5, - adsTimeAdd: 0.1, + weightKg: 64.88005, + adsTimeAdd: -0.075, silence: { soundRadiusBlocks: 4, soundEvent: 'gunrng:silenced_shot' }, recoilPitchMult: 0.8, synergyModifiers: [ @@ -70,8 +70,8 @@ describe('project file v2 (RC13 persistence)', () => { ], } const restored = parseProjectFile(serializeProject(authored)) - expect(restored.gunParts[0].weightKg).toBe(1.5) - expect(restored.gunParts[0].adsTimeAdd).toBe(0.1) + expect(restored.gunParts[0].weightKg).toBe(64.88005) + expect(restored.gunParts[0].adsTimeAdd).toBe(-0.075) expect(restored.gunParts[0].silence).toEqual({ soundRadiusBlocks: 4, soundEvent: 'gunrng:silenced_shot' }) expect(restored.gunParts[0].recoilPitchMult).toBe(0.8) expect(restored.gunParts[0].synergyModifiers).toEqual(authored.gunParts[0].synergyModifiers) diff --git a/src/renderer/src/modeller/panels/PartPanel.tsx b/src/renderer/src/modeller/panels/PartPanel.tsx index 2a71af0..8b320da 100644 --- a/src/renderer/src/modeller/panels/PartPanel.tsx +++ b/src/renderer/src/modeller/panels/PartPanel.tsx @@ -6,6 +6,7 @@ import { identityStatModifierSet, StatModifierGrid } from '../../ui/definitions/ import { OptionalNumberField } from '../../ui/definitions/OptionalNumberField' import { ALL_SYNERGY_CONDITION_TYPES, defaultConditionFor } from '../../ui/definitions/synergyConditionFields' import type { BaseStats, SynergyCondition, SynergyModifier } from '../../core/project/types' +import { handlingScalarValidationMessage } from '../../core/project/partHandling' import { useModeller } from '../store' import { usePartForm, type PartFormFields } from '../partFormStore' import { NumberField } from './CuboidInspector' @@ -253,12 +254,14 @@ function AdvancedGroup(props: { label="weight (kg)" value={fields.weightKg} placeholder={0} + validationMessage={handlingScalarValidationMessage('weightKg', fields.weightKg)} onCommit={(n) => setField({ weightKg: n })} /> setField({ adsTimeAdd: n })} /> { + it('preserves every authored decimal through a focus/blur-equivalent unchanged draft', () => { + expect(optionalNumberText(64.88005)).toBe('64.88005') + expect(commitOptionalNumberDraft(optionalNumberText(64.88005), 64.88005)).toEqual({ kind: 'unchanged' }) + expect(commitOptionalNumberDraft('64.88', 64.88005)).toEqual({ kind: 'commit', value: 64.88 }) + }) + + it('keeps undefined distinct, supports clearing, and rejects incomplete drafts', () => { + expect(optionalNumberText(undefined)).toBe('') + expect(commitOptionalNumberDraft('', undefined)).toEqual({ kind: 'unchanged' }) + expect(commitOptionalNumberDraft('', 1.5)).toEqual({ kind: 'commit', value: undefined }) + expect(commitOptionalNumberDraft('-', 1.5)).toEqual({ kind: 'invalid' }) + }) + + it('renders inline accessible diagnostics', () => { + const html = renderToStaticMarkup(createElement(OptionalNumberField, { + label: 'weight (kg)', + value: -1, + placeholder: 0, + validationMessage: 'Weight must be non-negative.', + onCommit: vi.fn(), + })) + expect(html).toContain('aria-invalid="true"') + expect(html).toContain('aria-describedby=') + expect(html).toContain('role="alert"') + expect(html).toContain('Weight must be non-negative.') + }) +}) diff --git a/src/renderer/src/ui/definitions/OptionalNumberField.tsx b/src/renderer/src/ui/definitions/OptionalNumberField.tsx index a85eb57..95968dc 100644 --- a/src/renderer/src/ui/definitions/OptionalNumberField.tsx +++ b/src/renderer/src/ui/definitions/OptionalNumberField.tsx @@ -1,4 +1,4 @@ -import { useState, type KeyboardEvent } from 'react' +import { useId, useState, type KeyboardEvent } from 'react' import { formatDraftNumber, parseDraftNumber } from '../../modeller/panels/numericField' // --- Nullable-aware NumberField (E4/E5 grill F3) ------------------------------ @@ -11,24 +11,46 @@ import { formatDraftNumber, parseDraftNumber } from '../../modeller/panels/numer // with the runtime default as placeholder, and blanking the input commits // `undefined` (the only way to un-author an optional scalar). +export function optionalNumberText(value: number | undefined): string { + return value === undefined ? '' : String(value) +} + +export type OptionalNumberDraftResult = + | { kind: 'unchanged' } + | { kind: 'invalid' } + | { kind: 'commit'; value: number | undefined } + +/** Pure focus/blur contract used by the component and precision tests. */ +export function commitOptionalNumberDraft( + draft: string, + liveValue: number | undefined, +): OptionalNumberDraftResult { + if (draft.trim() === '') { + return liveValue === undefined ? { kind: 'unchanged' } : { kind: 'commit', value: undefined } + } + const parsed = parseDraftNumber(draft) + if (parsed == null) return { kind: 'invalid' } + return Object.is(parsed, liveValue) ? { kind: 'unchanged' } : { kind: 'commit', value: parsed } +} + export function OptionalNumberField(props: { label: string value: number | undefined /** The runtime's default, shown greyed when unset — never committed. */ placeholder: number + validationMessage?: string onCommit: (n: number | undefined) => void }) { - const { label, value, placeholder, onCommit } = props + const { label, value, placeholder, validationMessage, onCommit } = props const [draft, setDraft] = useState(null) + const inputId = useId() + const errorId = `${inputId}-error` const commit = () => { if (draft == null) return - if (draft.trim() === '') onCommit(undefined) - else { - const parsed = parseDraftNumber(draft) - if (parsed != null) onCommit(parsed) - // rejected parse just reverts to the live value, same as NumberField - } + const result = commitOptionalNumberDraft(draft, value) + if (result.kind === 'commit') onCommit(result.value) + // Rejected and unchanged drafts revert without a semantic edit. setDraft(null) } @@ -41,15 +63,23 @@ export function OptionalNumberField(props: { ) } diff --git a/src/renderer/src/ui/library/Library.tsx b/src/renderer/src/ui/library/Library.tsx index ac66228..22d4f9c 100644 --- a/src/renderer/src/ui/library/Library.tsx +++ b/src/renderer/src/ui/library/Library.tsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from 'react' +import { useEffect, useId, useMemo, useState } from 'react' import { validateLibrary } from '../../core/project/libraryValidation' import type { AmmoType, EffectDef, ElementDef, FireMode, GunBase, GunPart, PackProject, PartType, Style } from '../../core/project/types' import { useModelThumbnail } from '../shared/thumbnail' import { filterAmmoTypes, filterBases, filterEffects, filterElements, filterFireModes, filterParts } from '../shared/filter' import { NewEntityModal } from './NewEntityModal' import type { DefinitionKind } from '../../core/project/applyDefinitions' +import { + MAX_PART_TYPE_SORT_ORDER, + MIN_PART_TYPE_SORT_ORDER, + parseAuthoredPartTypeSortOrder, +} from '../../core/project/partOrdering' import type { PresetKind } from '../../core/presets' import { SaveAsPresetButton } from './presets/SaveAsPresetButton' import { NewStyleModal } from './presets/NewStyleModal' @@ -576,12 +581,17 @@ function FireModeCard({ fireMode, onOpen }: { fireMode: FireMode; onOpen: (id: s // local draft/Save/Cancel split like the Ammo/Definitions editors) since // there's no geometry and no multi-field grouping to stage. -function PartTypeRow(props: { +export function PartTypeRow(props: { partType: PartType onSave?: (next: PartType) => void onDelete?: (id: string) => void }) { const { partType, onSave, onDelete } = props + const [sortDraft, setSortDraft] = useState(String(partType.sortOrder)) + const sortErrorId = useId() + useEffect(() => setSortDraft(String(partType.sortOrder)), [partType.sortOrder]) + const parsedSortOrder = parseAuthoredPartTypeSortOrder(sortDraft) + const invalidSortOrder = parsedSortOrder === undefined return (
{partType.id} @@ -599,15 +609,33 @@ function PartTypeRow(props: { /> required - { - const n = Number(e.target.value) - onSave?.({ ...partType, sortOrder: Number.isFinite(n) ? n : partType.sortOrder }) - }} - /> +
+ { + const nextDraft = e.target.value + setSortDraft(nextDraft) + const next = parseAuthoredPartTypeSortOrder(nextDraft) + if (next !== undefined) onSave?.({ ...partType, sortOrder: next }) + }} + onBlur={() => { + if (invalidSortOrder) setSortDraft(String(partType.sortOrder)) + }} + /> + {invalidSortOrder && ( + + Signed 32-bit integer required + + )} +