- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots. - Implemented UI elements including buttons for changing props and canceling actions. - Added `PropUseSpotData` class for validation and serialization of prop use spots. - Created tests for validating use spot data migration, serialization, and rule matching. - Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
178 lines
25 KiB
Markdown
178 lines
25 KiB
Markdown
# Phase 17b — Use spots: architecture and implementation specification
|
||
|
||
> **Status:** Approved design; implementation is present and awaiting in-editor user verification.
|
||
> **Product/UI contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [approved Use spots UI/UX specification](phase_17b_use_spots_ui.md).
|
||
> **Scope:** Placed-prop Use spots, a queued Use action, a completed-use rule trigger, and their EDIT/DIRECT/PLAY presentation in the Sandbox Stage.
|
||
|
||
## Decisions and boundaries
|
||
|
||
1. **Empty hands are required.** A stickman carrying a prop cannot begin Use. Using another prop while carrying is future work. A target currently held or reserved by any stickman is also unavailable for Use.
|
||
2. **Target physics remains normal.** A moving target is never frozen, teleported, or reserved for Use. Its local spot follows its live transform. The approach may be recalculated once within a bounded displacement; continued movement or an unreachable new position fails with a visible reason.
|
||
3. **Grounded locomotion only.** Use may walk, turn, reach, and crouch. Automatic jumping and climbing are future work.
|
||
4. Use does not move the target prop, fire an invented built-in effect, or treat a pickup grip as a Use spot. Only the successful touch emits `prop_used`; rules provide the story consequence. The reusable `.prop` asset schema and `PropAnchorData` remain unchanged.
|
||
|
||
### Shape of the design
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Card[StagePropSettingsView] -->|Add, rename, move, erase| Stage[SandboxStage]
|
||
Stage -->|validated edit| Prop[PropBlock instance spots]
|
||
Stage -->|save/load| Adapter[StageSceneAdapter + StageSceneIO 1.3]
|
||
Editor[ActionEditor + RuleEditor] -->|draft target| Capture[StageTargetCaptureSession]
|
||
Capture -->|preview| Solver[StickmanUseReachSolver]
|
||
Runner[StickmanActionRunner] --> Interactor[StickmanPropInteractor Use phases]
|
||
Interactor --> Solver
|
||
Interactor -->|completed prop + spot ID| Event[SandboxStage event dispatch]
|
||
Event --> Matcher[StageRuleEngine prop_used matcher]
|
||
```
|
||
|
||
`SandboxStage` remains the coordinator. Small data, overlay, and reach helpers carry their own logic; none becomes a global event bus or a new prop physics body.
|
||
|
||
## File responsibilities and typed contracts
|
||
|
||
| File | Responsibility and intended API |
|
||
| --- | --- |
|
||
| `scripts/props/prop_use_spot_data.gd` (new), `class_name PropUseSpotData extends RefCounted` | Pure schema helper: `static func validate_saved(raw_spots: Variant, raw_next: Variant) -> String`, `static func decode(raw_spots: Array) -> Array[Dictionary]`, `static func encode(spots: Array[Dictionary]) -> Array[Dictionary]`, `static func fallback_name(number: int) -> String`, `static func find(spots: Array[Dictionary], spot_id: String) -> Dictionary`. No scene-tree or physics access. |
|
||
| `scripts/props/prop_block.gd`, `class_name PropBlock extends RigidBody2D` | Own `use_spots: Array[Dictionary]` and `next_use_spot_number: int`; expose `get_use_spots() -> Array[Dictionary]`, `get_use_spot(id: String) -> Dictionary`, `get_use_spot_local_bounds() -> Rect2`, `add_use_spot(local_position: Vector2) -> String`, `rename_use_spot(id: String, name: String) -> bool`, `move_use_spot(id: String, local_position: Vector2) -> bool`, `erase_use_spot(id: String) -> bool`, `use_spot_world_position(id: String) -> Vector2`, and EDIT-only `use_spot_local_from_world(world_position: Vector2) -> Vector2`. Return copies from getters. No grip-anchor mutation. |
|
||
| `scripts/editors/stage/stage_prop_settings_view.gd`, `class_name StagePropSettingsView extends Control` | Append the UI-spec Use spots section to the existing `StageInspectorPanel` body. Emit `spot_add_requested()`, `spot_move_requested(spot_id: String)`, `spot_rename_requested(spot_id: String, name: String)`, `spot_erase_requested(spot_id: String)`, and `spot_selected(spot_id: String)`. Update through `show_prop`/a dedicated `sync_spots`, without writing to `PropBlock`. Keep multi-selection notice and dismissed-card behavior. |
|
||
| `scripts/editors/stage/stage_use_spot_overlay.gd`, `class_name StageUseSpotOverlay extends Control` | Screen-space numbered pins and name labels, EDIT cursor ghost, per-spot reach status, success pulse, and failure cue. Exposes `show_edit(...)`, `show_targeting(...)`, `show_success(prop, spot_id)`, `show_failure(prop, spot_id, reason)`, `hit_spots(screen_position)`, `closest_hit_spot(screen_position)`, `clear_session()`, and `set_paused(paused)`. It draws cached preview status beside the pin without approach or hand-path lines; it does not own spot data or run reach physics queries. Overlapping pin hits are disambiguated by the stage popup. |
|
||
| `scripts/editors/stage/stage_use_target_capture_bar.gd`, `class_name StageUseTargetCaptureBar extends PanelContainer` | DIRECT Use and Use-related rule capture prompt and actions. `set_capture_state(prop_selected, prop_has_use_spots)` switches between prop, spot, and no-spots prompts; `hide_capture()` closes it. Emits `change_prop_requested` and `cancel_requested`; it does not commit a target. |
|
||
| `scripts/editors/stage/stage_target_capture_session.gd`, `class_name StageTargetCaptureSession extends RefCounted` | Owns the active `USE_PROP` / `USE_SPOT` and EDIT `SPOT_ADD` / `SPOT_MOVE` capture states, provisional prop/spot IDs, actor/context, queue prefix, and cached preview. `begin_use_target(...)`, `select_use_target_prop(...)`, `reset_use_target_prop()`, `begin_spot_edit(...)`, and `finish()` maintain the single-capture invariant. Rule capture uses the same Use-target capture context; there is no separate `USED_RULE_PROP` kind. |
|
||
| `scripts/characters/stickman/stickman_use_reach_solver.gd` (new), `class_name StickmanUseReachSolver extends RefCounted` | One nonmutating `solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, options: Dictionary = {}) -> Dictionary`. It uses rig arm geometry, grounded terrain probes, route/body sweep, and current prop transform. Both DIRECT preview and PLAY call it. Direct-space physics calls occur only in `_physics_process`/rig physics tick; UI draws cached results. |
|
||
| `scripts/characters/stickman/stickman_prop_interactor.gd`, existing rig scene, `scripts/characters/stickman/stickman_rig.gd`, `scripts/characters/stickman/stickman_action_runner.gd` | Add explicit `USE_APPROACHING`, `USE_REACHING`, `USE_RECOVERING` phases under the existing one-action/one-hand ownership boundary. The interactor owns marker restoration, one replan count, completion and failure. Rig adds `preview_prop_use(...) -> Dictionary`; runner includes `use_prop` in the existing `PROP_INTERACTION` path and waits for `action_completed`. The existing interactor node path stays intact. |
|
||
| `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`, `scripts/stage/stage_rule_engine.gd` | Register `use_prop` and `prop_used`; implement flat/rule conversion and summaries, explicit event matching, and only-success dispatch. A failed Use still safely completes its queue slot, but emits no `prop_used` event. |
|
||
| `scripts/editors/stage/action_editor.gd`, `stage_action_queue_controller.gd`, `rule_editor.gd`, `stage_rule_authoring_controller.gd`, `queue_panel.gd`, `rule_panel.gd`, `sandbox_stage.gd` | Wire Use through the action menus, editors, capture callback, queue/rule lists, summaries, and repair state. Keep editor drafts separate from authored queue/rules until Done. Named spot labels are drawn on pins; overlapping hit regions open a named-choice popup. |
|
||
| `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_run_session.gd` | Collect/apply per-instance spots; migrate/validate scene v1.3; preserve missing Use references; clear Use animation/pulse on STOP while authored spots survive reset. |
|
||
| `scripts/stage/sandbox_theme.gd`, `sandbox_theme.json` | Add `stage_panels.spots` and Use preview tokens in defaults and JSON. The UI spec owns exact copy and minimum target sizes. |
|
||
|
||
`StageInspectorPanel` provides the fixed header, body scroll, safe fit, close, and action-button styling. `StagePanelToggleRow` and `StagePanelSwitchVisual` remain in the role section only. New `prop_used` rules target one named spot; the authoring flow has no **Any spot on this prop** option. Saved `spot_scope = "any"` rules remain readable and match any successfully used spot on their referenced prop for compatibility. Rule and action dialogs use `SandboxTheme.control_theme()` directly; they do not nest a `StageInspectorPanel`.
|
||
|
||
## Spot schema, identity, and coordinate contract
|
||
|
||
An in-memory spot is `{ "id": String, "number": int, "name": String, "position": Vector2 }`. `id` is `spot_<number>`; `number` is positive and unique within one placed prop. `next_use_spot_number` begins at 1, increments only on successful Add, and remains greater than every existing number. Erase never reduces it. Blank trimmed names normalize to `Spot <number>`; duplicate names are valid, and the number is always displayed. Names are presentation only; queue/rule references use IDs.
|
||
|
||
Scene version **1.3** adds the following keys to each real prop entry, with no `.prop` asset change:
|
||
|
||
```json
|
||
{
|
||
"file_id": "prop_1",
|
||
"type": "prop",
|
||
"stage_object_id": "prop_unchanging_instance_id",
|
||
"use_spots": [
|
||
{"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}}
|
||
],
|
||
"next_use_spot_number": 2
|
||
}
|
||
```
|
||
|
||
`StageSceneIO.FILE_VERSION` becomes `"1.3"`. Migrate 1.0/1.1/1.2 through the existing 1.2 role/depth migration, then set missing `use_spots: []` and `next_use_spot_number: 1`. Current 1.3 documents lacking the additive keys receive the same defaults. A **present** malformed collection, duplicate/noncanonical ID or number, nonfinite point, or counter not greater than the highest stored number fails root validation before replacing the loaded world. Decode saved `{x,y}` dictionaries with `data_to_vector`, never by assigning dictionaries to typed vector arrays. Missing-prop placeholders retain their original entry, including spots and counter, through resave. `ContentPaths.canonicalize` continues to handle only path fields.
|
||
|
||
`file_id` is regenerated on collection; it is not a persistent prop identity. `stage_object_id` survives save/load and is the action/rule prop key. A second placement of the same `.prop` begins with no Use spots and its own counter. If a future placement-copy command is added, it must allocate a new prop ID while deep-copying spots and preserving their local IDs within that new prop.
|
||
|
||
Coordinates are authored in **unscaled prop asset-local space** and follow the prop's translation and rotation. A spot may be in a transparent gap inside the prop artwork bounds. `get_use_spot_local_bounds()` returns `get_visual_local_bounds()` for custom layered props, a radius rect for built-in circles, and the point AABB for built-in polygons; `get_visual_local_bounds()` alone is empty for the built-in primitives. On EDIT placement, first check that the prop transform is finite and invertible; then use `prop.to_local(world_position)` and those local bounds. During EDIT, root `global_transform` already contains the authored scale. During PLAY, `PropBlock.begin_play_instance_scale()` moves the signed authored scale into children and resets root scale. Thus the sole world conversion is:
|
||
|
||
```gdscript
|
||
func use_spot_world_position(spot_id: String) -> Vector2:
|
||
var spot: Dictionary = get_use_spot(spot_id)
|
||
if spot.is_empty(): return Vector2.INF
|
||
var local_point: Vector2 = spot["position"]
|
||
var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE
|
||
return global_transform * (local_point * baked_scale)
|
||
```
|
||
|
||
Use the **signed** `_play_instance_scale`; `get_interaction_scale()` currently returns an absolute value for pickup grips and would lose mirrored spot positions. No caller multiplies by root scale again. Reset restores root authored scale through the existing `StageRunSession` path. Overlay hit regions convert the resulting world point to viewport pixels and maintain a minimum 44 × 44 screen-pixel target at any camera zoom.
|
||
|
||
## Authoring and capture transactions
|
||
|
||
EDIT Add/Move starts only for one selected, live `PropBlock`. The capture stores its `stage_object_id`, mode, and (for Move) existing spot ID; it does not mutate the spot collection while following the cursor. A click is accepted only inside that prop's visual-local bounds after inverse transform. The selected prop remains visible; clicking elsewhere leaves the session active. A valid click commits one Add/Move, marks the scene dirty, refreshes the card/pins and summaries, and ends capture. Esc, right-click, visible Cancel, prop-selection change, or mode change discards the provisional position. Rename commits on Enter/focus exit; blank returns to the same fallback. The row/pin selection affects emphasis and camera reveal only.
|
||
|
||
Erase shows the UI-spec confirmation, including the reference warning if any queue action, rule action, or specific `prop_used` trigger uses that exact prop/spot pair. On confirmation, erase only the spot. Keep the referring payloads and mark them as needing repair; do not retarget, broaden, or silently remove them. A prop deletion likewise retains Use-specific queue/rule references for repair, while the current behavior for unrelated rule types and deleted actor sources remains in place. Avoid the current `_cleanup_rules_for_nodes()` blanket pruning for a `prop_used` target by representing its target with stable IDs in trigger params.
|
||
|
||
DIRECT Use target capture is two steps: select a prop through `StageSelection.hit_test_all()` / **Objects here**, then click one of its labeled pins. Overlapping hit regions open a named-choice popup instead of selecting arbitrarily. The persistent capture bar sits below the top toolbar and mode pill; it shows a step-aware prompt, visible **Cancel**, and, after prop selection, **Change prop**. It uses a compact dark rounded panel with a rounded-edge DIRECT amber accent; its shared visual contract is in the [Stage panel UI system spec](stage_panel_ui_system.md). A known-invalid target leaves capture active. An uncertain future actor state yields the UI-spec amber cue and permits a structurally valid target; PLAY checks it again. Choosing another prop clears only the provisional spot. Cancel restores the invoking editor and its prior draft. A DIRECT-mode selection queues the Use action immediately; a selection made inside an editor updates its draft, which is committed through that editor's Done flow.
|
||
|
||
Rule capture selects the acting stickman, a prop with spots, and one specific spot ID. Rule capture does **not** run the reach solver. Changing prop clears the provisional spot so the child must choose a spot on the replacement prop. Existing saved rules with `spot_scope = "any"` remain supported for compatibility, but new rules are authored for one named spot and expose no Any-spot authoring button. A missing saved reference appears as **Choose a new spot** and cannot be recommitted until repaired.
|
||
|
||
The current exclusive-popup host hide/restore convention applies to all new captures and their Cancel/resolve paths; see existing debt #19. The capture bar keeps a visible Cancel control available in both stages of action and rule targeting. The separate EDIT Add/Move spot-placement flow retains its existing stage instruction and Cancel control; the shared capture-bar pattern is the approved standard for future EDIT/DIRECT capture flows, as recorded in the [Stage panel UI system spec](stage_panel_ui_system.md) and [AGENTS.md](../../AGENTS.md).
|
||
|
||
## Action, rule, and event wire contracts
|
||
|
||
Flat in-memory queue action:
|
||
|
||
```gdscript
|
||
{"type": "use_prop", "target_prop_id": "prop_...", "target_spot_id": "spot_2"}
|
||
```
|
||
|
||
`StageSceneIO.encode_queue/decode_queue` saves the same strings, validating nonempty IDs and a known action type. It must **not** require the referenced prop/spot to exist during decode; the reference may be awaiting repair. `ActionRegistry.to_rule_action/from_rule_action` preserves both keys in the nested `params` of a rule consequence, and `_encode_rule_action/_decode_rule_action` use the same flat codec. `ActionRegistry.summarize()` provides a safe fallback, while Stage-owned presentation resolves live names and replaces missing ones with **Choose a new spot**. Queue list, action editor, rule consequence list, and rule summary refresh after rename/erase/load.
|
||
|
||
`prop_used` trigger in memory and on disk:
|
||
|
||
```gdscript
|
||
{
|
||
"type": "prop_used",
|
||
"source": actor_instance_id,
|
||
"target": -1,
|
||
"params": {
|
||
"target_prop_id": "prop_...",
|
||
"spot_scope": "specific", # or "any"
|
||
"target_spot_id": "spot_2" # omitted for "any"
|
||
}
|
||
}
|
||
```
|
||
|
||
The saved rule uses the existing mapped `source_id` and null `target_id`; `target_prop_id` remains the stable string. Its matcher **must require** `target_prop_id` equality with the event's live `PropBlock.stage_object_id` even though `target == -1`; otherwise the current wildcard semantics could make a missing prop match every prop. For `specific`, require exact nonempty `target_spot_id` equality; `any` matches every successfully touched spot on that one prop. Validate the enum and conditional key in `StageSceneIO._encode_trigger/_decode_trigger`. Missing target objects/spots remain structurally valid saved references. Do not resolve them through `_unmap_ref`, which currently drops missing instance-ID rule targets. Existing source actor mapping and deletion semantics remain intact.
|
||
|
||
At verified touch the interactor emits a **separate typed** signal, `use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)`. `StickmanRig` forwards it; `SandboxStage` calls `_handle_event({"type": "prop_used", "source": rig, "target": prop, "spot_id": spot_id, "position": world_position, "action": ...})`, displays the pulse, then lets `StageRuleEngine` match rules. Keep the existing three-argument `prop_event` signal for pickup/drop/throw/Set Down so its consumers need no ambiguous reason/spot overloading. Failure emits `use_failed(reason_code: String, prop: PropBlock, spot_id: String)` for a local cue, with no `prop_used` or pulse. The runner still advances its queue safely; generic `action_finished` retains its existing meaning.
|
||
|
||
## Shared reach calculation and runtime behavior
|
||
|
||
The solver returns a typed-key `Dictionary`:
|
||
|
||
```gdscript
|
||
{
|
||
"known": true,
|
||
"valid": true,
|
||
"reason_code": "",
|
||
"spot_world": Vector2(420, -160),
|
||
"approach_root": Vector2(330, -385),
|
||
"approach_feet": Vector2(330, 0),
|
||
"facing_left": false,
|
||
"hand": StickmanPropInteractor.Hand.RIGHT,
|
||
"reach_start": Vector2(355, -210),
|
||
"reach_end": Vector2(420, -160)
|
||
}
|
||
```
|
||
|
||
Invalid results retain relevant preview geometry and use one of `prop_missing`, `spot_missing`, `hands_full`, `prop_held`, `no_standing_room`, `route_blocked`, `out_of_reach`, or `target_moved`. The UI maps these to its short child-facing strings. A future-position preview returns `known = false` with `reason_code = "earlier_actions"`; it is amber, never green. Only a known invalid result blocks DIRECT commit. Invalid/unknown source actor or nonfinite transforms fail explicitly.
|
||
|
||
The solver tests the current root first, then grounded standing candidates on both sides of the spot. It derives shoulder-to-wrist reach from live upper/lower arm lengths and rig facing, chooses an available hand only when empty handed, checks terrain support and route through the same navigation/actor-blocking sweeps as `StickmanLocomotion`, and checks standing torso/head clearance. It may choose a crouch pose for a low point. It does not invent a platform on a support prop, move a pinned point, or permit a jump. Hand-path obstruction checks exclude the target prop's own collider; configured blocking geometry along the reach still invalidates a path. Candidate ranking prefers the valid shortest grounded route with a reachable wrist. Validate the actual solved wrist against the live spot with a small contact tolerance at completion; the existing pickup `MAX_REACH = 390` wrist-distance heuristic alone does not prove a touch.
|
||
|
||
DIRECT prediction receives the current actor and the unconsumed queue prefix from `StageTargetCaptureSession` (generalize `throw_preview_prefix_for` without breaking its current callers). Earlier Move To actions shift the predicted root from their ground target to the rig root; Speak and Wait preserve the predicted position. An earlier Use on an existing stationary prop shifts the predicted root to its known approach when valid, while a known failed Use leaves the actor in place. Active motion, ragdoll/recovery, pickup/drop/throw/set-down interactions, unknown actions, and moving target physics make the prediction conditional. Preview is advisory; runtime uses live positions and repeats the check. Perform physics queries in `_physics_process`, cache the result, and draw it in `_process`/overlay without querying the physics space there.
|
||
|
||
```text
|
||
IDLE ── valid Use ──> USE_APPROACHING ── arrived ──> USE_REACHING
|
||
^ │ blocked/moved │ touch verified
|
||
│ └─────────────── fail ──────────┤
|
||
│ v
|
||
└──── restored marker/torso <── USE_RECOVERING <── prop_used once
|
||
```
|
||
|
||
At action start, resolve the stable prop and spot IDs, require empty hands and an animated/grounded actor, and solve. `USE_APPROACHING` walks toward the chosen feet point with normal prop collision policy. Keep the target's live spot world position under observation. If its displacement from the planned position exceeds a named threshold, recompute once from the current actor root and current prop transform; update the approach target. A second meaningful drift, blocked path, or newly invalid solution fails promptly. A small stationary jitter does not spend the replan. `USE_REACHING` drives one free IK hand marker toward the **current** spot; if it drifts beyond a separate contact tolerance after the approach, fail rather than teleport the arm. Recheck reach/obstruction and actual wrist contact before emitting success once. `USE_RECOVERING` eases hand/torso/legs back, then emits `action_completed` to the runner. Failures also restore the pose, emit a specific cue once, and finish safely. The target keeps its existing freeze/velocity/collision profile throughout.
|
||
|
||
Pause stops the rig physics tick and procedural Use timer. The Stage explicitly pauses the overlay's completion-pulse timer too, since a `Control._process` clock otherwise continues while the rig is paused. Resume continues from the same phase, revalidates the live spot, and observes the normal event baseline gate. Takeover/ragdoll cancels Use, restores the saved marker/pose, and emits no success; a completed Use event is never replayed by resume. STOP/reset calls the existing interactor cleanup, cancels the pulse/preview, and restores the authored world through `StageRunSession`. Repeated PLAY runs start with zero Use replan count and no stale marker or success latch.
|
||
|
||
## Implementation order and verification boundaries
|
||
|
||
1. Add schema validation/normalization and `PropBlock` local/world APIs; migrate StageSceneIO/StageSceneAdapter to 1.3, including missing-prop placeholders and strict present-key validation.
|
||
2. Add action/trigger registry and codec branches, runtime matching, stable-reference repair checks, and name-resolving summaries. Protect `prop_used` from deletion pruning and decode-time loss.
|
||
3. Add shared reach solver and interactor phases/signals; wire runner, rig facade, stage event dispatch, pause/reset cleanup, and failure reasons.
|
||
4. Extend the existing prop card and Stage UI theme, then build EDIT and DIRECT capture/overlay/dialog flows, overlap choices, preview, and keyboard list alternatives.
|
||
5. Review the completed implementation against the UI acceptance criteria in the editor. This document records the intended and current interaction contract; it does not claim that every visual or runtime behavior has been user-verified. Automated results, where available, are maintained separately in the architecture log.
|
||
|
||
## Trade-offs and open debt
|
||
|
||
- Persistent spot IDs on the placed `PropBlock` fit the current JSON scene model. New `Resource` files or child `Marker2D` nodes per spot would add scene-tree/persistence machinery without improving this small authored collection.
|
||
- `prop_used` uses stable prop identity in its params while keeping actor source mapping in the existing rule system. This targeted exception preserves missing references without changing every rule type; broader session-ID limitations remain tracked in architecture debt #24.
|
||
- Existing popup host visibility, broad AABB object targeting, and the large root stage controller remain tracked under debt #19, #12, and #67. This phase reuses their boundaries and adds no duplicate issue for them.
|
||
- Preview cannot simulate every earlier rule or moving rigid body. It reports uncertainty and rechecks in PLAY; a future predictive timeline can improve this without changing saved action targets. This distinct limitation is logged as debt #68; existing throw-prefix behavior remains tracked by resolved #52.
|
||
|