diff --git a/PLAN.md b/PLAN.md index 34cab0b..ce6f13f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/docs/GUNRNG_CHANGES.md b/docs/GUNRNG_CHANGES.md index cbab474..aa410ff 100644 --- a/docs/GUNRNG_CHANGES.md +++ b/docs/GUNRNG_CHANGES.md @@ -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) | @@ -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). @@ -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`). + +### 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: ":"}` (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). diff --git a/sidecar/bridge.py b/sidecar/bridge.py index 9554f01..b09764f 100644 --- a/sidecar/bridge.py +++ b/sidecar/bridge.py @@ -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 @@ -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])] @@ -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), @@ -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": @@ -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 = ( diff --git a/sidecar/test_bridge.py b/sidecar/test_bridge.py index 9ddfa1e..5db1409 100644 --- a/sidecar/test_bridge.py +++ b/sidecar/test_bridge.py @@ -17,6 +17,12 @@ from gun_modeller.fileio.content_pack_io import load_project, save_project from gun_modeller.fileio.export import export_to_folder from gun_modeller.fileio.model_json import model_to_dict +from gun_modeller.model.affix_condition import ( + CustomAffixCondition, + LowAmmoCondition, + WhileAdsCondition, +) +from gun_modeller.model.affix_pool_def import AffixEntry, AffixPoolDef from gun_modeller.model.ammo_type_def import AmmoTypeDef, EffectContributionDef from gun_modeller.model.archetype import BaseStats from gun_modeller.model.assemble_preview import assemble_preview_model @@ -1619,6 +1625,48 @@ def test_save_part_stashes_new_schema_fields_as_extras(): assert part.weight == 1.0 # untouched (omitted key) +def test_synergy_condition_non_catalog_namespaced_ids_round_trip_verbatim(): + """D1.3 ship-grill HIGH-1: local-namespaced condition ids OUTSIDE the + camel catalog map (real pool vocabulary like ``gunrng:always``, addon + ids like ``armsng:custom_thing``) must be served to the renderer + VERBATIM. The pre-fix path stripped the namespace on the way out, and + the D1.3 validator then hard-flagged the tool's own output after one + authoring round-trip (ADR 0008 rule 1 / Arms ADR 0004 preserved- + spelling law); re-saving the bare leak also mis-prefixed ``armsng:*`` + back as ``gunrng:*``.""" + p = _pack_with_geometry() + out = bridge.save_part(p, { + "id": "voltspoke_barrel", + "synergyModifiers": [ + {"when": {"type": "gunrng:always"}, "modifiers": {"damageMult": 1.05}}, + {"when": {"type": "armsng:custom_thing", "stacks": 3}, "modifiers": {"recoilAdd": -0.1}}, + {"when": {"type": "withPartTag", "tag": "suppressed"}, "modifiers": {"damageMult": 1.2}}, + ], + }) + extras = bridge._extras_of(p.gun_parts["voltspoke_barrel"]) + assert [row["when"] for row in extras["synergy_modifiers"]] == [ + {"type": "gunrng:always"}, + {"type": "armsng:custom_thing", "stacks": 3}, + {"type": "gunrng:with_part_tag", "tag": "suppressed"}, + ] + # Serve side: catalog ids camelize; everything else keeps its stored + # spelling — namespace included. + assert [row["when"] for row in out["synergyModifiers"]] == [ + {"type": "gunrng:always"}, + {"type": "armsng:custom_thing", "stacks": 3}, + {"type": "withPartTag", "tag": "suppressed"}, + ] + # Fixpoint: re-saving exactly what was served must not move a single + # spelling in the stash or on the next serve. + out2 = bridge.save_part(p, { + "id": "voltspoke_barrel", + "synergyModifiers": out["synergyModifiers"], + }) + assert bridge._extras_of(p.gun_parts["voltspoke_barrel"])["synergy_modifiers"] \ + == extras["synergy_modifiers"] + assert out2["synergyModifiers"] == out["synergyModifiers"] + + def test_save_ammo_extras_field_cleared_by_explicit_null(): p = _minimal_pack() bridge.save_ammo(p, {"id": "bullet", "armorIgnore": 0.5}) @@ -1966,3 +2014,136 @@ def test_unified_effect_alias_collision_refuses_before_mutation(operation): assert p.impact_effects == before_impact assert p.flight_effects == before_flight + + +# --------------------------------------------------------------------------- +# D1.3 (affix-pool condition exposure) + D1.4 (part tags vocabulary) +# --------------------------------------------------------------------------- + + +def _pack_with_affix_pools() -> ContentPackProject: + p = _minimal_pack() + p.affix_pools["standard_affixes"] = AffixPoolDef( + id="standard_affixes", + display_name="Standard Affixes", + entries=[ + AffixEntry(id="steady", display_name="Steady", condition=WhileAdsCondition()), + AffixEntry( + id="clutch", display_name="Clutch", condition=LowAmmoCondition(threshold=0.2) + ), + AffixEntry(id="plain", display_name="Plain"), # no condition = always active + ], + ) + p.affix_pools["addon_affixes"] = AffixPoolDef( + id="addon_affixes", + display_name="", + entries=[ + AffixEntry( + id="executioner", + display_name="Executioner", + condition=CustomAffixCondition(type_id="othermod:on_kill", extra={"stacks": 3}), + ), + ], + ) + return p + + +def test_design_json_exposes_affix_pools_with_condition_ids(): + """D1.3: read-only pool exposure (id / displayName / entry condition ids) + so the TS condition picker can build the F7 union. Condition ids travel + EXACTLY as the pool carries them - a foreign/addon id is never rewritten.""" + data = bridge.project_to_design_json(_pack_with_affix_pools()) + pools = {pool["id"]: pool for pool in data["affixPools"]} + assert set(pools) == {"standard_affixes", "addon_affixes"} + standard = pools["standard_affixes"] + assert standard["displayName"] == "Standard Affixes" + assert standard["entries"] == [ + {"id": "steady", "displayName": "Steady", "conditionType": "gunrng:while_ads"}, + {"id": "clutch", "displayName": "Clutch", "conditionType": "gunrng:low_ammo"}, + {"id": "plain", "displayName": "Plain"}, # no condition -> key omitted + ] + addon = pools["addon_affixes"] + assert addon["displayName"] == "addon_affixes" # blank display name -> id fallback + assert addon["entries"] == [ + {"id": "executioner", "displayName": "Executioner", "conditionType": "othermod:on_kill"}, + ] + + +def test_design_json_affix_pools_empty_without_pools(): + assert bridge.project_to_design_json(_minimal_pack())["affixPools"] == [] + + +def test_design_json_affix_pool_blank_custom_condition_reads_as_unconditioned(): + """A CustomAffixCondition with a blank type_id has no usable dispatch id - + expose the entry as unconditioned rather than emitting an empty string the + TS union would surface as a picker option.""" + p = _minimal_pack() + p.affix_pools["p"] = AffixPoolDef(id="p", entries=[ + AffixEntry( + id="x", display_name="X", + condition=CustomAffixCondition(type_id="", extra={"k": 1}), + ), + ]) + data = bridge.project_to_design_json(p) + (pool,) = data["affixPools"] + assert pool["entries"] == [{"id": "x", "displayName": "X"}] + + +def test_design_json_part_tags_identity_omission(): + """D1.4: `tags` is a FIRST-CLASS GunPartDoc field (never an extras-bag + key); a non-empty list reaches the design JSON, the empty identity stays + off the wire (the mod codec treats absent and empty identically).""" + p = _minimal_pack() + p.gun_parts["voltspoke_barrel"].tags = ["heavy", "silenced"] + data = bridge.project_to_design_json(p) + part = next(part for part in data["gunParts"] if part["id"] == "voltspoke_barrel") + assert part["tags"] == ["heavy", "silenced"] + + p.gun_parts["voltspoke_barrel"].tags = [] + data = bridge.project_to_design_json(p) + part = next(part for part in data["gunParts"] if part["id"] == "voltspoke_barrel") + assert "tags" not in part + + +def test_save_part_upserts_tags_presence_guarded(): + """D1.4 write path: `tags` follows the RC3 presence-guard discipline - + a sent list replaces, an omitted key preserves, an explicit null (or the + empty list) clears back to the codec's empty default.""" + p = _pack_with_geometry() + part = p.gun_parts["voltspoke_barrel"] + + out = bridge.save_part(p, {"id": "voltspoke_barrel", "tags": ["heavy", "silenced"]}) + assert part.tags == ["heavy", "silenced"] + assert out["tags"] == ["heavy", "silenced"] + + bridge.save_part(p, {"id": "voltspoke_barrel", "weight": 2.0}) + assert part.tags == ["heavy", "silenced"] # omitted key preserves + + bridge.save_part(p, {"id": "voltspoke_barrel", "tags": None}) + assert part.tags == [] # explicit null clears + + part.tags = ["stale"] + out = bridge.save_part(p, {"id": "voltspoke_barrel", "tags": []}) + assert part.tags == [] # empty list clears too + assert "tags" not in out # empty identity stays off the wire + + +def test_part_tags_survive_export_roundtrip(tmp_path): + """Tags ride gunModeller's own first-class part serialization end to end: + save_part -> export -> written JSON carries `tags` -> reload serves them + back through project_to_design_json.""" + p = _minimal_pack() + bridge.save_part(p, {"id": "voltspoke_barrel", "tags": ["heavy"]}) + + dest = tmp_path / "resources" + result = export_to_folder(p, dest, force_overwrite=True) + assert result.success, result.errors + written = json.loads( + (dest / "gun_parts" / "barrel" / "voltspoke_barrel.json").read_text("utf-8") + ) + assert written["tags"] == ["heavy"] + + reloaded = load_project(dest) + data = bridge.project_to_design_json(reloaded) + part = next(part for part in data["gunParts"] if part["id"] == "voltspoke_barrel") + assert part["tags"] == ["heavy"] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ab20ae..78f56b6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,6 +13,8 @@ import { StyleSettings } from './ui/shared/StyleSettings' import { summarizeExport, type ExportSummary } from './ui/export/exportResult' import { bareId } from './core/project/identifiers' import { applyBaseToProject, applyPartToProject } from './core/project/applyModellerSession' +import { observedPartTags } from './core/project/partTags' +import { synergyConditionOptions } from './ui/definitions/synergyConditionFields' import { applyDefinitionToProject, removeDefinitionFromProject, type DefinitionKind } from './core/project/applyDefinitions' import { validateLibrary } from './core/project/libraryValidation' import { AmmoEditor } from './ui/definitions/AmmoEditor' @@ -187,6 +189,9 @@ export default function App() { partGroup: part.partGroup, weight: part.weight, baseStats: { ...part.baseStats }, + // D1.4: seed tags like the E6 fields below — unauthored stays + // undefined so Apply clears honestly. + tags: part.tags ? [...part.tags] : undefined, // EXPANSION (E6): seed the advanced fields from the entity so opening // the panel doesn't silently drop a previously-authored value (an // unauthored form field CLEARS the entity's field on Apply — see @@ -469,6 +474,8 @@ export default function App() { partSlotOptions={project.partTypes.map((t) => t.id)} partStyleOptions={project.styles} partGroupOptions={(style) => project.partGroups.filter((g) => g.style === style).map((g) => g.id)} + partConditionOptions={synergyConditionOptions(project.affixPools)} + partTagOptions={observedPartTags(project.gunParts)} /> )} diff --git a/src/renderer/src/core/bridge/entityWire.test.ts b/src/renderer/src/core/bridge/entityWire.test.ts index 12dac71..23302af 100644 --- a/src/renderer/src/core/bridge/entityWire.test.ts +++ b/src/renderer/src/core/bridge/entityWire.test.ts @@ -88,7 +88,9 @@ describe('entityWire (RC13 export rework)', () => { weight: 1, snapLocation: { position: [8, 8, 0], rotation: [0, 0, 0] }, model: { elements: [{ from: [7, 7, 0], to: [9, 9, 10], faces: {} }] }, - // Explicit null-when-absent for clearable extras (E2/E3 grill F1). + // Explicit null-when-absent for clearable fields (E2/E3 grill F1; D1.4 + // tags follow the same clear-vs-omit contract). + tags: null, weightKg: null, adsTimeAdd: null, silence: null, @@ -108,6 +110,14 @@ describe('entityWire (RC13 export rework)', () => { // --- EXPANSION (E3): part new-schema fields (R1.10-R1.12) ------------------- + it('partToWire carries authored tags (D1.4) and sends null when unauthored', () => { + const tagged: GunPart = { ...part(), tags: ['heavy', 'silenced'] } + expect(partToWire(tagged).tags).toEqual(['heavy', 'silenced']) + const untagged = partToWire(part()) + expect('tags' in untagged).toBe(true) + expect(untagged.tags).toBeNull() + }) + it('partToWire carries authored new-schema fields', () => { const withExtras: GunPart = { ...part(), @@ -266,7 +276,7 @@ describe('entityWire — explicit null clears (E2/E3 grill F1)', () => { it('partToWire sends null for absent part extras', () => { const wire = partToWire(makeGunPart([], 'barrel', 'voltspoke', 'core')) - for (const key of ['weightKg', 'adsTimeAdd', 'silence', 'recoilPitchMult', 'recoilYawMult', 'synergyModifiers'] as const) { + for (const key of ['tags', 'weightKg', 'adsTimeAdd', 'silence', 'recoilPitchMult', 'recoilYawMult', 'synergyModifiers'] as const) { expect(wire).toHaveProperty(key) expect(wire[key]).toBeNull() } diff --git a/src/renderer/src/core/bridge/entityWire.ts b/src/renderer/src/core/bridge/entityWire.ts index 75c5b84..654cd0b 100644 --- a/src/renderer/src/core/bridge/entityWire.ts +++ b/src/renderer/src/core/bridge/entityWire.ts @@ -46,6 +46,12 @@ export function partToWire(part: GunPart): SaveGunPartPayload { snapLocation: part.snapLocation ?? null, model: modelToWire(part.model), + // D1.4: same clear-vs-omit contract as the fields below — the sidecar's + // `save_part` is presence-guarded, so unauthored tags must ship an + // explicit null (clearing back to the codec's empty default), never an + // omitted key that would preserve stale sidecar state. + tags: part.tags ?? null, + // --- EXPANSION (E3): new-schema part fields (R1.10-R1.12) ------------- // Explicit null-when-absent (E2/E3 grill F1): the sidecar's `save_part` // upsert is presence-guarded (RC3), so an OMITTED key would PRESERVE a diff --git a/src/renderer/src/core/bridge/syncProject.test.ts b/src/renderer/src/core/bridge/syncProject.test.ts index f8327a8..82bf372 100644 --- a/src/renderer/src/core/bridge/syncProject.test.ts +++ b/src/renderer/src/core/bridge/syncProject.test.ts @@ -123,6 +123,10 @@ const minimalProject = (): PackProject => ({ burstCount: 1, shotsPerTrigger: 1, cooldownTicks: 0, ammoPerShot: 1, parameters: {}, }, ], + // Import-owned, read-only (D1.3): the sync must neither push nor prune these. + affixPools: [ + { id: 'standard_affixes', displayName: 'Standard Affixes', entries: [] }, + ], }) describe('syncProjectToSidecar push order', () => { diff --git a/src/renderer/src/core/project/applyModellerSession.test.ts b/src/renderer/src/core/project/applyModellerSession.test.ts index ab2e6c5..f84187b 100644 --- a/src/renderer/src/core/project/applyModellerSession.test.ts +++ b/src/renderer/src/core/project/applyModellerSession.test.ts @@ -165,6 +165,21 @@ describe('applyPartSession', () => { expect(result![0].weightKg).toBeUndefined() expect(result![0].silence).toBeUndefined() }) + + // --- D1.4: tags passthrough -------------------------------------------------- + + it('writes authored tags onto the part and clears them when unauthored (honest passthrough)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'voltspoke_core'), + id: 'p1', + tags: ['stale'], // previously authored + } + const tagged = applyPartSession([part], 'p1', editedModel(), null, form({ tags: ['heavy', 'silenced'] })) + expect(tagged![0].tags).toEqual(['heavy', 'silenced']) + + const cleared = applyPartSession([part], 'p1', editedModel(), null, form()) + expect(cleared![0].tags).toBeUndefined() + }) }) describe('applyBaseToProject / applyPartToProject (PackProject-level wrappers)', () => { @@ -180,6 +195,7 @@ describe('applyBaseToProject / applyPartToProject (PackProject-level wrappers)', elements: [], effects: [], fireModes: [], + affixPools: [], }) it('applyBaseToProject returns ok:true with the merged collection on a hit, ok:false on a vanished id', () => { diff --git a/src/renderer/src/core/project/applyModellerSession.ts b/src/renderer/src/core/project/applyModellerSession.ts index c3cdfc3..01165da 100644 --- a/src/renderer/src/core/project/applyModellerSession.ts +++ b/src/renderer/src/core/project/applyModellerSession.ts @@ -36,6 +36,9 @@ export interface PartApplyForm { partGroup: string weight: number baseStats: BaseStats + /** D1.4 tags — optional like the E6 groups below: `undefined` means "no + * tags authored" and Apply writes that through (clearing), never preserves. */ + tags?: string[] weightKg?: number adsTimeAdd?: number silence?: SilenceSettings @@ -106,10 +109,12 @@ export function applyPartSession( partGroup: form.partGroup, weight: num(form.weight), baseStats, - // EXPANSION (E6): honest optional-passthrough — an unauthored form - // field (undefined) must overwrite whatever the entity previously - // held (a removed group must actually clear, not just fail to - // update), so this is a plain assignment, never `?? p.`. + // EXPANSION (E6) + D1.4 tags: honest optional-passthrough — an + // unauthored form field (undefined) must overwrite whatever the + // entity previously held (a removed group must actually clear, not + // just fail to update), so this is a plain assignment, never + // `?? p.`. + tags: form.tags, weightKg: form.weightKg, adsTimeAdd: form.adsTimeAdd, silence: form.silence, diff --git a/src/renderer/src/core/project/fromSidecar.test.ts b/src/renderer/src/core/project/fromSidecar.test.ts index fb53447..73f0cfa 100644 --- a/src/renderer/src/core/project/fromSidecar.test.ts +++ b/src/renderer/src/core/project/fromSidecar.test.ts @@ -56,6 +56,7 @@ describe('fromSidecarProject', () => { baseStats: { damage: 2, fireRateRpm: 0, spread: 0, range: 12, recoil: 0, magazineSize: 0, reloadTime: 0 }, modelRef: 'gunrng:item/coil_barrel', weight: 1, + tags: ['heavy', 'silenced'], weightKg: 64.88005, adsTimeAdd: -0.075, snapLocation: { position: [8, 8, 0], rotation: [0, 0, 0] }, @@ -75,6 +76,7 @@ describe('fromSidecarProject', () => { const part = project.gunParts[0] expect(part.slot).toBe('barrel') expect(part.baseStats.damage).toBe(2) + expect(part.tags).toEqual(['heavy', 'silenced']) expect(part.weightKg).toBe(64.88005) expect(part.adsTimeAdd).toBe(-0.075) expect(part.snapLocation).toEqual({ position: [8, 8, 0], rotation: [0, 0, 0] }) @@ -102,6 +104,65 @@ describe('fromSidecarProject', () => { expect(project.elements).toEqual([]) expect(project.effects).toEqual([]) expect(project.fireModes).toEqual([]) + expect(project.affixPools).toEqual([]) // pre-D1.3 payloads too + }) + + // --- D1.3: read-only imported affix pools ---------------------------------- + + it('maps affix pools with condition ids preserved exactly (foreign included)', () => { + const project = fromSidecarProject({ + ...sample, + affixPools: [ + { + id: 'standard_affixes', + displayName: 'Standard Affixes', + entries: [ + { id: 'steady', displayName: 'Steady', conditionType: 'gunrng:while_ads' }, + { id: 'executioner', displayName: 'Executioner', conditionType: 'othermod:on_kill' }, + { id: 'plain', displayName: 'Plain' }, // unconditioned — key stays absent + ], + }, + ], + }) + expect(project.affixPools).toEqual([ + { + id: 'standard_affixes', + displayName: 'Standard Affixes', + entries: [ + { id: 'steady', displayName: 'Steady', conditionType: 'gunrng:while_ads' }, + { id: 'executioner', displayName: 'Executioner', conditionType: 'othermod:on_kill' }, + { id: 'plain', displayName: 'Plain' }, + ], + }, + ]) + expect('conditionType' in project.affixPools[0].entries[2]).toBe(false) + }) + + it('does not alias affix pools (defensive copies like every other collection)', () => { + const wire: SidecarProject = { + ...sample, + affixPools: [ + { id: 'p', displayName: 'P', entries: [{ id: 'e', displayName: 'E', conditionType: 'gunrng:low_ammo' }] }, + ], + } + const project = fromSidecarProject(wire) + project.affixPools[0].entries[0].id = 'mutated' + expect(wire.affixPools![0].entries[0].id).toBe('e') + }) + + it('omits part tags when the wire omits or empties them (identity omission)', () => { + const partWire = { + id: 'p', slot: 'barrel', style: 'voltspoke', partGroup: 'vs_core', + baseStats: { damage: 0, fireRateRpm: 0, spread: 0, range: 0, recoil: 0, magazineSize: 0, reloadTime: 0 }, + modelRef: '', weight: 1, + model: { elements: [{ from: [0, 0, 0] as [number, number, number], to: [1, 1, 1] as [number, number, number], faces: {} }] }, + } + const project = fromSidecarProject({ + ...sample, + gunParts: [partWire, { ...partWire, id: 'q', tags: [] }], + }) + expect('tags' in project.gunParts[0]).toBe(false) + expect('tags' in project.gunParts[1]).toBe(false) }) // --- EXPANSION (E2/E3): full authored-definition collections --------------- diff --git a/src/renderer/src/core/project/fromSidecar.ts b/src/renderer/src/core/project/fromSidecar.ts index 09a8848..0646aa5 100644 --- a/src/renderer/src/core/project/fromSidecar.ts +++ b/src/renderer/src/core/project/fromSidecar.ts @@ -1,5 +1,6 @@ import { modelFromWire, type Vec3, type WireModel } from './cuboid' import type { + AffixPool, AmmoType, BaseStats, EffectContribution, @@ -95,6 +96,9 @@ export interface SidecarGunPart { weight: number snapLocation?: SnapLocation model: WireModel + /** D1.4: first-class `GunPartDoc.tags` (never an extras-bag key). Identity + * omission on the wire: absent = the codec's empty default. */ + tags?: string[] // --- EXPANSION (E2/E3, R1.10-R1.12): new-schema part fields ----------- // Wire-identical to the `GunPart` TS shape (the sidecar's extras bag @@ -127,6 +131,10 @@ export type SidecarEffectDef = EffectDef /** Full fire mode on the design-JSON wire — field-for-field `FireMode`. */ export type SidecarFireMode = FireMode +/** Read-only affix-pool summary on the design-JSON wire (D1.3) — + * field-for-field `AffixPool`. */ +export type SidecarAffixPool = AffixPool + export interface SidecarProject { manifest: { packId: string @@ -148,6 +156,9 @@ export interface SidecarProject { elements?: SidecarElementDef[] effects?: SidecarEffectDef[] fireModes?: SidecarFireMode[] + /** Read-only imported affix pools (D1.3): absent from pre-D1.3 sidecar + * payloads — `fromSidecarProject` defaults them to `[]` (F9). */ + affixPools?: SidecarAffixPool[] catalog?: PackCatalog } @@ -203,6 +214,10 @@ export function partFromWire(p: SidecarGunPart): GunPart { : {}), model: modelFromWire(p.model), + // D1.4: identity omission both ways — an absent/empty `tags` stays + // `undefined` on the TS type (never a materialized `[]`). + ...(p.tags != null && p.tags.length > 0 ? { tags: [...p.tags] } : {}), + // --- EXPANSION (E3, R1.10-R1.12): new-schema part fields -------------- // Identity-omission both ways: absent on the wire means the sidecar has // no extras stashed for this field, so the TS type stays `undefined` @@ -301,6 +316,21 @@ export function fireModeFromWire(f: SidecarFireMode): FireMode { } } +/** D1.3: defensive per-entry copy of a read-only imported affix pool. + * `conditionType` is preserved EXACTLY as observed (foreign ids included) — + * canonicalization happens only at the picker-union dedupe, never here. */ +export function affixPoolFromWire(pool: SidecarAffixPool): AffixPool { + return { + id: pool.id, + displayName: pool.displayName, + entries: pool.entries.map((entry) => ({ + id: entry.id, + displayName: entry.displayName, + ...(entry.conditionType !== undefined ? { conditionType: entry.conditionType } : {}), + })), + } +} + export function fromSidecarProject(data: SidecarProject): PackProject { return { manifest: data.manifest, @@ -319,6 +349,9 @@ export function fromSidecarProject(data: SidecarProject): PackProject { elements: (data.elements ?? []).map(elementFromWire), effects: (data.effects ?? []).map(effectFromWire), fireModes: (data.fireModes ?? []).map(fireModeFromWire), + // Read-only imported affix pools (D1.3, F9): pre-D1.3 sidecar payloads + // simply map to an empty list — consumers stay fallback-free. + affixPools: (data.affixPools ?? []).map(affixPoolFromWire), catalog: data.catalog, } } diff --git a/src/renderer/src/core/project/libraryValidation.test.ts b/src/renderer/src/core/project/libraryValidation.test.ts index 5d4597c..66b7a8a 100644 --- a/src/renderer/src/core/project/libraryValidation.test.ts +++ b/src/renderer/src/core/project/libraryValidation.test.ts @@ -698,3 +698,75 @@ describe('validateLibrary (final grill packet B) — reference + numeric totalit expect(validateLibrary(lib({ ammoTypes }))).toEqual([]) }) }) + +describe('validateLibrary (D1.3) - foreign synergy condition ids', () => { + it('accepts a namespaced foreign/addon condition id (runtime tolerates unknown ids, ADR 0004)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + synergyModifiers: [ + { when: { type: 'othermod:on_kill' }, modifiers: zeroModifiers() }, + { when: { type: 'gunrng:always' }, modifiers: zeroModifiers() }, + ], + } + expect(validateLibrary(lib({ gunParts: [part] }))).toEqual([]) + }) + + it('still flags a bare unknown condition spelling (typo guard)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + synergyModifiers: [ + { when: { type: 'made_up_condition' } as unknown as SynergyCondition, modifiers: zeroModifiers() }, + ], + } + expect(validateLibrary(lib({ gunParts: [part] })).map((i) => i.path)).toEqual([ + `gunParts[${part.id}].synergyModifiers[0].when.type`, + ]) + }) + + it('flags a malformed namespaced id (blank namespace or path)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + synergyModifiers: [ + { when: { type: ':on_kill' } as unknown as SynergyCondition, modifiers: zeroModifiers() }, + { when: { type: 'othermod:' } as unknown as SynergyCondition, modifiers: zeroModifiers() }, + ], + } + expect(validateLibrary(lib({ gunParts: [part] })).map((i) => i.path)).toEqual([ + `gunParts[${part.id}].synergyModifiers[0].when.type`, + `gunParts[${part.id}].synergyModifiers[1].when.type`, + ]) + }) +}) + +describe('validateLibrary (D1.4) - part tags', () => { + it('accepts non-empty deduped tags (case-sensitive: differing case is NOT a duplicate)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + tags: ['heavy', 'Heavy', 'silenced'], + } + expect(validateLibrary(lib({ gunParts: [part] }))).toEqual([]) + }) + + it('flags blank/whitespace-only tags', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + tags: ['', ' ', 'heavy'], + } + expect(validateLibrary(lib({ gunParts: [part] })).map((i) => i.path)).toEqual([ + `gunParts[${part.id}].tags[0]`, + `gunParts[${part.id}].tags[1]`, + ]) + }) + + it('flags exact duplicate tags (bare string equality - no namespace folding)', () => { + const part: GunPart = { + ...makeGunPart([], 'barrel', 'voltspoke', 'core'), + tags: ['heavy', 'heavy', 'gunrng:heavy'], + } + // `gunrng:heavy` is a DIFFERENT tag from `heavy` (tags are not identifiers, + // ADR 0004 §4) - only the exact repeat is flagged. + expect(validateLibrary(lib({ gunParts: [part] })).map((i) => i.path)).toEqual([ + `gunParts[${part.id}].tags[1]`, + ]) + }) +}) diff --git a/src/renderer/src/core/project/libraryValidation.ts b/src/renderer/src/core/project/libraryValidation.ts index 4387126..38f9d7f 100644 --- a/src/renderer/src/core/project/libraryValidation.ts +++ b/src/renderer/src/core/project/libraryValidation.ts @@ -177,9 +177,40 @@ export function validateLibrary(lib: LibraryCollections): GeometryIssue[] { } } + // --- D1.4: part tags — non-empty strings, deduped by EXACT equality. + // Tags are NOT identifiers (ADR 0004 §4: bare string equality, + // case-sensitive, no namespace semantics), so unlike every id check in + // this file the duplicate guard deliberately skips `bareId`. + const seenTags = new Set() + part.tags?.forEach((tag, i) => { + if (tag.trim() === '') { + issues.push({ + path: `${at}.tags[${i}]`, + message: 'blank tag — tags must be non-empty strings', + }) + return + } + if (seenTags.has(tag)) { + issues.push({ + path: `${at}.tags[${i}]`, + message: `duplicate tag "${tag}" — tags must be unique per part (exact, case-sensitive match)`, + }) + } + seenTags.add(tag) + }) + part.synergyModifiers?.forEach((synergy, i) => { const conditionType: string = synergy.when.type - if (!(SYNERGY_CONDITION_TYPES as readonly string[]).includes(conditionType)) { + // Valid vocabularies (D1.3): the built-in catalog spellings, or a + // well-formed namespaced dispatch id (an imported/addon condition — + // the runtime tolerates unknown namespaced ids with a load warning, + // ADR 0004 §3, and the sidecar wire round-trips them verbatim). A + // BARE unknown spelling stays an issue: it is a typo by construction + // (the picker never produces one, and save would silently qualify it). + const separator = conditionType.indexOf(':') + const wellFormedNamespaced = + separator > 0 && separator < conditionType.length - 1 + if (!(SYNERGY_CONDITION_TYPES as readonly string[]).includes(conditionType) && !wellFormedNamespaced) { issues.push({ path: `${at}.synergyModifiers[${i}].when.type`, message: `unknown synergy condition type "${conditionType}"`, @@ -204,9 +235,12 @@ export function validateLibrary(lib: LibraryCollections): GeometryIssue[] { } // withStyle names a style the project must know (final grill packet B // F4) — a dangling style means the synergy can never activate. - // (withPartTag's tag payload has NO DesignRNG-side vocabulary — part - // tags are runtime metadata not modelled here — so it stays free-form, - // same documented status as the F7 imported-conditions gap.) + // (withPartTag's tag payload stays FREE-FORM here even though D1.4 now + // gives the editor an observed-tag vocabulary: the runtime rule is + // advisory — a tag may come from another pack loaded later or an addon + // (ADR 0004 §4) — and validateLibrary issues BLOCK export, so an + // unobserved tag must not become a hard error. The picker offers the + // observed union; validation deliberately does not enforce it.) if (synergy.when.type === 'withStyle' && !hasStyle(synergy.when.style)) { issues.push({ path: `${at}.synergyModifiers[${i}].when.style`, diff --git a/src/renderer/src/core/project/partTags.test.ts b/src/renderer/src/core/project/partTags.test.ts new file mode 100644 index 0000000..c575f5f --- /dev/null +++ b/src/renderer/src/core/project/partTags.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { observedPartTags } from './partTags' + +// --- Observed part-tag vocabulary (D1.4) -------------------------------------- +// The union of tags across the project's parts feeds the `withPartTag` +// condition sub-field's picker (plus free entry) and the tags editor's +// suggestions. Arms NG ADR 0004 §4 pins the matching rule: BARE STRING +// EQUALITY, case-sensitive, no namespace semantics — so the vocabulary +// dedupes by EXACT string, never by local-namespace identity. + +describe('observedPartTags', () => { + const part = (tags?: string[]): { tags?: string[] } => (tags ? { tags } : {}) + + it('unions tags across parts, deduped by exact string, sorted for a stable picker', () => { + const tags = observedPartTags([ + part(['heavy', 'silenced']), + part(['compact', 'heavy']), + part(), + ]) + expect(tags).toEqual(['compact', 'heavy', 'silenced']) + }) + + it('is case-sensitive (ADR 0004 §4: bare string equality, no identity folding)', () => { + expect(observedPartTags([part(['Heavy']), part(['heavy'])])).toEqual(['Heavy', 'heavy']) + }) + + it('does NOT fold local-namespace spellings (tags are not identifiers)', () => { + expect(observedPartTags([part(['gunrng:heavy']), part(['heavy'])])).toEqual([ + 'gunrng:heavy', + 'heavy', + ]) + }) + + it('skips blank/whitespace-only tags (they are validation issues, not vocabulary)', () => { + expect(observedPartTags([part(['', ' ', 'heavy'])])).toEqual(['heavy']) + }) + + it('returns [] for a project with no tagged parts', () => { + expect(observedPartTags([part(), part([])])).toEqual([]) + }) +}) diff --git a/src/renderer/src/core/project/partTags.ts b/src/renderer/src/core/project/partTags.ts new file mode 100644 index 0000000..d955914 --- /dev/null +++ b/src/renderer/src/core/project/partTags.ts @@ -0,0 +1,22 @@ +// --- Part-tag vocabulary (D1.4) ------------------------------------------------ +// Tags live on `GunPart.tags` (the existing runtime wire field — a +// base-compatibility filter and the payload domain of `gunrng:with_part_tag` +// synergy conditions). Arms NG ADR 0004 §4 pins the matching rule: BARE +// STRING EQUALITY, case-sensitive, NO namespace semantics — so unlike every +// identifier surface in this codebase, tags never go through `bareId`/ +// `identifierIdentity`. The observed vocabulary is advisory (free entry stays +// legal — a tag may come from another pack loaded later or an addon), which +// is why the pickers offer it as suggestions rather than a closed list. + +/** The union of tags observed across the given parts: deduped by EXACT + * string, blanks skipped (those are `validateLibrary` issues, not + * vocabulary), sorted for a stable picker. */ +export function observedPartTags(parts: readonly { tags?: string[] }[]): string[] { + const seen = new Set() + for (const part of parts) { + for (const tag of part.tags ?? []) { + if (tag.trim() !== '') seen.add(tag) + } + } + return [...seen].sort() +} diff --git a/src/renderer/src/core/project/projectFile.test.ts b/src/renderer/src/core/project/projectFile.test.ts index 74df3de..f59a2fd 100644 --- a/src/renderer/src/core/project/projectFile.test.ts +++ b/src/renderer/src/core/project/projectFile.test.ts @@ -81,6 +81,45 @@ describe('project file v2 (RC13 persistence)', () => { expect('recoilYawMult' in raw).toBe(false) }) + it('round-trips part tags; absent/empty stays absent (D1.4 identity omission)', () => { + const tagged: PackProject = { + ...sampleProject, + gunParts: [ + { ...sampleProject.gunParts[0], tags: ['heavy', 'silenced'] }, + { ...sampleProject.gunParts[1], tags: [] }, + ], + } + const restored = parseProjectFile(serializeProject(tagged)) + expect(restored.gunParts[0].tags).toEqual(['heavy', 'silenced']) + expect(restored.gunParts[1].tags).toBeUndefined() + const raw = JSON.parse(serializeProject(tagged)).project.gunParts + expect(raw[0].tags).toEqual(['heavy', 'silenced']) + expect('tags' in raw[1]).toBe(false) // the [] identity never serializes + }) + + it('round-trips the imported affix pools and defaults them to [] on pre-D1.3 files', () => { + const withPools: PackProject = { + ...sampleProject, + affixPools: [ + { + id: 'standard_affixes', + displayName: 'Standard Affixes', + entries: [ + { id: 'steady', displayName: 'Steady', conditionType: 'gunrng:while_ads' }, + { id: 'plain', displayName: 'Plain' }, + ], + }, + ], + } + const restored = parseProjectFile(serializeProject(withPools)) + expect(restored.affixPools).toEqual(withPools.affixPools) + + // A v2 file written before D1.3 has no affixPools key — parse defaults []. + const legacy = JSON.parse(serializeProject(sampleProject)) + delete legacy.project.affixPools + expect(parseProjectFile(JSON.stringify(legacy)).affixPools).toEqual([]) + }) + it('round-trips model geometry (faces/rotation/comment) via the RC1 wire mappers', () => { const textured: PackProject = { ...sampleProject, diff --git a/src/renderer/src/core/project/projectFile.ts b/src/renderer/src/core/project/projectFile.ts index fa78007..0831552 100644 --- a/src/renderer/src/core/project/projectFile.ts +++ b/src/renderer/src/core/project/projectFile.ts @@ -1,5 +1,6 @@ import { modelFromWire, modelToWire, type WireModel } from './cuboid' import type { + AffixPool, AmmoType, EffectDef, ElementDef, @@ -66,6 +67,9 @@ export interface GunPartFile { weight: number snapLocation?: GunPart['snapLocation'] model: WireModel + /** D1.4 — identity omission like every optional field here: absent in the + * file when the part has no tags (the `[]` identity never serializes). */ + tags?: GunPart['tags'] weightKg?: GunPart['weightKg'] adsTimeAdd?: GunPart['adsTimeAdd'] silence?: GunPart['silence'] @@ -89,6 +93,10 @@ interface PackProjectFileV2 { elements?: ElementDef[] effects?: EffectDef[] fireModes?: FireMode[] + /** Read-only imported affix pools (D1.3): additive/optional like the + * collections above (no version bump); persisted so the synergy condition + * picker's vocabulary union survives a save/open without a re-import. */ + affixPools?: AffixPool[] catalog?: PackCatalog } @@ -133,6 +141,7 @@ export function partToFile(p: GunPart): GunPartFile { weight: p.weight, ...(p.snapLocation != null ? { snapLocation: p.snapLocation } : {}), model: modelToWire(p.model), + ...(p.tags != null && p.tags.length > 0 ? { tags: p.tags } : {}), ...(p.weightKg != null ? { weightKg: p.weightKg } : {}), ...(p.adsTimeAdd != null ? { adsTimeAdd: p.adsTimeAdd } : {}), ...(p.silence != null ? { silence: p.silence } : {}), @@ -153,6 +162,7 @@ export function partFromFile(p: GunPartFile): GunPart { weight: p.weight, ...(p.snapLocation != null ? { snapLocation: p.snapLocation } : {}), model: modelFromWire(p.model), + ...(p.tags != null && p.tags.length > 0 ? { tags: p.tags } : {}), ...(p.weightKg != null ? { weightKg: p.weightKg } : {}), ...(p.adsTimeAdd != null ? { adsTimeAdd: p.adsTimeAdd } : {}), ...(p.silence != null ? { silence: p.silence } : {}), @@ -175,6 +185,7 @@ export function serializeProject(project: PackProject): string { elements: project.elements, effects: project.effects, fireModes: project.fireModes, + affixPools: project.affixPools, ...(project.catalog != null ? { catalog: project.catalog } : {}), } const envelope: ProjectFileEnvelope = { format: MAGIC, version: PROJECT_FILE_VERSION, project: fileProject } @@ -238,6 +249,7 @@ export function parseProjectFile(text: string): PackProject { elements: p.elements ?? [], effects: p.effects ?? [], fireModes: p.fireModes ?? [], + affixPools: p.affixPools ?? [], ...(p.catalog != null ? { catalog: p.catalog } : {}), } } diff --git a/src/renderer/src/core/project/sampleData.ts b/src/renderer/src/core/project/sampleData.ts index 0badb7f..585aed3 100644 --- a/src/renderer/src/core/project/sampleData.ts +++ b/src/renderer/src/core/project/sampleData.ts @@ -168,6 +168,9 @@ export const sampleProject: PackProject = { elements: sampleElements, effects: sampleEffects, fireModes: sampleFireModes, + // Import-owned (D1.3): the sample project has no imported pack, so no pools + // — the synergy condition picker falls back to the built-in catalog alone. + affixPools: [], styles: [ { id: 'voltspoke', displayName: 'Voltspoke', color: '#37c2c4' }, { id: 'gravemarshal', displayName: 'Gravemarshal', color: '#b8455f' }, diff --git a/src/renderer/src/core/project/types.ts b/src/renderer/src/core/project/types.ts index 060b859..6f4ab2a 100644 --- a/src/renderer/src/core/project/types.ts +++ b/src/renderer/src/core/project/types.ts @@ -91,6 +91,16 @@ export interface GunPart { snapLocation?: SnapLocation model: GunModelData + /** + * Free-form tag vocabulary (D1.4; wire `tags`, `list` on the + * existing GunPart contract — a base-compatibility filter, and the payload + * domain of `gunrng:with_part_tag` synergy conditions). Arms NG ADR 0004 §4: + * matching is BARE STRING EQUALITY, case-sensitive, no namespace semantics — + * so tags never go through `bareId`/identity helpers. Identity omission: + * absent means "no tags" (the codec's empty default), same as `[]`. + */ + tags?: string[] + // --- EXPANSION (E1, research 07 §C / GUNRNG_CHANGES R1.10–R1.12) ------ /** Movement/handling penalty while equipped, ALL part types (R1.10). */ weightKg?: number @@ -283,12 +293,25 @@ export interface FireMode { parameters: Record } +/** + * A namespaced condition dispatch id observed in an imported pack's affix + * pools but NOT in the built-in catalog (D1.3, the F7 union's imported half) + * — e.g. an addon-registered `othermod:on_kill`. Always `namespace:path`; + * the bare camelCase catalog spellings below never contain a colon, so this + * template literal keeps the tagged union's switch-narrowing intact. + */ +export type ForeignSynergyConditionId = `${string}:${string}` + /** A conditional-modifier gate for a part synergy (research 07 §C, BL2 * evidence: synergies are conditional modifiers, not set-bonus objects). A * tagged union so the picker can exhaustively enumerate condition kinds; * reuses the same dispatch shape as gunModeller's affix conditions * (`while_ads`/`after_n_hits`/`low_ammo`/`on_full_magazine`) plus two new - * part-composition conditions (`withPartTag`/`withStyle`). */ + * part-composition conditions (`withPartTag`/`withStyle`), plus a foreign + * passthrough variant (D1.3): a namespaced id from an imported pool whose + * payload schema DesignRNG doesn't know — the runtime tolerates unknown ids + * (Arms NG ADR 0004 §3), and the sidecar wire already round-trips them + * verbatim, so the editor may offer and preserve them without sub-fields. */ export type SynergyCondition = | { type: 'withPartTag'; tag: string } | { type: 'withStyle'; style: string } @@ -296,6 +319,7 @@ export type SynergyCondition = | { type: 'onFullMagazine' } | { type: 'afterNHits'; count: number } | { type: 'lowAmmo'; threshold: number } + | { type: ForeignSynergyConditionId } /** One conditional modifier row: apply `modifiers` when `when` holds. */ export interface SynergyModifier { @@ -309,6 +333,31 @@ export interface SilenceSettings { soundEvent?: string } +/** + * One entry of a read-only imported affix pool (D1.3). Deliberately a summary + * — id, display name, and the entry's condition dispatch id — NOT the full + * affix surface (weights/rarities/modifiers stay sidecar-side): this exposure + * exists so the synergy condition picker can union imported condition ids + * with the built-in catalog (the F7 promise), not for affix authoring. + */ +export interface AffixPoolEntry { + id: string + displayName: string + /** Namespaced condition dispatch id (e.g. `gunrng:while_ads`, + * `othermod:on_kill`), preserved EXACTLY as the pool carries it. Absent + * means the entry is unconditioned ("always active"). */ + conditionType?: string +} + +/** A read-only imported affix pool (D1.3). Import-owned like `catalog`: never + * authored, synced, or pruned by the editor — the sidecar session keeps the + * full pool docs and this summary refreshes on every import. */ +export interface AffixPool { + id: string + displayName: string + entries: AffixPoolEntry[] +} + /** A style-scoped part family (gunModeller `PartGroupDef`, display fields only). */ export interface PartGroup { id: string @@ -363,6 +412,12 @@ export interface PackProject { elements: ElementDef[] effects: EffectDef[] fireModes: FireMode[] + /** Read-only imported affix pools (D1.3): REQUIRED, `[]`-defaulted at every + * load boundary (sampleData/fromSidecar/projectFile), same F9 discipline as + * the collections above — but IMPORT-OWNED like `catalog`: never authored, + * synced, or pruned here. Consumed only by the synergy condition picker's + * vocabulary union. */ + affixPools: AffixPool[] /** Present after importing a pack; the Library falls back to a default list otherwise. */ catalog?: PackCatalog } diff --git a/src/renderer/src/modeller/panels/PartPanel.tsx b/src/renderer/src/modeller/panels/PartPanel.tsx index 8b320da..45803ab 100644 --- a/src/renderer/src/modeller/panels/PartPanel.tsx +++ b/src/renderer/src/modeller/panels/PartPanel.tsx @@ -1,10 +1,9 @@ import { useState } from 'react' import type { Vec3 } from '../../core/project/cuboid' import { modelAabb } from '../../core/project/cuboid' -import type { SynergyConditionType } from '../../core/project/effectCatalog' import { identityStatModifierSet, StatModifierGrid } from '../../ui/definitions/StatModifierGrid' import { OptionalNumberField } from '../../ui/definitions/OptionalNumberField' -import { ALL_SYNERGY_CONDITION_TYPES, defaultConditionFor } from '../../ui/definitions/synergyConditionFields' +import { ALL_SYNERGY_CONDITION_TYPES, conditionForPickerValue } from '../../ui/definitions/synergyConditionFields' import type { BaseStats, SynergyCondition, SynergyModifier } from '../../core/project/types' import { handlingScalarValidationMessage } from '../../core/project/partHandling' import { useModeller } from '../store' @@ -43,15 +42,33 @@ const STAT_FIELDS: { key: keyof BaseStats; label: string }[] = [ { key: 'reloadTime', label: 'reload (s)' }, ] +/** `` id shared by the tags editor and the `withPartTag` sub-field + * (D1.4) — a single PartPanel mounts at a time, so a static id is safe. */ +const PART_TAG_DATALIST_ID = 'part-tag-options' + export interface PartPanelProps { /** Pack vocabularies for the assignment selects; empty arrays fall back to * free-text inputs (same convention as `NewEntityModal`). */ slotOptions: string[] styleOptions: { id: string; displayName: string }[] partGroupOptions: (style: string) => string[] + /** D1.3: the synergy condition picker's full vocabulary — the built-in + * catalog UNIONED with condition ids observed in the imported pack's affix + * pools (`synergyConditionOptions`). Absent = catalog only. */ + conditionOptions?: string[] + /** D1.4: tags observed across the project's parts (`observedPartTags`) — + * advisory suggestions for the tags editor and the `withPartTag` sub-field; + * free entry always stays legal (ADR 0004 §4). */ + tagOptions?: string[] } -export function PartPanel({ slotOptions, styleOptions, partGroupOptions }: PartPanelProps) { +export function PartPanel({ + slotOptions, + styleOptions, + partGroupOptions, + conditionOptions, + tagOptions, +}: PartPanelProps) { const partMode = useModeller((s) => s.partMode) const model = useModeller((s) => s.model) const snapLocation = useModeller((s) => s.snapLocation) @@ -219,15 +236,70 @@ export function PartPanel({ slotOptions, styleOptions, partGroupOptions }: PartP onCommit={(n) => setField({ weight: n })} /> + setField({ tags: next.length > 0 ? next : undefined })} + /> - + + + {/* D1.4: one shared suggestion list for the tags editor and the + withPartTag sub-field — observed vocabulary, free entry legal. */} + + {(tagOptions ?? []).map((tag) => ( + )} ) } +// --- Tags editor (D1.4) -------------------------------------------------------- +// Free-form string list on `GunPart.tags` (the existing runtime wire field — +// base-compatibility filter + the `withPartTag` synergy payload domain). +// Suggestions come from the shared observed-tags datalist; an emptied list +// collapses back to `undefined` in the form store so Apply un-authors the +// field honestly (the same discipline as the synergy editor below). + +function TagsEditor(props: { tags: string[]; onChange: (next: string[]) => void }) { + const { tags, onChange } = props + const setAt = (i: number, value: string) => { + const copy = [...tags] + copy[i] = value + onChange(copy) + } + return ( +
+
Tags — free-form part labels (base compatibility, withPartTag synergies).
+ {tags.map((tag, i) => ( +
+ setAt(i, e.target.value)} + /> + +
+ ))} + +
+ ) +} + // --- Advanced group (EXPANSION E6) -------------------------------------------- // Collapsible so the panel isn't overwhelming for the common case of a part // with no advanced fields authored. Open state is local UI state (not form @@ -237,8 +309,9 @@ export function PartPanel({ slotOptions, styleOptions, partGroupOptions }: PartP function AdvancedGroup(props: { fields: PartFormFields setField: (patch: Partial) => void + conditionOptions?: string[] }) { - const { fields, setField } = props + const { fields, setField, conditionOptions } = props const [open, setOpen] = useState(false) return ( @@ -322,6 +395,7 @@ function AdvancedGroup(props: { setField({ synergyModifiers: next.length > 0 ? next : undefined })} /> @@ -330,12 +404,14 @@ function AdvancedGroup(props: { ) } -// --- Synergy editor (EXPANSION E6) -------------------------------------------- +// --- Synergy editor (EXPANSION E6, F7 union completed by D1.3) ----------------- // Each row = a condition picker (drives its own sub-fields off the // `SynergyCondition` tagged union) + a `StatModifierGrid`. Condition -// vocabulary is `SYNERGY_CONDITION_TYPES` (research 07 §8 catalog) — the -// imported-pack half of the F7 union is blocked on sidecar exposure (no -// affix-pool condition ids reach the TS side today; see this chunk's report). +// vocabulary is the FULL F7 union: `SYNERGY_CONDITION_TYPES` (research 07 §8 +// catalog) plus condition ids observed in the imported pack's affix pools +// (`synergyConditionOptions`, fed in via `conditionOptions`). A foreign id +// renders no sub-fields — DesignRNG doesn't know its payload schema, and the +// wire round-trips the bare `{type}` dispatch object verbatim. function ConditionFields(props: { condition: SynergyCondition; onChange: (next: SynergyCondition) => void }) { const { condition, onChange } = props @@ -346,6 +422,7 @@ function ConditionFields(props: { condition: SynergyCondition; onChange: (next: tag onChange({ ...condition, tag: e.target.value })} /> @@ -383,15 +460,20 @@ function ConditionFields(props: { condition: SynergyCondition; onChange: (next: case 'whileAds': case 'onFullMagazine': return null + default: + // Foreign/imported condition (D1.3): unknown payload schema — no + // sub-fields to render; the `{type}` dispatch object travels as-is. + return null } } function SynergyRow(props: { synergy: SynergyModifier + conditionOptions: string[] onChange: (next: SynergyModifier) => void onRemove: () => void }) { - const { synergy, onChange, onRemove } = props + const { synergy, conditionOptions, onChange, onRemove } = props return (
@@ -399,9 +481,15 @@ function SynergyRow(props: { when