New implant app (app_path implant/browser, key B, fullscreen), sibling
to the Atlas per Jeroen's IA ruling: kind picker -> generic filterable
index (live search-mode typing) -> generic detail, parameterized per
kind, composed entirely from D-169 components. available_in_companion
left unset (default true) — the app appears in the companion shell
automatically via the generic-host seam, zero companion-side wiring.
browser_adapter.gd is the sole home of literal wire field names: maps
Oscar's BrowseResponse contract ({id, primary, secondary} index rows;
BrowseDetail enum-as-single-key-map) to view models for all six kinds,
folding join partners (system economy/factions/culture, corporation
presence, commodity production chains with nested Leontief inputs).
browse_protocol.gd split out of protocol.gd (max-file-lines);
sim_bridge gains browse_response_received + request_browse_index/detail.
Live-data catch: a present-but-NULL key (unnamed asteroid belt
proper_name) bypasses Dictionary.get fallbacks and rendered '<null>' —
_display_or() null-vs-absent helper applied across all six detail
mappers, 4 regression tests distinct from the absent-key cases.
43 gdUnit adapter cases; full suite 3074 green. Live-verified against
a real server + real systems.db: all six kinds Ready with real row
counts (301/3240/466/165/36/28), detail drill-down, NotFound on bogus
ids. Spawn-mode DB resolution issue found during verification is
pre-existing (cwd-relative data/systems.db) — server-side fix follows
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
439 lines
20 KiB
GDScript
439 lines
20 KiB
GDScript
class_name BrowserAdapter
|
|
## Pure translation layer between the wire BrowseRequest/BrowseResponse
|
|
## contract (T-1131, Oscar, D-254 §4) and the view-models the browser's
|
|
## screens render (T-1133).
|
|
##
|
|
## WHY THIS EXISTS: Oscar's contract names its own field-naming churn risk
|
|
## up front (Body's filter field renaming mid-implementation; per-kind
|
|
## detail field names "TBD" at contract-send time). Every other browser file
|
|
## reads through this adapter's accessor functions instead of touching a
|
|
## raw response Dictionary's keys directly — so a field rename on the wire
|
|
## is a one-line change here, never a screen rewrite. This is the ONE file
|
|
## that is allowed to know the literal wire key names.
|
|
##
|
|
## Kept as a static-function-only class (no state, no scene tree) so it is
|
|
## gdUnit-testable with plain Dictionary literals standing in for decoded
|
|
## msgpack responses — no live server, no SimBridge, no protocol.gd
|
|
## round-trip required to exercise the mapping logic.
|
|
|
|
## The six entity kinds, in the exact order the kind-menu screen displays
|
|
## them (D-254 §4's v1 list order). Wire values are the bare-string unit
|
|
## variant names BrowseEntityKind serializes to (Protocol._decode_status_field
|
|
## doc: unit variants are bare strings on the wire).
|
|
const KIND_STAR_SYSTEM: String = "StarSystem"
|
|
const KIND_BODY: String = "Body"
|
|
const KIND_STATION: String = "Station"
|
|
const KIND_CORPORATION: String = "Corporation"
|
|
const KIND_COMMODITY: String = "Commodity"
|
|
const KIND_TRAIT_TEMPLATE: String = "TraitTemplate"
|
|
|
|
const KIND_ORDER: Array[String] = [
|
|
KIND_STAR_SYSTEM,
|
|
KIND_BODY,
|
|
KIND_STATION,
|
|
KIND_CORPORATION,
|
|
KIND_COMMODITY,
|
|
KIND_TRAIT_TEMPLATE,
|
|
]
|
|
|
|
## Display label per kind, for the kind-menu screen and detail/index headers.
|
|
const KIND_LABELS: Dictionary = {
|
|
KIND_STAR_SYSTEM: "STAR SYSTEMS",
|
|
KIND_BODY: "BODIES",
|
|
KIND_STATION: "STATIONS",
|
|
KIND_CORPORATION: "CORPORATIONS",
|
|
KIND_COMMODITY: "COMMODITIES",
|
|
KIND_TRAIT_TEMPLATE: "TRAIT CATALOG",
|
|
}
|
|
|
|
|
|
## True iff kind is one of the six v1 entity kinds (defends against a typo'd
|
|
## kind string reaching a request encode — better to fail loud client-side
|
|
## than send a request the server's kind enum can't decode).
|
|
static func is_valid_kind(kind: String) -> bool:
|
|
return kind in KIND_ORDER
|
|
|
|
|
|
static func kind_label(kind: String) -> String:
|
|
return KIND_LABELS.get(kind, kind)
|
|
|
|
|
|
# =============================================================================
|
|
# Index rows
|
|
# =============================================================================
|
|
|
|
|
|
## One row-view-model: {id, primary, secondary}. secondary is "" (not null)
|
|
## when the wire response carried no secondary column, so callers can always
|
|
## treat it as a plain String without a null check.
|
|
static func _row_from_raw(raw: Dictionary) -> Dictionary:
|
|
return {
|
|
"id": str(raw.get("id", "")),
|
|
"primary": str(raw.get("primary", "")),
|
|
"secondary": str(raw.get("secondary", "")) if raw.get("secondary") != null else "",
|
|
}
|
|
|
|
|
|
## Extract the index row list from a decoded BrowseResponse. Returns [] for
|
|
## any non-Ready status or a response with no "index" field (e.g. a Detail
|
|
## response) rather than erroring — callers check response status themselves
|
|
## via response_status()/response_error() before trusting an empty list as
|
|
## "genuinely no rows".
|
|
static func index_rows(response: Dictionary) -> Array:
|
|
if response.get("status", "") != "Ready":
|
|
return []
|
|
var raw_rows: Variant = response.get("index")
|
|
if not raw_rows is Array:
|
|
return []
|
|
var rows: Array = []
|
|
for raw: Dictionary in raw_rows:
|
|
rows.append(_row_from_raw(raw))
|
|
return rows
|
|
|
|
|
|
## Case-insensitive substring filter over primary/secondary/id — the index
|
|
## screen's search box. Empty query returns rows unchanged (identity, not a
|
|
## copy — callers must not mutate the result in place).
|
|
static func filter_rows(rows: Array, query: String) -> Array:
|
|
if query.is_empty():
|
|
return rows
|
|
var needle := query.to_lower()
|
|
var out: Array = []
|
|
for row: Dictionary in rows:
|
|
var haystack: String = (
|
|
str(row.get("primary", "")) + " " + str(row.get("secondary", "")) + " "
|
|
+ str(row.get("id", ""))
|
|
).to_lower()
|
|
if haystack.contains(needle):
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
# =============================================================================
|
|
# Detail — per-kind field extraction
|
|
# =============================================================================
|
|
|
|
|
|
## Status of a decoded BrowseResponse: "Ready" | "NotFound" | "Error" | "".
|
|
## Empty string means the Dictionary isn't a recognizable BrowseResponse at
|
|
## all (e.g. null, or a decode failure upstream).
|
|
static func response_status(response: Variant) -> String:
|
|
if not response is Dictionary:
|
|
return ""
|
|
return str(response.get("status", ""))
|
|
|
|
|
|
## Error message for an Error-status response; "" otherwise (including for
|
|
## Ready/NotFound, where there is nothing to show).
|
|
static func response_error(response: Dictionary) -> String:
|
|
if response.get("status", "") != "Error":
|
|
return ""
|
|
return str(response.get("error", ""))
|
|
|
|
|
|
## The BrowseDetail payload for a Ready Detail response, or {} if the
|
|
## response is any other shape. This is the ONE enum-variant-as-single-key-map
|
|
## unwrap in the adapter — BrowseDetail is an externally-tagged Rust enum
|
|
## (StarSystem(...)|Body(...)|...), so the decoded map has exactly one key
|
|
## matching the response's own "kind" field, and the value under that key is
|
|
## the per-kind detail Dictionary every _detail_rows_for_* function reads.
|
|
static func detail_payload(response: Dictionary) -> Dictionary:
|
|
if response.get("status", "") != "Ready":
|
|
return {}
|
|
var raw_detail: Variant = response.get("detail")
|
|
if not raw_detail is Dictionary:
|
|
return {}
|
|
var kind: String = str(response.get("kind", ""))
|
|
if raw_detail.has(kind) and raw_detail[kind] is Dictionary:
|
|
return raw_detail[kind]
|
|
# Defensive fallback: some server-side encodings of a single-variant enum
|
|
# collapse to the inner map directly rather than wrapping it under the
|
|
# variant name (observed with other unit-vs-struct enums in this
|
|
# codebase's wire contracts, e.g. AtlasLayerStatus's Error(String) case).
|
|
# If the "kind"-keyed lookup above misses, and the map has no OTHER
|
|
# single-key wrapper shape either, assume it's already the unwrapped
|
|
# detail and return it as-is rather than silently rendering a blank
|
|
# detail screen.
|
|
if raw_detail.size() == 1 and not raw_detail.has(kind):
|
|
return raw_detail.values()[0]
|
|
return raw_detail
|
|
|
|
|
|
## header (name, subtitle) + Array[{label, value}] rows for a detail
|
|
## payload, dispatched by kind. Kept as one dispatcher rather than one method
|
|
## per kind on the caller side, so DetailScreen only ever calls one function
|
|
## regardless of which of the six kinds it's showing.
|
|
static func detail_view(kind: String, payload: Dictionary) -> Dictionary:
|
|
match kind:
|
|
BrowserAdapter.KIND_STAR_SYSTEM:
|
|
return _detail_star_system(payload)
|
|
BrowserAdapter.KIND_BODY:
|
|
return _detail_body(payload)
|
|
BrowserAdapter.KIND_STATION:
|
|
return _detail_station(payload)
|
|
BrowserAdapter.KIND_CORPORATION:
|
|
return _detail_corporation(payload)
|
|
BrowserAdapter.KIND_COMMODITY:
|
|
return _detail_commodity(payload)
|
|
BrowserAdapter.KIND_TRAIT_TEMPLATE:
|
|
return _detail_trait_template(payload)
|
|
_:
|
|
return {"name": "", "subtitle": "", "rows": []}
|
|
|
|
|
|
static func _row(label: String, value: Variant) -> Dictionary:
|
|
return {"label": label, "value": str(value) if value != null else "—"}
|
|
|
|
|
|
static func _bool_str(v: Variant) -> String:
|
|
return "yes" if bool(v) else "no"
|
|
|
|
|
|
## Dictionary.get(key, fallback)'s fallback only fires when key is ABSENT —
|
|
## a present key with a NULL value (a real, common shape here: msgpack
|
|
## encodes a SQL NULL column as null, and rusqlite's Option<T> => None keeps
|
|
## the key in the map) returns that null unchanged, and str(null) renders as
|
|
## the literal string "<null>" — a real bug that surfaced live (a Body row
|
|
## for an unnamed asteroid belt: proper_name is a present NULL column, so
|
|
## the name line rendered "<null>" instead of falling back to body_id).
|
|
## Every _detail_*'s "name" line goes through this instead of a bare
|
|
## p.get(primary_key, p.get(fallback_key, "—")) for exactly this reason —
|
|
## the per-field row values are already safe via _row()'s own null check
|
|
## above; this is the ONE other place a display string is built directly
|
|
## from a wire value without going through _row().
|
|
static func _display_or(p: Dictionary, key: String, fallback: String) -> String:
|
|
var v: Variant = p.get(key)
|
|
if v == null:
|
|
return fallback
|
|
return str(v)
|
|
|
|
|
|
## "distance" is the one row that appends a unit suffix instead of a bare
|
|
## _row(label, value) call (str(p.get("dist_ly", "—")) + " ly" would render
|
|
## "<null> ly" for a NULL column via the same bug _display_or() fixes above,
|
|
## since concatenating a suffix happens before _row()'s own null guard would
|
|
## ever see the value) — a dedicated helper keeps the "— ly" placeholder
|
|
## intact for a genuinely-NULL dist_ly (Sol's own row: distance from Earth to
|
|
## itself is meaningless, not zero, so a NULL/omitted dist_ly is expected
|
|
## real data here, not an error condition).
|
|
static func _distance_ly_str(dist_ly: Variant) -> String:
|
|
if dist_ly == null:
|
|
return "—"
|
|
return str(dist_ly) + " ly"
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
|
## StarSystemDetail folds system_economy/system_factions/system_culture.
|
|
static func _detail_star_system(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "proper_name", _display_or(p, "system_id", "—"))
|
|
var rows: Array = [
|
|
_row("system id", p.get("system_id", "—")),
|
|
_row("star type", p.get("star_type", "—")),
|
|
_row("spectral class", p.get("spectral_class", "—")),
|
|
_row("distance", _distance_ly_str(p.get("dist_ly"))),
|
|
_row("sector", p.get("geographic_sector", "—")),
|
|
_row("geographic band", p.get("geographic_band", "—")),
|
|
_row("political zone", p.get("political_zone", "—")),
|
|
_row("habitable planets", p.get("habitable_planet_count", "—")),
|
|
_row("inhabited planets", p.get("inhabited_planet_count", "—")),
|
|
_row("asteroid belt", _bool_str(p.get("asteroid_belt", false))),
|
|
_row("gas giant", _bool_str(p.get("gas_giant", false))),
|
|
_row("habitability profile", p.get("habitability_profile", "—")),
|
|
_row("earth alignment", p.get("earth_alignment", "—")),
|
|
_row("earth proximity", p.get("earth_proximity", "—")),
|
|
_row("earth tension", p.get("earth_tension", "—")),
|
|
_row("stability index", p.get("stability_index", "—")),
|
|
_row("volatility", p.get("system_volatility", "—")),
|
|
_row("cultural corridor", p.get("cultural_corridor", "—")),
|
|
_row("currency zone", p.get("currency_zone", "—")),
|
|
_row("economic tier", p.get("economic_tier", "—")),
|
|
_row("population", p.get("population", "—")),
|
|
_row("economic base", p.get("economic_base_primary", "—")),
|
|
_row("economic base (secondary)", p.get("economic_base_secondary", "—")),
|
|
_row("governance", p.get("governance_type", "—")),
|
|
_row("dominant faction", p.get("dominant_faction", "—")),
|
|
_row("cultural register", p.get("cultural_register", "—")),
|
|
_row("atmospheric tone", p.get("atmospheric_tone", "—")),
|
|
_row("primary archetype", p.get("primary_archetype", "—")),
|
|
]
|
|
return {"name": name, "subtitle": "STAR SYSTEM", "rows": rows}
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
|
## `bodies` row, no folded child tables.
|
|
static func _detail_body(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "proper_name", _display_or(p, "body_id", "—"))
|
|
var rows: Array = [
|
|
_row("body id", p.get("body_id", "—")),
|
|
_row("system", p.get("system_id", "—")),
|
|
_row("parent body", p.get("parent_body_id", "—")),
|
|
_row("type", p.get("body_type", "—")),
|
|
_row("orbit index", p.get("orbit_index", "—")),
|
|
_row("mass class", p.get("mass_class", "—")),
|
|
_row("atmosphere", p.get("atmosphere", "—")),
|
|
_row("surface gravity (g)", p.get("surface_gravity", "—")),
|
|
_row("orbital period (days)", p.get("orbital_period_days", "—")),
|
|
_row("rotation period (hrs)", p.get("rotation_period_hours", "—")),
|
|
_row("planet class", p.get("planet_class", "—")),
|
|
_row("hydrosphere", p.get("hydrosphere", "—")),
|
|
_row("biosphere class", p.get("biosphere_class", "—")),
|
|
_row("inhabited", _bool_str(p.get("inhabited", false))),
|
|
_row("population", p.get("population", "—")),
|
|
_row("economic role", p.get("economic_role", "—")),
|
|
_row("founding age (years)", p.get("founding_age_years", "—")),
|
|
_row("settlement pattern", p.get("settlement_pattern", "—")),
|
|
_row("cultural corridor", p.get("cultural_corridor", "—")),
|
|
_row("industrial corridor", p.get("industrial_corridor", "—")),
|
|
_row("radius (km)", p.get("body_radius_km", "—")),
|
|
_row("axial tilt (deg)", p.get("axial_tilt_deg", "—")),
|
|
]
|
|
return {"name": name, "subtitle": "CELESTIAL BODY", "rows": rows}
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
|
## `stations` row, no folded child tables.
|
|
static func _detail_station(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "proper_name", _display_or(p, "station_id", "—"))
|
|
var rows: Array = [
|
|
_row("station id", p.get("station_id", "—")),
|
|
_row("system", p.get("system_id", "—")),
|
|
_row("orbits", p.get("orbits_body_id", "—")),
|
|
_row("type", p.get("station_type", "—")),
|
|
_row("population", p.get("population", "—")),
|
|
_row("economic role", p.get("economic_role", "—")),
|
|
_row("governance", p.get("governance_type", "—")),
|
|
_row("docking class", p.get("docking_class", "—")),
|
|
_row("gate infrastructure", _bool_str(p.get("has_gate_infrastructure", false))),
|
|
_row("districts", p.get("district_count", "—")),
|
|
]
|
|
return {"name": name, "subtitle": "STATION", "rows": rows}
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
|
## CorporationDetail folds corp_presence (as a presence array) + corp_financial_state.
|
|
static func _detail_corporation(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "proper_name", _display_or(p, "corp_id", "—"))
|
|
var rows: Array = [
|
|
_row("corp id", p.get("corp_id", "—")),
|
|
_row("type", p.get("corp_type", "—")),
|
|
_row("scope", p.get("scope", "—")),
|
|
_row("hq system", p.get("headquarters_system", "—")),
|
|
_row("hq body", p.get("headquarters_body", "—")),
|
|
_row("specialization", p.get("specialization", "—")),
|
|
_row("parent corp", p.get("parent_corp", "—")),
|
|
_row("notes", p.get("notes", "—")),
|
|
_row("behavioral archetype", p.get("behavioral_archetype", "—")),
|
|
_row("supply chain role", p.get("supply_chain_role", "—")),
|
|
_row("shadow economy access", _bool_str(p.get("shadow_economy_access", false))),
|
|
_row("corp specialization", p.get("corp_specialization", "—")),
|
|
_row("hq placement", p.get("hq_placement", "—")),
|
|
_row("health metric", p.get("health_metric", "—")),
|
|
]
|
|
return {
|
|
"name": name,
|
|
"subtitle": "CORPORATION",
|
|
"rows": rows,
|
|
"text": _presence_summary(p.get("presence", [])),
|
|
}
|
|
|
|
|
|
## presence: Array[{location_id, location_type, primary_operation}] ->
|
|
## one line per entry for the detail screen's ImplantTextBlock. Empty array
|
|
## (no presence rows) yields "" so the caller's has-text check stays a plain
|
|
## is_empty() rather than needing a special "no presence" sentinel.
|
|
static func _presence_summary(presence: Variant) -> String:
|
|
if not presence is Array or presence.is_empty():
|
|
return ""
|
|
var lines: Array = []
|
|
for entry: Dictionary in presence:
|
|
var loc: String = _display_or(entry, "location_id", "—")
|
|
var loc_type: String = _display_or(entry, "location_type", "")
|
|
var op: String = _display_or(entry, "primary_operation", "")
|
|
var line: String = loc
|
|
if not loc_type.is_empty():
|
|
line += " (" + loc_type + ")"
|
|
if not op.is_empty():
|
|
line += " — " + op
|
|
lines.append(line)
|
|
return "PRESENCE\n" + "\n".join(lines)
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) —
|
|
## CommodityDetail folds production_chains + chain_inputs (as produced_by).
|
|
static func _detail_commodity(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "name", _display_or(p, "commodity_id", "—"))
|
|
var rows: Array = [
|
|
_row("commodity id", p.get("commodity_id", "—")),
|
|
_row("tier", p.get("tier", "—")),
|
|
_row("elasticity", p.get("elasticity", "—")),
|
|
_row("base price", p.get("base_price", "—")),
|
|
_row("bulk class", p.get("bulk_class", "—")),
|
|
_row("unit", p.get("unit", "—")),
|
|
_row("production ubiquity", p.get("production_ubiquity", "—")),
|
|
_row("demand model", p.get("demand_model", "—")),
|
|
_row("commission certifiable", _bool_str(p.get("commission_certifiable", false))),
|
|
_row("compact contested", _bool_str(p.get("compact_contested", false))),
|
|
_row("shadow viable", _bool_str(p.get("shadow_viable", false))),
|
|
_row("panic threshold (weeks)", p.get("panic_threshold_weeks", "—")),
|
|
]
|
|
var text_parts: Array = []
|
|
var description: String = _display_or(p, "description", "")
|
|
if not description.is_empty():
|
|
text_parts.append(description)
|
|
var chains_summary: String = _production_chains_summary(p.get("produced_by", []))
|
|
if not chains_summary.is_empty():
|
|
text_parts.append(chains_summary)
|
|
return {"name": name, "subtitle": "COMMODITY", "rows": rows, "text": "\n\n".join(text_parts)}
|
|
|
|
|
|
## produced_by: Array[{chain_id, output_quantity, location_bound, description,
|
|
## inputs: [{input_commodity_id, quantity}]}] -> one summary line per chain,
|
|
## with its Leontief inputs inlined as "qty x input_id" pairs. Empty array ->
|
|
## "" (same no-sentinel convention as _presence_summary).
|
|
static func _production_chains_summary(chains: Variant) -> String:
|
|
if not chains is Array or chains.is_empty():
|
|
return ""
|
|
var lines: Array = []
|
|
for chain: Dictionary in chains:
|
|
var chain_id: String = _display_or(chain, "chain_id", "—")
|
|
var qty: String = _display_or(chain, "output_quantity", "—")
|
|
var raw_inputs: Variant = chain.get("inputs")
|
|
var inputs: Array = raw_inputs if raw_inputs is Array else []
|
|
var input_strs: Array = []
|
|
for entry: Dictionary in inputs:
|
|
var input_qty: String = _display_or(entry, "quantity", "—")
|
|
var input_id: String = _display_or(entry, "input_commodity_id", "—")
|
|
input_strs.append(input_qty + "x " + input_id)
|
|
var line: String = "%s (qty %s)" % [chain_id, qty]
|
|
if not input_strs.is_empty():
|
|
line += " <- " + ", ".join(input_strs)
|
|
lines.append(line)
|
|
return "PRODUCTION CHAINS\n" + "\n".join(lines)
|
|
|
|
|
|
## Field names confirmed against Oscar's T-1131 message (2026-07-17) — bare
|
|
## `trait_templates` row. weight_mods/zone_affinity/allow_tags/block_tags/
|
|
## visual_bundle are JSON-text columns the server does NOT parse (Oscar's
|
|
## contract: "opaque strings, not parsed server-side") — shown here as raw
|
|
## JSON text rows rather than silently dropped; a data browser's job is to
|
|
## expose the registry as it actually is, not to pretty-print it.
|
|
static func _detail_trait_template(p: Dictionary) -> Dictionary:
|
|
var name: String = _display_or(p, "label", _display_or(p, "tag", "—"))
|
|
var rows: Array = [
|
|
_row("tag", p.get("tag", "—")),
|
|
_row("corridor pool", p.get("corridor_pool", "—")),
|
|
_row("geographic sector", p.get("geographic_sector", "—")),
|
|
_row("bulk class gate", p.get("bulk_class_gate", "—")),
|
|
_row("production ubiquity gate", p.get("production_ubiquity_gate", "—")),
|
|
_row("min prosperity (bps)", p.get("min_prosperity_bps", "—")),
|
|
_row("base weight (bps)", p.get("base_weight", "—")),
|
|
_row("weight mods", p.get("weight_mods", "—")),
|
|
_row("zone affinity", p.get("zone_affinity", "—")),
|
|
_row("allow tags", p.get("allow_tags", "—")),
|
|
_row("block tags", p.get("block_tags", "—")),
|
|
_row("era scope", p.get("era_scope", "—")),
|
|
_row("visual bundle", p.get("visual_bundle", "—")),
|
|
]
|
|
var text_block: String = _display_or(p, "cultural_description", "")
|
|
return {"name": name, "subtitle": "TRAIT TEMPLATE", "rows": rows, "text": text_block}
|