Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,20 @@ not a v4 owner or release gate. See ADR 0007.
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.
- **D1.3 + D1.4 IMPLEMENTED (2026-07-18; pending merge):** the two P2.3-sequenced
tooling obligations from Arms NG's DESIGNRNG_CHANGES.md ledger. D1.3 closes the F7
half-union: `project_to_design_json` exposes read-only affix pools (id/displayName/
entry condition ids, preserved exactly), plumbed through `SidecarProject`/
`affixPoolFromWire`/required `PackProject.affixPools`, and the E6 synergy condition
picker now offers `SYNERGY_CONDITION_TYPES` ∪ observed pool condition ids (deduped by
canonical local identity per ADR 0008; foreign ids preserved verbatim via a new
template-literal `SynergyCondition` variant). Pools stay import-owned — never synced or
pruned. D1.4 makes `GunPart.tags` (first-class in gunModeller, verified — NOT extras)
authorable end-to-end: PartPanel tags editor + observed-tag suggestions for the
`withPartTag` sub-field (free entry legal, ADR 0004 §4 exact case-sensitive matching),
`save_part` presence-guarded upsert, `validateLibrary` non-empty/deduped tag checks.
Evidence: pytest 709→715/4-skip, vitest 588→615, typecheck/lint/build green; ledger
in `docs/GUNRNG_CHANGES.md` (`## D1.3 + D1.4`).
- 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.
Expand Down
85 changes: 84 additions & 1 deletion docs/GUNRNG_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ longer described as open mod-side work.
| R1.9 | Ammo economy (`craftCost`) | AmmoEditor (E4) | **Authoring shipped** (mod-side recipe generation still TODO) |
| 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.12 | Part synergies (conditional modifiers) | PartPanel synergy editor (E6) | **Authoring shipped**; F7 union COMPLETE 2026-07-18 (D1.3): the condition picker now offers catalog ∪ imported-pool condition ids — see the D1.3/D1.4 section 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 |
| R1.14 | Pierce/ricochet/AoE interaction contract | — (semantics contract, nothing to author) | **Runtime-only** |
| R1.15 | Multishot interplay (extends R1.1) | Reflected in the Roll card's honest DPS/pellet display (E7); nothing separate to author | **Runtime-only** (DesignRNG-side math done; mod must mirror it — see E7 below) |
Expand Down Expand Up @@ -519,6 +519,10 @@ affix pools on the wire. Risk register item ("hardcoded condition vocabulary dri
imported packs") stays open/accepted until that plumbing exists — tracked here, not built
speculatively.

> **RESOLVED 2026-07-18 (D1.3):** the blocked half is built — `project_to_design_json` now
> serializes affix pools, and the picker reads the full union. Risk register item closed.
> See the `## D1.3 + D1.4` section below.

## E7 — Resolver/Roll parity: R-V2 CLOSURE + multishot DPS runtime-parity obligation (2026-07-05)

Closes the R-V2 open question above (E2 confirmed the payload GAP; E7 builds the fix).
Expand Down Expand Up @@ -621,3 +625,82 @@ values other than the runtime's current behavior, that implementation should agr
per-strategy combination rules stated here (add/multiply/max), since this is the ONE place in
either codebase that spells them out precisely.


## D1.3 + D1.4 — Affix-pool condition exposure + part tags vocabulary (2026-07-18)

Closes the two P2.3-sequenced tooling obligations from Arms NG's DESIGNRNG_CHANGES.md
ledger (rows D1.3/D1.4). No mod-pack `.json` shape changes — D1.3 is a design-JSON
(sidecar → Electron) exposure of data the packs already carry, and D1.4 authors an
EXISTING wire field (`GunPart.tags`, `list<string>`).

### D1.3 — affix pools reach the TS side (the F7 union, completed)

`project_to_design_json` now emits `affixPools: [{id, displayName, entries:
[{id, displayName, conditionType?}]}]` — a READ-ONLY summary (weights/rarities/stat
modifiers deliberately stay sidecar-side; this is condition-picker fuel, not affix
authoring). `conditionType` is the entry's condition dispatch id preserved EXACTLY as the
pool carries it (`type_id` on gunModeller's `AffixCondition` union, including
`CustomAffixCondition`'s verbatim addon ids); an absent condition — or a blank passthrough
`type_id` — keeps the key off the wire ("always active").

TS side: `AffixPool`/`AffixPoolEntry` types + `affixPoolFromWire` + `SidecarProject.
affixPools` + required `PackProject.affixPools` (`[]`-defaulted at every load boundary;
persisted in project-file v2 as an additive optional key so the union survives save/open).
Pools are IMPORT-OWNED like `catalog`: `syncProjectToSidecar` neither pushes nor prunes
them (the sidecar's `_PRUNABLE` set is unchanged), so round-trips are write-free.

The E6 condition picker's vocabulary is now `synergyConditionOptions(pools)` =
`SYNERGY_CONDITION_TYPES` ∪ observed pool condition ids, deduped by canonical LOCAL
identity (ADR 0008: bare/`gunrng:`/`armsng:` fold; e.g. `armsng:while_ads` collapses into
the catalog's `whileAds`), foreign ids offered EXACTLY as observed (first spelling wins on
repeat observations). Picking a foreign id produces a new `SynergyCondition` variant
`{type: "<namespace>:<path>"}` (template-literal-typed so switch narrowing is intact) with
no sub-fields — its payload schema is unknown here, the wire already round-trips it
verbatim, and the runtime tolerates unknown namespaced ids with a load warning (Arms NG
ADR 0004 §3). `validateLibrary` accordingly accepts well-formed namespaced condition ids
and still hard-flags bare unknown spellings (typo guard).

### D1.4 — part tags vocabulary

`GunPartDoc.tags` is FIRST-CLASS in gunModeller (verified: `gun_part_doc.py` declares
`tags: list[str]`, default `[]` — it does NOT ride the extras bag), so the sidecar half is
plain plumbing: `_part_to_library_json` emits `tags` when non-empty (identity omission —
the mod codec treats absent and empty identically) and `save_part` upserts it
presence-guarded (sent list replaces; omitted preserves; explicit null/`[]` clears).
Export needs no extras merge — gunModeller's own part serializer already writes `tags`.

TS side: `GunPart.tags?: string[]` (+ form/apply/wire plumbing: `PartFormFields`,
`PartApplyForm` with honest clear-on-unauthored passthrough, `partToWire`'s `tags: ??
null` clear-vs-omit contract, project-file v2 identity omission). PartPanel's Assignment
group gains a tags list editor; suggestions come from `observedPartTags(project.gunParts)`
— deduped by EXACT case-sensitive string (Arms NG ADR 0004 §4: tags are NOT identifiers;
no `bareId` folding) — and the same observed union feeds the `withPartTag` sub-field's
datalist (free entry stays legal; the vocabulary is advisory, matching the runtime's
soft-warn rule). `validateLibrary`: tags must be non-empty strings, deduped per part
(exact duplicates flagged; `heavy` vs `Heavy` vs `gunrng:heavy` are three distinct tags).

**Runtime-parity obligation:** none new — both halves consume rules Arms NG already
pinned (ADR 0004 §3/§4, ADR 0007 §R1.12). `gunrng:with_part_tag` authoring is now
reachable end-to-end author-side, which was the stated gate for its production activation.

Evidence: repo-root pytest 709→716 passed / 4 skipped (sidecar suite + the 20
fixture-contract tests; 2 skips are Windows symlink-privilege skips); vitest 588→615
passed; typecheck/lint/build green.

**Ship grill (2026-07-18): HOLD → folded → re-verified.** HIGH-1: the sidecar→TS
serializer (`_synergy_modifiers_to_camel`) stripped the local namespace BEFORE the
catalog-map lookup and passed misses through bare — `gunrng:always` (real pool
vocabulary; the picker offers it verbatim) served as `always`, `armsng:custom_thing`
as `custom_thing` — so the D1.3 validator hard-flagged the tool's own output after one
authoring round-trip and blocked export, and a re-save mis-prefixed `armsng:*` back as
`gunrng:*`. Fold: camel-map ONLY when the stripped id is catalog vocabulary, else emit
the STORED spelling verbatim (the exact mirror of `_synergy_modifiers_to_snake`'s `:`
guard); pinned by `test_synergy_condition_non_catalog_namespaced_ids_round_trip_verbatim`
(serve-side verbatim + stash + re-save fixpoint for all three id classes). This makes
the "wire already round-trips it verbatim" claim above TRUE rather than aspirational.
Accepted as-is: LOW-1 degenerate `gunrng:`-empty pool condition ids surface as
malformed picker options but are caught by the malformed-namespace validation before
export (pathological input, degrades safely); LOW-2 the sidecar's `save_part` accepts
blank/duplicate tags that only TS `validateLibrary` refuses (reachable only by
non-DesignRNG RPC clients; the runtime treats tags as opaque strings); INFO-3
entry-level `displayName` has no id fallback on the read-only pool summary (cosmetic).
52 changes: 50 additions & 2 deletions sidecar/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def preflight_raw_part_type_sort_orders(project_root: Path) -> None:
AmmoTypeDef,
EffectContributionDef,
)
from gun_modeller.model.affix_pool_def import AffixPoolDef # noqa: E402
from gun_modeller.model.archetype import BaseStats # noqa: E402
from gun_modeller.model.assemble_preview import assemble_preview_model # noqa: E402
from gun_modeller.model.content_pack import ContentPackProject # noqa: E402
Expand Down Expand Up @@ -412,10 +413,40 @@ def project_to_design_json(project: ContentPackProject) -> dict[str, Any]:
+ [_effect_definition_to_full_json(eid, e) for eid, e in project.flight_effects.items()]
),
"fireModes": [_fire_mode_to_full_json(fid, f) for fid, f in project.fire_modes.items()],
# D1.3: read-only affix-pool exposure (import-owned, like `catalog` —
# never authored, synced, or pruned by the editor). The TS synergy
# condition picker unions the condition ids observed here with its
# built-in catalog (the F7 promise); nothing else consumes this.
"affixPools": [
_affix_pool_to_json(pool_id, pool) for pool_id, pool in project.affix_pools.items()
],
"catalog": catalog,
}


def _affix_pool_to_json(pool_id: str, pool: AffixPoolDef) -> dict[str, Any]:
"""D1.3 read-only pool summary: id / displayName / entries' identity +
condition dispatch ids. Deliberately NOT the full affix surface (weights,
rarities, stat modifiers stay sidecar-side) — this exposure exists for the
condition-picker union, not affix authoring. Condition ids are preserved
EXACTLY as the pool carries them (`type_id` on gunModeller's tagged union,
including `CustomAffixCondition`'s verbatim addon ids); an absent condition
— or a blank passthrough type_id, which has no usable dispatch id — keeps
`conditionType` off the wire so it reads as "always active"."""
entries = []
for entry in pool.entries:
entry_json: dict[str, Any] = {"id": entry.id, "displayName": entry.display_name}
condition_type = getattr(entry.condition, "type_id", "") if entry.condition else ""
if condition_type:
entry_json["conditionType"] = str(condition_type)
entries.append(entry_json)
return {
"id": pool_id,
"displayName": pool.display_name or strip_local_namespace(pool_id),
"entries": entries,
}


def _vec3_json(vec: tuple[float, float, float]) -> list[float]:
return [float(vec[0]), float(vec[1]), float(vec[2])]

Expand Down Expand Up @@ -489,6 +520,11 @@ def _part_to_library_json(part_id: str, part: GunPartDoc) -> dict[str, Any]:
"weight": float(part.weight),
"model": model_to_dict(part.model),
}
# D1.4: `tags` is a FIRST-CLASS GunPartDoc field (list[str], default []),
# now editor-owned. Identity omission: the mod codec treats absent and
# empty identically, so the empty default stays off the wire.
if part.tags:
out["tags"] = [str(tag) for tag in part.tags]
if part.snap_location is not None:
out["snapLocation"] = {
"position": _vec3_json(part.snap_location.position),
Expand Down Expand Up @@ -795,8 +831,15 @@ def _synergy_modifiers_to_camel(snake_list: Any) -> Any:
out = []
for row in snake_list:
when = row.get("when") or {}
when_type = strip_local_namespace(when.get("type", ""))
camel_type = _SYNERGY_CONDITION_TYPE_SNAKE_TO_CAMEL.get(when_type, when_type)
# Camel-map ONLY catalog vocabulary (mirror of the ":"-guard in
# _synergy_modifiers_to_snake): a non-catalog id keeps its STORED
# spelling verbatim — namespace included. Stripping first and
# passing the miss through served gunrng:always as bare "always",
# which the D1.3 validator then hard-flagged after one authoring
# round-trip, and re-saving mis-prefixed armsng:* as gunrng:*.
stored_type = when.get("type", "")
stripped_type = strip_local_namespace(stored_type)
camel_type = _SYNERGY_CONDITION_TYPE_SNAKE_TO_CAMEL.get(stripped_type, stored_type)
when_camel: dict[str, Any] = {"type": camel_type}
for key, value in when.items():
if key == "type":
Expand Down Expand Up @@ -1457,6 +1500,11 @@ def save_part(project: ContentPackProject, part_json: dict[str, Any]) -> dict[st
)
if "weight" in part_json:
part.weight = float(part_json["weight"])
if "tags" in part_json:
# D1.4: first-class dataclass field, presence-guarded like every other
# key. Explicit null and the empty list both mean "no tags" (the codec
# default) — the TS save client sends `tags: null` for unauthored.
part.tags = [str(tag) for tag in (part_json["tags"] or [])]
if "snapLocation" in part_json:
sl = part_json["snapLocation"]
part.snap_location = (
Expand Down
Loading
Loading