fix(ui): address PR #99 review — character creation editor fixes

13 fixes from PR #99 review on #705 (character editor):

- #10/#11: Add _get_swatch_color() helper; fix stale closure in
  _make_color_swatch (reads current color at click time, not
  construction time); fix _on_modal_ok to use helper instead of
  wrong get_child(0) traversal
- #12: Replace HSV palette with D-165 hardcoded hex values (6×9
  rows, muted HSL per spec — reds/greens/blues/purples/browns/grays)
- #13: Fix fallback asset IDs to match actual files — hair: bald/
  bob/buzzed/long; heads: head_001–004; facial hair: beard/moustache/
  mutton_chops (stubble removed — no asset)
- #14: Remove invented "slot field" TODO comment; replace with
  name-prefix convention note
- #15: Fix overhead camera pitch 0.0°→-80.0° (0° was horizontal,
  not top-down as intended per D-158)
- #16: Make hair highlight swatch non-interactive (_make_display_swatch);
  remove _on_hair_highlight_changed and _hair_highlight_auto flag
- #17: Remove no-op self-assignment in _on_hair_selected; remove
  redundant assignment inside _hair_highlight_auto block
- #18: Store _clothing_dock_container/_accessory_dock_container as
  instance vars — eliminates fragile get_child(3).get_child(0) chain
- #19: Fix test_r_key_triggers_randomise — replaces assert_bool(true)
  with a real assertion checking body_type or skin_tone changed
- #20: Cache scan results in _cached_hair_ids/_cached_head_ids during
  tab build; _on_randomise() reads cache (no re-scan per keypress)
- #21: Fix character_visual.gd docstring "21 segments" → "18 segments"
- #22: Fix _get_accessory_ids_for_slot — apply slot name-prefix
  filtering instead of returning all accessories for every slot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:46:10 +01:00
co-authored by Claude Opus 4.6
parent 56e8c70081
commit 33d7d6996b
3 changed files with 149 additions and 92 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ extends Node3D
## pre-authored asset files. No runtime deformation — selects and attaches meshes.
##
## All asset paths derived programmatically from BASE_PATH + descriptor fields:
## bodies/{body_type}/seg_{name}.glb (D-160: 21 segments per body type)
## bodies/{body_type}/seg_{name}.glb (D-160: 18 segments per body type)
## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D)
## hair/{hair_id}.glb (BoneAttachment3D to Head bone)
## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type)
@@ -474,13 +474,24 @@ func test_q_key_rotates_left() -> void:
func test_r_key_triggers_randomise() -> void:
if _scene == null:
return
# R key should change SOMETHING in the descriptor (not all defaults)
var event := InputEventKey.new()
event.keycode = KEY_R
event.pressed = true
# Just verify it doesn't crash
_scene._input(event)
assert_bool(true).is_true() # reached here without error
# Seed to a known state so change is detectable
_scene._descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
_scene._descriptor.skin_tone = 0
var body_before := _scene._descriptor.body_type
var skin_before := _scene._descriptor.skin_tone
# Press R up to 10 times — must change at least one observable descriptor field
var changed := false
for _i in 10:
var event := InputEventKey.new()
event.keycode = KEY_R
event.pressed = true
_scene._input(event)
if _scene._descriptor.body_type != body_before or _scene._descriptor.skin_tone != skin_before:
changed = true
break
assert_bool(changed).override_failure_message(
"R key must trigger randomise and change body_type or skin_tone"
).is_true()
# =============================================================================
+130 -84
View File
@@ -24,8 +24,9 @@ const ITEM_SELECTED_BG := Color(0.10, 0.13, 0.18, 1.0)
const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER — selection border
# --- Camera presets (D-158) ---
# Pitch is degrees below horizontal: 0=top-down, 5=near-horizontal, 30=steeper
const CAM_PITCHES: Array[float] = [-5.0, -30.0, 0.0] # frontal, dramatic, overhead
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
# Negative values = tilt downward (camera looks toward ground).
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
const CAM_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
const CAM_DISTANCE: float = 2.4
const CAM_TARGET_HEIGHT: float = 0.9
@@ -66,8 +67,8 @@ const BODY_TYPE_LABELS: Dictionary = {
}
# --- Hair / facial hair / eyebrows ---
const FACIAL_HAIR_OPTIONS := ["", "stubble", "beard", "moustache"]
const FACIAL_HAIR_LABELS := ["None", "Stubble", "Beard", "Moustache"]
const FACIAL_HAIR_OPTIONS := ["", "beard", "moustache", "mutton_chops"]
const FACIAL_HAIR_LABELS := ["None", "Beard", "Moustache", "Mutton Chops"]
const EYEBROW_OPTIONS := ["regular", "female", "teen", "thick"]
const EYEBROW_LABELS := ["Regular", "Female", "Teen", "Thick"]
@@ -86,8 +87,21 @@ const ACCESSORY_SLOT_LABELS := [
]
# --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) ---
const PALETTE_HUES: Array[float] = [0.0, 150.0, 215.0, 275.0, 28.0]
const PALETTE_SAT: Array[float] = [0.35, 0.28, 0.35, 0.28, 0.35]
# Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
const PALETTE_COLORS: Array[Array] = [
# Row 1: Reds H=0° S=35%
["#3d1d1d", "#522727", "#673131", "#7f3d3d", "#974848", "#af5959", "#bc7575", "#c99090", "#d7acac"],
# Row 2: Greens H=150° S=28%
["#213a2d", "#2c4e3d", "#37614c", "#43785e", "#508f70", "#62a684", "#7cb599", "#96c4ad", "#b0d2c1"],
# Row 3: Blues H=215° S=35%
["#1d2b3d", "#273952", "#314867", "#3d587f", "#486997", "#597daf", "#7593bc", "#90a8c9", "#acbed7"],
# Row 4: Purples H=275° S=28%
["#30213a", "#402c4e", "#503761", "#624378", "#75508f", "#8a62a6", "#9d7cb5", "#b196c4", "#c4b0d2"],
# Row 5: Browns H=28° S=35%
["#3d2c1d", "#523b27", "#674a31", "#7f5c3d", "#976d48", "#af8159", "#bc9675", "#c9ab90", "#d7c0ac"],
# Row 6: Neutral grays
["#000000", "#1f1f1f", "#3f3f3f", "#5f5f5f", "#7f7f7f", "#9f9f9f", "#bfbfbf", "#dfdfdf", "#ffffff"],
]
const PALETTE_COLS := 9
const RECENT_SLOTS := 9
@@ -145,7 +159,7 @@ var _accessory_primary_swatches: Dictionary = {} # slot -> Button
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
var _hair_highlight_auto: bool = true
# Note: hair highlight is always auto-derived (display-only swatch, no override).
var _eyebrow_tint_auto: bool = true
var _facial_hair_tint_auto: bool = true
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
@@ -161,6 +175,14 @@ var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches
var _recent_colors: Array[Color] = [] # persisted recent custom colors
var _modal_recent_btns: Array[Button] = []
# --- Dock container references (set during tab build; avoid fragile child-index traversal) ---
var _clothing_dock_container: Control = null
var _accessory_dock_container: Control = null
# --- Cached asset scan results (populated once during tab build, reused by randomise) ---
var _cached_hair_ids: Array = []
var _cached_head_ids: Array = []
# --- Per-tab search text ---
var _tab_search: Array[String] = ["", "", "", "", ""] # one per tab index
## Per-tab grid container for search filtering (index = tab index 04).
@@ -177,7 +199,7 @@ func _ready() -> void:
_descriptor.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
_descriptor.skin_tone = 2 # light_olive — readable middle-ground default
_descriptor.eyebrow_id = "regular"
_descriptor.hair_id = "short_a"
_descriptor.hair_id = "bob"
_descriptor.hair_tint = Color(0.55, 0.35, 0.20) # warm brown default
_char_visual = CharacterVisual.new()
@@ -361,9 +383,9 @@ func _build_head_tab(tab: Control) -> void:
scroll.add_child(grid)
_tab_grids[1] = grid
var head_ids := _scan_asset_ids("res://assets/characters/heads/templates/", ".glb",
["starter_a", "starter_b", "starter_c", "starter_d"])
for hid in head_ids:
_cached_head_ids = _scan_asset_ids("res://assets/characters/heads/templates/", ".glb",
["head_001", "head_002", "head_003", "head_004"])
for hid in _cached_head_ids:
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(96, 80))
btn.pressed.connect(_on_head_selected.bind(hid))
grid.add_child(btn)
@@ -416,9 +438,9 @@ func _build_hair_tab(tab: Control) -> void:
hair_scroll.add_child(hair_grid)
_tab_grids[2] = hair_grid
var hair_ids := _scan_asset_ids("res://assets/characters/hair/", ".glb",
["short_a", "short_b", "medium_a", "medium_b", "long_a", "bald"])
for hid in hair_ids:
_cached_hair_ids = _scan_asset_ids("res://assets/characters/hair/", ".glb",
["bald", "bob", "buzzed", "long"])
for hid in _cached_hair_ids:
var btn := _make_grid_item_btn(hid.replace("_", " ").capitalize(), Vector2(80, 64))
btn.pressed.connect(_on_hair_selected.bind(hid))
hair_grid.add_child(btn)
@@ -460,8 +482,6 @@ func _build_hair_tab(tab: Control) -> void:
func _on_hair_selected(hair_id: String) -> void:
_descriptor.hair_id = hair_id
if _hair_highlight_auto:
_descriptor.hair_tint = _descriptor.hair_tint # keep primary; highlight derived on refresh
_update_hair_btns()
_update_hair_color_dock()
_refresh_preview()
@@ -509,8 +529,9 @@ func _build_hair_color_dock() -> Control:
func(c): _on_hair_primary_changed(c))
row.add_child(_hair_primary_swatch)
_hair_highlight_swatch = _make_color_swatch(_derive_hair_highlight(_descriptor.hair_tint),
"Highlight ●", func(c): _on_hair_highlight_changed(c))
# Highlight is auto-derived from primary — display only, no click handler
_hair_highlight_swatch = _make_display_swatch(_derive_hair_highlight(_descriptor.hair_tint),
"Highlight ●")
row.add_child(_hair_highlight_swatch)
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
@@ -528,8 +549,7 @@ func _update_hair_color_dock() -> void:
if not _hair_primary_swatch:
return
_set_swatch_color(_hair_primary_swatch, _descriptor.hair_tint)
var highlight := _derive_hair_highlight(_descriptor.hair_tint) if _hair_highlight_auto else _descriptor.hair_tint
_set_swatch_color(_hair_highlight_swatch, highlight)
_set_swatch_color(_hair_highlight_swatch, _derive_hair_highlight(_descriptor.hair_tint))
var eb_tint := _descriptor.hair_tint if _eyebrow_tint_auto else _descriptor.eyebrow_tint
_set_swatch_color(_eyebrow_tint_swatch, eb_tint)
var fh_tint := _descriptor.hair_tint if _facial_hair_tint_auto else _descriptor.facial_hair_tint
@@ -538,8 +558,6 @@ func _update_hair_color_dock() -> void:
func _on_hair_primary_changed(color: Color) -> void:
_descriptor.hair_tint = color
if _hair_highlight_auto:
_descriptor.hair_tint = color # highlight derived at render time
if _eyebrow_tint_auto:
_descriptor.eyebrow_tint = color
if _facial_hair_tint_auto:
@@ -548,14 +566,6 @@ func _on_hair_primary_changed(color: Color) -> void:
_refresh_preview()
func _on_hair_highlight_changed(color: Color) -> void:
_hair_highlight_auto = false
# Hair has a single tint in the descriptor — highlight is auto-derived
# When overriding, we store via hair_tint direction (compositor uses one tint)
_update_hair_color_dock()
_refresh_preview()
func _on_eyebrow_tint_changed(color: Color) -> void:
_eyebrow_tint_auto = false
_descriptor.eyebrow_tint = color
@@ -625,14 +635,14 @@ func _build_clothing_tab(tab: Control) -> void:
_clothing_item_btns[slot][item_id] = btn
# Color dock (per slot, rebuilt on slot change)
var dock_container := Control.new()
dock_container.name = "ClothingColorDock"
dock_container.custom_minimum_size = Vector2(0, 64)
dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(dock_container)
_clothing_dock_container = Control.new()
_clothing_dock_container.name = "ClothingColorDock"
_clothing_dock_container.custom_minimum_size = Vector2(0, 64)
_clothing_dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(_clothing_dock_container)
# _tab_grids[3] left null: clothing uses per-slot grids (search filters active slot via _clothing_grids)
_rebuild_clothing_color_dock(dock_container)
_rebuild_clothing_color_dock(_clothing_dock_container)
_update_clothing_slot_btns()
_update_clothing_item_btns()
@@ -644,9 +654,8 @@ func _on_clothing_slot_selected(slot: String) -> void:
_update_clothing_slot_btns()
# Rebuild color dock for newly selected slot
var dock_container: Control = _tab_container.get_child(3).get_child(0).get_node_or_null("ClothingColorDock")
if dock_container:
_rebuild_clothing_color_dock(dock_container)
if _clothing_dock_container:
_rebuild_clothing_color_dock(_clothing_dock_container)
func _rebuild_clothing_color_dock(container: Control) -> void:
@@ -689,9 +698,8 @@ func _on_clothing_item_selected(slot: String, item_id: String) -> void:
_update_clothing_item_btns()
# Rebuild color dock to show new item's defaults
var dock_container: Control = _tab_container.get_child(3).get_child(0).get_node_or_null("ClothingColorDock")
if dock_container:
_rebuild_clothing_color_dock(dock_container)
if _clothing_dock_container:
_rebuild_clothing_color_dock(_clothing_dock_container)
_refresh_preview()
@@ -799,14 +807,14 @@ func _build_accessories_tab(tab: Control) -> void:
grid.add_child(btn)
_accessory_item_btns[slot][item_id] = btn
var dock_container := Control.new()
dock_container.name = "AccessoryColorDock"
dock_container.custom_minimum_size = Vector2(0, 56)
dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(dock_container)
_accessory_dock_container = Control.new()
_accessory_dock_container.name = "AccessoryColorDock"
_accessory_dock_container.custom_minimum_size = Vector2(0, 56)
_accessory_dock_container.size_flags_horizontal = Control.SIZE_FILL
vbox.add_child(_accessory_dock_container)
# _tab_grids[4] left null: accessories uses per-slot grids (search filters active slot via _accessory_grids)
_rebuild_accessory_color_dock(dock_container)
_rebuild_accessory_color_dock(_accessory_dock_container)
_update_accessory_slot_btns()
_update_accessory_item_btns()
@@ -816,9 +824,8 @@ func _on_accessory_slot_selected(slot: String) -> void:
for s in _accessory_grids:
_accessory_grids[s].visible = (s == slot)
_update_accessory_slot_btns()
var dock_container: Control = _tab_container.get_child(4).get_child(0).get_node_or_null("AccessoryColorDock")
if dock_container:
_rebuild_accessory_color_dock(dock_container)
if _accessory_dock_container:
_rebuild_accessory_color_dock(_accessory_dock_container)
func _rebuild_accessory_color_dock(container: Control) -> void:
@@ -851,9 +858,8 @@ func _on_accessory_item_selected(slot: String, item_id: String) -> void:
_descriptor.accessory_tints[item_id] = [Color.WHITE] # Array[Color]: Primary only to start
_accessory_secondary_auto[slot] = true
_update_accessory_item_btns()
var dock_container: Control = _tab_container.get_child(4).get_child(0).get_node_or_null("AccessoryColorDock")
if dock_container:
_rebuild_accessory_color_dock(dock_container)
if _accessory_dock_container:
_rebuild_accessory_color_dock(_accessory_dock_container)
_refresh_preview()
@@ -934,26 +940,13 @@ func _build_color_picker_modal() -> void:
box.add_child(palette_grid)
_modal_swatch_btns.clear()
# 5 chromatic rows
for row_i in PALETTE_HUES.size():
var h_deg := PALETTE_HUES[row_i]
var s := PALETTE_SAT[row_i]
for col_i in PALETTE_COLS:
# Vary lightness across the row (0.15 → 0.85)
var v := 0.15 + col_i * (0.7 / (PALETTE_COLS - 1))
var color := Color.from_hsv(h_deg / 360.0, s, v)
var swatch := _make_palette_swatch(color)
# 6 rows × 9 cols per D-165 (5 chromatic + 1 neutral gray)
for row_hexes: Array in PALETTE_COLORS:
for hex: String in row_hexes:
var swatch := _make_palette_swatch(Color(hex))
palette_grid.add_child(swatch)
_modal_swatch_btns.append(swatch)
# Neutral gray row
for col_i in PALETTE_COLS:
var v := float(col_i) / (PALETTE_COLS - 1)
var color := Color(v, v, v)
var swatch := _make_palette_swatch(color)
palette_grid.add_child(swatch)
_modal_swatch_btns.append(swatch)
# Hex input row
var hex_row := HBoxContainer.new()
hex_row.add_theme_constant_override("separation", 4)
@@ -1068,10 +1061,7 @@ func _on_modal_cancel() -> void:
func _on_modal_ok() -> void:
# Add current preview color to recent list
if _modal_preview_swatch:
var swatch_rect := _modal_preview_swatch.get_child(0) as ColorRect
if swatch_rect:
var final_color := swatch_rect.color
_push_recent_color(final_color)
_push_recent_color(_get_swatch_color(_modal_preview_swatch))
_modal_root.visible = false
@@ -1165,11 +1155,11 @@ func _on_randomise() -> void:
_descriptor.body_type = all_types[randi() % all_types.size()]
_descriptor.skin_tone = randi() % CharacterVisual.SKIN_TONES.size()
# Pick random hair from available
var hair_ids := _scan_asset_ids("res://assets/characters/hair/", ".glb",
["short_a", "short_b", "medium_a", "medium_b", "long_a", "bald"])
if not hair_ids.is_empty():
_descriptor.hair_id = hair_ids[randi() % hair_ids.size()]
# Pick random head and hair from cached scan results (populated during tab build)
if not _cached_head_ids.is_empty():
_descriptor.head_id = _cached_head_ids[randi() % _cached_head_ids.size()]
if not _cached_hair_ids.is_empty():
_descriptor.hair_id = _cached_hair_ids[randi() % _cached_hair_ids.size()]
_descriptor.hair_tint = Color.from_hsv(randf(), 0.4 + randf() * 0.3, 0.4 + randf() * 0.4)
# Random facial hair and eyebrows
@@ -1177,7 +1167,6 @@ func _on_randomise() -> void:
_descriptor.eyebrow_id = EYEBROW_OPTIONS[randi() % EYEBROW_OPTIONS.size()]
# Sync auto tints
_hair_highlight_auto = true
_eyebrow_tint_auto = true
_facial_hair_tint_auto = true
_descriptor.eyebrow_tint = _descriptor.hair_tint
@@ -1240,6 +1229,18 @@ func _update_skin_tone_btns() -> void:
# Shared helpers — color swatches
# =============================================================================
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
func _get_swatch_color(btn: Button) -> Color:
for child in btn.get_children():
if child is VBoxContainer:
for sub in (child as VBoxContainer).get_children():
if sub is ColorRect:
return (sub as ColorRect).color
if child is ColorRect:
return (child as ColorRect).color
return Color.WHITE
func _make_color_swatch(color: Color, label_text: String, callback: Callable) -> Button:
var btn := Button.new()
btn.custom_minimum_size = Vector2(56, 44)
@@ -1266,12 +1267,45 @@ func _make_color_swatch(color: Color, label_text: String, callback: Callable) ->
lbl.clip_text = true
vbox.add_child(lbl)
# Use _get_swatch_color to read current color at click time (avoids stale closure).
btn.pressed.connect(func():
_open_color_picker(color, btn, callback)
_open_color_picker(_get_swatch_color(btn), btn, callback)
)
return btn
## Build a non-interactive display swatch (auto-derived values — no click handler).
func _make_display_swatch(color: Color, label_text: String) -> Button:
var btn := Button.new()
btn.custom_minimum_size = Vector2(56, 44)
btn.flat = false
btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.focus_mode = Control.FOCUS_NONE
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 2)
btn.add_child(vbox)
var swatch_rect := ColorRect.new()
swatch_rect.color = color
swatch_rect.custom_minimum_size = Vector2(0, 24)
swatch_rect.size_flags_horizontal = Control.SIZE_FILL
swatch_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(swatch_rect)
if not label_text.is_empty():
var lbl := Label.new()
lbl.text = label_text.replace("", "")
lbl.add_theme_font_size_override("font_size", 9)
lbl.add_theme_color_override("font_color", TEXT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.clip_text = true
vbox.add_child(lbl)
return btn
func _set_swatch_color(btn: Button, color: Color) -> void:
if not btn:
return
@@ -1424,9 +1458,9 @@ static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> A
func _get_clothing_ids_for_slot(slot: String) -> Array:
# Clothing items are directories: clothing/{item_id}/{body_type}.glb
# Slot assignment uses item name prefixes (convention, not a metadata field).
var all_ids := _scan_subdirs("res://assets/characters/clothing/",
["coveralls_basic", "jacket_basic", "shirt_basic", "pants_basic", "boots_basic", "gloves_basic"])
# TODO: filter by slot once coverage.json includes a "slot" field. For now return all.
match slot:
"torso": return all_ids.filter(func(id: String) -> bool: return id.begins_with("coverall") or id.begins_with("jacket") or id.begins_with("shirt") or id.begins_with("dress") or id.begins_with("uniform"))
"legs": return all_ids.filter(func(id: String) -> bool: return id.begins_with("pants") or id.begins_with("skirt"))
@@ -1436,7 +1470,19 @@ func _get_clothing_ids_for_slot(slot: String) -> Array:
func _get_accessory_ids_for_slot(slot: String) -> Array:
return _scan_subdirs("res://assets/characters/accessories/", [])
# Accessory items are directories: accessories/{item_id}/{body_type}.glb
# Slot assignment uses item name prefixes (convention, not a metadata field).
var all_ids := _scan_subdirs("res://assets/characters/accessories/", [])
match slot:
"hat": return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
"goggles": return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
"mask": return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
"backpack": return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
"belt": return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
_: return all_ids
# =============================================================================