Merge feature/main-window-shell into dev

M3-b — Main Window shell (2a): the exploration HUD frame every screen
lives in. Two-panel tabletop split — dark world slot (turn rail, minimap,
slide-in system dock, command bar) + the permanent DM narration book wired
to the proven /dm/narrate loop (prose-only + refire, considering-state,
fallback, fact-harvest). Thin tested ShellState; suite 138/138.

Establishes the editor-first UI-scene convention (ADR 0001): screens are
authored as .tscn node trees, scripts do on-load work only. Theme gained
Button-base DockButton/ParchmentButton + a base RichTextLabel prose style.
This commit is contained in:
2026-07-11 10:06:18 -05:00
31 changed files with 2300 additions and 55 deletions

View File

@@ -494,7 +494,13 @@ quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
plus a `README.md` handoff. **Not production code** — each screen is recreated as
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
(the README's palette, three fonts, and reusable styleboxes keep the look one
system). Each `.dc.html`'s `Component` class is the **behavior spec** — the
system). **Author these scenes editor-first:** the layout lives in the `.tscn`
as real Control nodes with the `Theme` and type-variations set in the editor, so
the scene *previews* and can be arranged in the Godot editor — do **not**
build the node tree in `_ready()`. Scripts hold only on-load work: `@onready`
node refs, data/DM wiring, animations, and binding state into fixed authored
nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each
`.dc.html`'s `Component` class is the **behavior spec** — the
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
highest-value logic to port; the seed-data maps (items, quests, locations, units,
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real

View File

@@ -1,12 +1,13 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_cu4xe"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"]
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eckwq"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i0b23"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -22,7 +23,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -38,7 +39,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -54,7 +55,7 @@ corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -70,7 +71,57 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.41960785, 0.38039216, 0.32156864, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -86,7 +137,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -103,7 +154,73 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
content_margin_bottom = 6.0
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -119,7 +236,7 @@ corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -135,7 +252,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -151,7 +268,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -167,7 +284,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -183,7 +300,7 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -199,9 +316,9 @@ corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jly1r"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -217,7 +334,7 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -233,7 +350,7 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -249,9 +366,9 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -267,7 +384,7 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -283,7 +400,7 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
content_margin_left = 12.0
content_margin_top = 6.0
content_margin_right = 12.0
@@ -300,54 +417,72 @@ corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[resource]
default_font = ExtResource("3_4lwr2")
default_font = ExtResource("3_eckwq")
default_font_size = 18
Accent/base_type = &"Label"
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
Accent/font_sizes/font_size = 24
Accent/fonts/font = ExtResource("1_cu4xe")
Accent/fonts/font = ExtResource("1_605w4")
Chip/base_type = &"Button"
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Chip/font_sizes/font_size = 11
Chip/fonts/font = ExtResource("2_605w4")
Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq")
Chip/styles/hover = SubResource("StyleBoxFlat_i0b23")
Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn")
Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk")
Chip/fonts/font = ExtResource("2_4lwr2")
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
DarkPanel/base_type = &"PanelContainer"
DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc")
DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr")
DockButton/base_type = &"Button"
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r")
DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq")
DockButton/styles/normal = SubResource("StyleBoxFlat_4kten")
DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm")
Heading/base_type = &"Label"
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
Heading/font_sizes/font_size = 34
Heading/fonts/font = ExtResource("3_4lwr2")
Heading/fonts/font = ExtResource("3_eckwq")
ItemTile/base_type = &"PanelContainer"
ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme")
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
ItemTileEmpty/base_type = &"PanelContainer"
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr")
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
Mono/base_type = &"Label"
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
Mono/font_sizes/font_size = 13
Mono/fonts/font = ExtResource("2_605w4")
Mono/fonts/font = ExtResource("2_4lwr2")
ParchmentButton/base_type = &"Button"
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r")
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk")
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7")
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07")
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx")
ParchmentCard/base_type = &"PanelContainer"
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ube3r")
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
ParchmentInset/base_type = &"PanelContainer"
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq")
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
PrimaryCTA/base_type = &"Button"
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_4kten")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak")
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
RichTextLabel/font_sizes/normal_font_size = 20
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
RichTextLabel/fonts/normal_font = ExtResource("3_eckwq")
Tab/base_type = &"Button"
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r")
Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk")
Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7")
Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07")
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
TabActive/base_type = &"Button"
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx")
TabActive/styles/hover = SubResource("StyleBoxFlat_oi067")
TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj")
TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73")
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq")

View File

@@ -12,6 +12,49 @@ Put here:
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
## UI scene conventions (shell + all screens)
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
+ `scripts/ui/shell/*.gd`.
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the
scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation`
(from `ThemeKeys`) in the editor, so the scene previews themed. Instance
sub-screens (`PackedScene` ext_resource) rather than rebuilding them.
- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs,
signal/service wiring, animations, and binding state into the authored nodes
(label text, colours from `Palette`). Do **not** create nodes in `_ready()`.
- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough
turn-order tokens / consumable slots / etc. in the editor; the script fills the
first N from state and hides the rest. Previewable now; systems write them later.
- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080)
for editor preview, but **no full-rect anchors on the root** — a root Control run
via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size`
on `_ready` + a `size_changed` connection (the M3-a collapse lesson).
- **Button styling → Button-base variations only.** Never put a PanelContainer
variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/
pressed` styleboxes so the button renders unskinned. Use the Button-base
variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`).
- **`RichTextLabel` prose** reads its dark serif ink from the theme's base
`RichTextLabel` style (it does *not* inherit `default_font`/`default_color`).
Just add the `RichTextLabel` — don't override its colour per-node.
- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is
built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a
variation, edit the builder + `theme_keys.gd`, then regenerate:
`godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit
the `.tres` or put a hex literal in a script or `.tscn`.
- **Wiring the DM loop / injectable services.** Follow the shell: a `service`
var settable *between* `instantiate()` and `add_child()` lets tests inject a
`FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready`
builds the real `HttpTransport`-backed one when none was injected.
- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero,
bindings run); it cannot prove rendering. Every screen needs a **human F6
eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render
their texture only on an F6 run, not in the editor.
## Narrate live smoke (M2 client HTTP loop)
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`

View File

@@ -0,0 +1,300 @@
[gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"]
[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"]
[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"]
[node name="MainWindowShell" type="Control"]
layout_mode = 3
anchors_preset = 0
offset_right = 1920.0
offset_bottom = 1080.0
theme = ExtResource("2")
script = ExtResource("1")
[node name="Split" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/separation = 0
[node name="World" type="Control" parent="Split"]
layout_mode = 2
size_flags_horizontal = 3
clip_contents = true
[node name="Bay" parent="Split/World" instance=ExtResource("3")]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Caption" type="Label" parent="Split/World"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"Mono"
text = "ISOMETRIC WORLD VIEW"
[node name="TurnRail" type="PanelContainer" parent="Split/World"]
layout_mode = 1
anchors_preset = 0
offset_left = 28.0
offset_top = 28.0
offset_right = 300.0
offset_bottom = 128.0
theme_type_variation = &"DarkPanel"
[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="Title" type="Label" parent="Split/World/TurnRail/Col"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "TURN ORDER"
[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "EL"
[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "DW"
[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "HU"
[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "✕"
[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "—"
[node name="Minimap" type="PanelContainer" parent="Split/World"]
custom_minimum_size = Vector2(200, 200)
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -228.0
offset_top = 28.0
offset_right = -28.0
offset_bottom = 228.0
grow_horizontal = 0
theme_type_variation = &"DarkPanel"
[node name="Label" type="Label" parent="Split/World/Minimap"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 1
theme_type_variation = &"Mono"
text = "MINIMAP"
[node name="SystemDock" parent="Split/World" instance=ExtResource("4")]
layout_mode = 1
anchors_preset = 6
anchor_left = 1.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_left = -228.0
offset_top = -210.0
offset_right = -8.0
offset_bottom = 210.0
grow_horizontal = 0
grow_vertical = 2
[node name="CommandBar" type="HBoxContainer" parent="Split/World"]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 28.0
offset_top = -150.0
offset_right = -28.0
grow_vertical = 0
theme_override_constants/separation = 20
alignment = 1
[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"]
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
size_flags_vertical = 8
theme_override_constants/separation = 8
[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "HP 42 / 60"
[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "MP 18 / 30"
[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "◈ 1240 gp"
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 8
theme_override_constants/separation = 9
alignment = 1
[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Salve"
[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Draught"
[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Bomb"
[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Antidote"
[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Ration"
[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTile"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
text = "Torch"
[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTileEmpty"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
custom_minimum_size = Vector2(93, 62)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"ItemTileEmpty"
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"]
layout_mode = 2
horizontal_alignment = 1
vertical_alignment = 2
clip_text = true
theme_override_font_sizes/font_size = 11
theme_type_variation = &"Mono"
[node name="EndTurn" type="Button" parent="Split/World/CommandBar"]
custom_minimum_size = Vector2(140, 60)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PrimaryCTA"
text = "END TURN"
[node name="BookPanel" type="PanelContainer" parent="Split"]
custom_minimum_size = Vector2(740, 0)
layout_mode = 2
theme_type_variation = &"ParchmentCard"
[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")]
layout_mode = 2

View File

@@ -0,0 +1,85 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
[node name="NarrationBook" type="Control"]
layout_mode = 3
anchors_preset = 0
offset_right = 740.0
offset_bottom = 1080.0
theme = ExtResource("2")
script = ExtResource("1")
[node name="Margin" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 40
theme_override_constants/margin_top = 34
theme_override_constants/margin_right = 40
theme_override_constants/margin_bottom = 34
[node name="Col" type="VBoxContainer" parent="Margin"]
layout_mode = 2
theme_override_constants/separation = 18
[node name="Header" type="Label" parent="Margin/Col"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "ROUND 4 · THE LOWER WARD"
[node name="Prose" type="RichTextLabel" parent="Margin/Col"]
custom_minimum_size = Vector2(660, 320)
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter."
fit_content = true
[node name="DegradedTag" type="Label" parent="Margin/Col"]
visible = false
layout_mode = 2
theme_type_variation = &"Mono"
text = "THE MASTER'S VOICE COMES THIN"
[node name="Prompt" type="Label" parent="Margin/Col"]
layout_mode = 2
theme_type_variation = &"Accent"
text = "▸ Your turn. What do you do?"
[node name="Choice1" type="Button" parent="Margin/Col"]
layout_mode = 2
disabled = true
theme_type_variation = &"ParchmentButton"
text = "1 · Pay the fence double and keep your hand where he can see it"
alignment = 0
[node name="Choice2" type="Button" parent="Margin/Col"]
layout_mode = 2
disabled = true
theme_type_variation = &"ParchmentButton"
text = "2 · Draw your blade — he's closer than his guards"
alignment = 0
[node name="Choice3" type="Button" parent="Margin/Col"]
layout_mode = 2
disabled = true
theme_type_variation = &"ParchmentButton"
text = "3 · Lie — tell him the rest is coming with your dwarf"
alignment = 0
[node name="FreeText" type="Label" parent="Margin/Col"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "⌨ …or describe your own action"
[node name="Refire" type="Button" parent="Margin/Col"]
layout_mode = 2
theme_type_variation = &"ParchmentButton"
text = "▸ The Master continues…"
[node name="Status" type="Label" parent="Margin/Col"]
layout_mode = 2
theme_type_variation = &"Mono"

View File

@@ -0,0 +1,86 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
[node name="SystemDock" type="Control"]
layout_mode = 3
anchors_preset = 0
offset_right = 220.0
offset_bottom = 420.0
theme = ExtResource("2")
script = ExtResource("1")
[node name="Menu" type="VBoxContainer" parent="."]
layout_mode = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -216.0
offset_right = -36.0
offset_bottom = 316.0
grow_horizontal = 0
theme_override_constants/separation = 10
[node name="Inventory" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "I Inventory"
alignment = 0
metadata/screen_id = &"inventory"
[node name="Character" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "C Character"
alignment = 0
metadata/screen_id = &"character"
[node name="QuestLog" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "J Quest Log"
alignment = 0
metadata/screen_id = &"quest_log"
[node name="Spellbook" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "K Spellbook"
alignment = 0
metadata/screen_id = &"spellbook"
[node name="WorldMap" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "M World Map"
alignment = 0
metadata/screen_id = &"world_map"
[node name="Party" type="Button" parent="Menu"]
custom_minimum_size = Vector2(0, 42)
layout_mode = 2
theme_type_variation = &"DockButton"
text = "P Party"
alignment = 0
metadata/screen_id = &"party"
[node name="Handle" type="Button" parent="."]
custom_minimum_size = Vector2(26, 104)
layout_mode = 1
anchor_left = 1.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_left = -26.0
offset_top = -52.0
offset_right = 0.0
offset_bottom = 52.0
grow_horizontal = 0
grow_vertical = 2
theme_type_variation = &"TabActive"
text = ""

View File

@@ -16,6 +16,7 @@ extends SceneTree
const OUT := "res://assets/theme/game_theme.tres"
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
@@ -33,8 +34,11 @@ func _init() -> void:
static func build_theme() -> Theme:
var theme := Theme.new()
_fonts(theme)
_rich_text(theme)
_primary_cta(theme)
_tabs(theme)
_dock_button(theme)
_parchment_button(theme)
_chip(theme)
_tiles(theme)
_cards_and_panels(theme)
@@ -76,6 +80,18 @@ static func _fonts(theme: Theme) -> void:
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
static func _rich_text(theme: Theme) -> void:
# Base RichTextLabel styling — the narrative "prose" voice (mock README §Typography:
# Georgia/serif for DM/flavor body, italic for emphasis). RichTextLabel does NOT
# inherit default_font/default_color, so without this its text renders in the engine
# default (near-white) and is unreadable on parchment. Every parchment prose surface
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
static func _primary_cta(theme: Theme) -> void:
var k := ThemeKeys.PRIMARY_CTA
theme.set_type_variation(k, "Button")
@@ -106,6 +122,35 @@ static func _tabs(theme: Theme) -> void:
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
static func _dock_button(theme: Theme) -> void:
# Dark nav button for the slide-in system dock (mock 2a). Button-base so
# normal/hover/pressed styleboxes actually apply (a PanelContainer variation's
# lone `panel` stylebox does not skin a Button). Colours are dock-dark.
var k := ThemeKeys.DOCK_BUTTON
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8))
theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8))
theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY)
static func _parchment_button(theme: Theme) -> void:
# Clickable parchment row/card for the narration book (mock 2a response
# choices + the refire affordance). Button-base so normal/hover/pressed/
# disabled styleboxes actually apply (a PanelContainer variation's lone
# `panel` stylebox does not skin a Button). Colours are the mock's choice-card.
var k := ThemeKeys.PARCHMENT_BUTTON
theme.set_type_variation(k, "Button")
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8))
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8))
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8))
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8))
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
theme.set_color(&"font_color", k, Palette.INK_BODY)
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
static func _chip(theme: Theme) -> void:
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
var k := ThemeKeys.CHIP

View File

@@ -9,6 +9,8 @@ const PRIMARY_CTA := &"PrimaryCTA"
const TAB := &"Tab"
const TAB_ACTIVE := &"TabActive"
const CHIP := &"Chip"
const DOCK_BUTTON := &"DockButton"
const PARCHMENT_BUTTON := &"ParchmentButton"
const ITEM_TILE := &"ItemTile"
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
const PARCHMENT_CARD := &"ParchmentCard"
@@ -28,6 +30,8 @@ const ALL := {
TAB: "Button",
TAB_ACTIVE: "Button",
CHIP: "Button",
DOCK_BUTTON: "Button",
PARCHMENT_BUTTON: "Button",
ITEM_TILE: "PanelContainer",
ITEM_TILE_EMPTY: "PanelContainer",
PARCHMENT_CARD: "PanelContainer",

View File

@@ -0,0 +1,134 @@
class_name MainWindowShell
extends Control
## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel
## tabletop split: a dark isometric world slot (turn rail, minimap, system dock,
## command bar) + the permanent DM narration book. §2: the shell owns HUD state
## (ShellState) and consumes DM text (never owns narrative truth). It reuses the
## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented.
##
## Layout is authored in MainWindowShell.tscn (and its SystemDock/NarrationBook
## sub-scenes) — open it in the editor to arrange it. This script binds the seed
## ShellState into the authored nodes, wires the DM loop, and keeps the runtime
## viewport-fit. The turn-order tokens and consumable slots are a FIXED set of
## authored nodes bound from state (extra slots hide); combat/inventory become the
## writers later.
# HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every
# other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via
# preload, so this mirrors the existing pattern rather than inventing a new one.
const HttpTransport = preload("res://scripts/net/http_transport.gd")
var service: DmService = null # injection seam; real HttpTransport built if null
var _state: ShellState
var _log: CanonLog
var _http: HTTPRequest
@onready var book: NarrationBook = $Split/BookPanel/Book
@onready var dock: SystemDock = $Split/World/SystemDock
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
func _ready() -> void:
# The theme is assigned on the scene root in the editor (so it previews). Keep
# the runtime viewport-fit: a root Control run via F6 is not sized by full-rect
# anchors — the .tscn root uses a fixed size + no full-rect preset (M3-a bug).
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
_state = ShellState.seed()
_log = _build_seed_log()
_bind_state()
if service == null:
_http = HTTPRequest.new()
add_child(_http)
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
book.setup(service, _log, _state)
dock.setup(_state)
dock.screen_requested.connect(_on_screen_requested)
book.narrate() # initial narration; async, fire-and-forget
func _fit_to_viewport() -> void:
size = get_viewport_rect().size
func _bind_state() -> void:
_bind_turn_rail()
_bind_vitals()
_bind_consumables()
func _bind_turn_rail() -> void:
# Fill the authored token labels from the turn order; hide any spare slots.
var tokens := _tokens.get_children()
for i in range(tokens.size()):
var label: Label = tokens[i]
if i < _state.turn_order.size():
var e: TurnEntry = _state.turn_order[i]
label.text = "" if e.is_downed else e.initials
label.add_theme_color_override("font_color", _side_color(e))
label.visible = true
else:
label.visible = false
func _side_color(e: TurnEntry) -> Color:
if e.is_downed:
return Palette.BLOOD
if e.is_active:
return Palette.GOLD
match e.side:
&"enemy": return Palette.BLOOD_BRIGHT
&"ally": return Palette.STEEL
_: return Palette.CREAM
func _bind_vitals() -> void:
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
_gold.text = "%d gp" % _state.vitals["gold"]
_gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
func _bind_consumables() -> void:
# The 8 slots are authored; bind labels + fill/empty variation, hide spares.
var slots := _consumables.get_children()
for i in range(slots.size()):
var slot: PanelContainer = slots[i]
var label: Label = slot.get_node("Label")
if i < _state.consumables.size():
var c: Dictionary = _state.consumables[i]
label.text = c["label"]
slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY
slot.visible = true
else:
slot.visible = false
func _on_screen_requested(_id: StringName) -> void:
pass # routing seam — the dock's screens land in later milestones (§17)
func _build_seed_log() -> CanonLog:
# Code owns the canon log (§11). Hand-built until character creation (M4) and
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
# number is rendered anywhere (§7); the descriptor is prose-only.
var log := CanonLog.new()
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
log.set_location("lower_ward", "the Lower Ward")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
log.push_event("Followed the beastfolk fence into the Lower Ward after dark")
log.push_event("The sewer bite on Vexcca's left arm has begun to turn")
log.add_fact("the fence deals out of the Lower Ward")
log.add_fact("Vexcca carries an infected bite on the left arm")
log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence"))
log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4)
return log

View File

@@ -0,0 +1 @@
uid://bobihkpvsax2u

View File

@@ -0,0 +1,61 @@
class_name NarrationBook
extends Control
## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes
## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are
## written back to the canon log (§11). Prose-only this milestone: the numbered
## response choices and the "describe your own action" line are faithful but inert
## (no action system yet); one in-world refire affordance re-runs narration.
##
## Layout is authored in NarrationBook.tscn (editor-previewable). This script only
## binds the header, drives the narrate loop, and swaps prose text.
var last_degraded: bool = false
var _service: DmService
var _log: CanonLog
var _state: ShellState
var _indicator: ConsideringIndicator
@onready var _header: Label = $Margin/Col/Header
@onready var _prose: RichTextLabel = $Margin/Col/Prose
@onready var _degraded_tag: Label = $Margin/Col/DegradedTag
@onready var _refire: Button = $Margin/Col/Refire
@onready var _status: Label = $Margin/Col/Status
func _ready() -> void:
# The considering indicator is a non-visual Node (a Timer + label driver), so
# it stays code-created rather than authored in the scene.
_indicator = ConsideringIndicator.new()
add_child(_indicator)
_refire.pressed.connect(_on_refire_pressed)
func setup(service: DmService, log: CanonLog, state: ShellState) -> void:
_service = service
_log = log
_state = state
_header.text = "%s · %s" % [state.round_label, state.location_label]
func narrate() -> void:
if _service == null or _log == null:
return
_refire.disabled = true
_indicator.start(_status)
var result: NarrateResult = await _service.narrate(_log)
_indicator.stop()
show_prose(result)
for f in result.facts:
_log.add_fact(f) # §11: code applies the state write, explicitly
_refire.disabled = false
func show_prose(result: NarrateResult) -> void:
_prose.text = result.display_text
last_degraded = result.degraded
_degraded_tag.visible = result.degraded
func _on_refire_pressed() -> void:
narrate()

View File

@@ -0,0 +1 @@
uid://blmbc06xpea3x

View File

@@ -0,0 +1,36 @@
class_name ShellState
extends RefCounted
## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock
## 2a; later systems (combat vitals, inventory consumables) become the writers.
## No Luck/LCK value lives here — it is never surfaced (§7).
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
var turn_order: Array[TurnEntry] = []
var consumables: Array = []
var round_label: String = ""
var location_label: String = ""
var dock_open: bool = true
func toggle_dock() -> bool:
dock_open = not dock_open
return dock_open
static func seed() -> ShellState:
var s := ShellState.new()
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
s.turn_order = [
TurnEntry.new("EL", 17, &"you", true, false),
TurnEntry.new("DW", 12, &"ally"),
TurnEntry.new("HU", 9, &"ally"),
TurnEntry.new("GK", 0, &"enemy", false, true),
]
var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""]
s.consumables = []
for i in range(labels.size()):
s.consumables.append({"hotkey": i + 1, "label": labels[i]})
s.round_label = "ROUND 4"
s.location_label = "THE LOWER WARD"
s.dock_open = true
return s

View File

@@ -0,0 +1 @@
uid://f8vix48wmisv

View File

@@ -0,0 +1,61 @@
class_name SystemDock
extends Control
## The slide-in system dock (mock 2a): a gold handle toggles a column of screen
## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons
## are the routing seam — they emit screen_requested; the screens they open land
## in later milestones, so the signal is currently a no-op for the shell.
##
## Layout is authored in SystemDock.tscn (editor-previewable). This script only
## wires the buttons' signals, drives the slide animation, and toggles state. Each
## dock button carries a `screen_id` StringName in its scene metadata; add/reorder
## buttons in the editor and the wiring follows.
signal screen_requested(id: StringName)
const SLIDE_SECONDS := 0.32
const HIDDEN_SHIFT := 230.0
var _screen_buttons: Dictionary = {}
var _state: ShellState
var _menu_open_pos: Vector2
@onready var _menu: VBoxContainer = $Menu
@onready var _handle: Button = $Handle
func _ready() -> void:
# The buttons live in the scene; harvest each one's authored screen_id so the
# routing seam follows editor edits (add/remove/reorder needs no code change).
for child in _menu.get_children():
if child is Button and child.has_meta("screen_id"):
var id: StringName = child.get_meta("screen_id")
child.pressed.connect(func(): screen_requested.emit(id))
_screen_buttons[id] = child
_handle.pressed.connect(_on_handle_pressed)
# Capture the authored open position so the slide is relative to wherever the
# menu is placed in the editor — moving it in the editor just works.
_menu_open_pos = _menu.position
func setup(state: ShellState) -> void:
_state = state
_apply_open(_state.dock_open)
func _on_handle_pressed() -> void:
if _state == null:
return
_apply_open(_state.toggle_dock())
func _apply_open(open: bool) -> void:
_handle.text = "" if open else ""
var target := _menu_open_pos if open else _menu_open_pos + Vector2(HIDDEN_SHIFT, 0.0)
var alpha := 1.0 if open else 0.0
if not is_inside_tree():
_menu.position = target
_menu.modulate.a = alpha
return
var tween := create_tween().set_parallel(true)
tween.tween_property(_menu, "position", target, SLIDE_SECONDS)
tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS)

View File

@@ -0,0 +1 @@
uid://dvbg24rvye3qw

View File

@@ -0,0 +1,20 @@
class_name TurnEntry
extends RefCounted
## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the
## combat turn manager (M5) becomes its writer. `side` colours the rail:
## &"you" gold, &"ally" teal-muted, &"enemy" blood.
var initials: String
var initiative: int
var side: StringName
var is_active: bool
var is_downed: bool
func _init(p_initials: String, p_initiative: int, p_side: StringName,
p_is_active := false, p_is_downed := false) -> void:
initials = p_initials
initiative = p_initiative
side = p_side
is_active = p_is_active
is_downed = p_is_downed

View File

@@ -0,0 +1 @@
uid://toetic2iu7b8

View File

@@ -0,0 +1,37 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _shell() -> MainWindowShell:
# Inject a fake service BEFORE _ready (add_child) so the initial narrate does
# not touch the network — instantiate() does not run _ready(); add_child does.
var node = load(SCENE).instantiate()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
add_child_autofree(node)
return node
func test_shell_applies_theme_and_fits_viewport():
var packed = load(SCENE)
assert_true(packed is PackedScene)
var node := _shell()
assert_true(node is Control)
assert_true(node.theme is Theme, "shell applies game_theme")
assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)")
func test_shell_mounts_both_panels():
var node := _shell()
assert_not_null(node.book, "narration book mounted")
assert_not_null(node.dock, "system dock mounted")
func test_shell_builds_a_code_owned_seed_log():
var node := _shell()
assert_not_null(node._log, "a canon log exists to post (§11)")
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")

View File

@@ -0,0 +1 @@
uid://dbd4cq1awo4su

View File

@@ -0,0 +1,37 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/NarrationBook.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _book_with(response) -> NarrationBook:
var transport = FakeTransport.new()
transport.set_response(response)
var service := DmService.new(transport, FallbackLibrary.new())
var book = load(SCENE).instantiate()
add_child_autofree(book) # _ready() builds nodes + the considering indicator
book.setup(service, CanonLog.new(), ShellState.seed())
return book
func test_narrate_shows_prose_and_harvests_facts():
var book := _book_with(DmResponse.ok(200,
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
var log := CanonLog.new()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200,
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed())
await book.narrate()
assert_string_contains(book._prose.text, "The rain runs black into the gutter.")
assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose")
assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log")
assert_false(book.last_degraded)
func test_degraded_response_shows_fallback_and_sets_flag():
var book := _book_with(DmResponse.failed("proxy down"))
await book.narrate()
assert_true(book.last_degraded, "a failed call degrades")
assert_ne(book._prose.text, "", "the fallback line is still shown (§13)")

View File

@@ -0,0 +1 @@
uid://dj6ly2ljf3ai4

View File

@@ -0,0 +1,44 @@
extends "res://addons/gut/test.gd"
func test_turn_entry_holds_fields():
var e := TurnEntry.new("DW", 12, &"ally")
assert_eq(e.initials, "DW")
assert_eq(e.initiative, 12)
assert_eq(e.side, &"ally")
assert_false(e.is_active)
assert_false(e.is_downed)
func test_seed_vitals():
var s := ShellState.seed()
assert_eq(s.vitals["hp"], 42)
assert_eq(s.vitals["hp_max"], 60)
assert_eq(s.vitals["mp"], 18)
assert_eq(s.vitals["mp_max"], 30)
assert_eq(s.vitals["gold"], 1240)
func test_seed_turn_order():
var s := ShellState.seed()
assert_eq(s.turn_order.size(), 4)
assert_eq(s.turn_order[0].initials, "EL")
assert_true(s.turn_order[0].is_active, "first combatant is the active turn")
assert_true(s.turn_order[3].is_downed, "last combatant is downed")
func test_seed_consumables():
var s := ShellState.seed()
assert_eq(s.consumables.size(), 8)
assert_eq(s.consumables[0]["label"], "Salve")
assert_eq(s.consumables[0]["hotkey"], 1)
assert_eq(s.consumables[6]["label"], "", "slot 7 is empty")
func test_toggle_dock_flips():
var s := ShellState.seed()
assert_true(s.dock_open, "seed opens with the dock out")
assert_false(s.toggle_dock())
assert_false(s.dock_open)
assert_true(s.toggle_dock())
assert_true(s.dock_open)

View File

@@ -0,0 +1 @@
uid://wkbpiulrv8rx

View File

@@ -0,0 +1,32 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/SystemDock.tscn"
func _dock() -> SystemDock:
var d = load(SCENE).instantiate()
add_child_autofree(d) # _ready() builds the buttons
return d
func test_builds_all_six_screen_buttons():
var d := _dock()
assert_eq(d._screen_buttons.size(), 6)
assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity")
assert_true(d._screen_buttons.has(&"party"))
func test_pressing_a_button_emits_its_id():
var d := _dock()
watch_signals(d)
d._screen_buttons[&"inventory"].emit_signal("pressed")
assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"])
func test_handle_toggles_state():
var d := _dock()
var state := ShellState.seed()
d.setup(state)
assert_true(state.dock_open)
d._on_handle_pressed()
assert_false(state.dock_open, "handle flips the shared state")

View File

@@ -0,0 +1 @@
uid://dllvm2kmia00h

View File

@@ -0,0 +1,86 @@
# 0001 — UI screens are editor-authored scenes, not code-built
- **Status:** accepted
- **Date:** 2026-07-11 (M3-b, Main Window shell)
## Context
The M3-b Main Window shell was first built by constructing every Control node in
`_ready()` (a pattern borrowed from `theme_showcase.gd`, where it was fine because
the showcase is a throwaway proof). For a real screen this was wrong:
- **It does not preview in the Godot editor.** Opening the `.tscn` showed a bare
root Control — every widget only existed at runtime — so the human could not see
or arrange the layout in the editor. Tuning meant editing pixel offsets in code,
running, eyeballing, repeating.
- It buried layout, styling, and content in GDScript, where it is verbose and
hard to reason about, and it drifted from charter §16's own words: *"each screen
is recreated as a Godot 4.7 Control-node scene."*
The human asked to be able to arrange the scene in the editor "as much as
possible," with code only for genuine on-load work.
## Decision
**UI screens (and their sub-scenes) are authored editor-first as `.tscn` node
trees.** The split:
- **In the `.tscn` (editor):** the whole node tree — containers, panels, labels,
buttons, art-slot placeholders — with the shared `Theme` assigned on the scene
root and every `theme_type_variation` set on its node. Authored placeholder
text/values so the screen previews with representative content. Sub-screens are
**instanced** (`ExtResource` PackedScene), not rebuilt.
- **In the script (on-load only):** `@onready` references to named nodes; wiring
(signals, the DM loop, injected services); animations (e.g. the dock slide);
and **binding state into the authored nodes** (setting label text/colours from
the model). Never construct the tree in `_ready()`.
- **Data-driven repeats** (turn-order tokens, consumable slots, later inventory
grids) are a **fixed set of authored nodes** bound from state at runtime —
spare slots hide. This keeps them previewable; combat/inventory become the
writers later. (Author enough slots for the common case; if a screen ever needs
a genuinely unbounded list, revisit with a code-instanced container then.)
Supporting conventions established alongside this decision:
- **Scene root sizing.** Give the `.tscn` root a fixed size (e.g. 1920×1080) via
offsets so it previews, but **no full-rect anchors on the root** — a root
Control run via F6 is not sized by full-rect anchors and collapses to (0,0).
The script keeps `size = get_viewport_rect().size` on `_ready` +
`size_changed` (the M3-a lesson).
- **Button styling uses Button-base theme variations.** Never apply a
PanelContainer-base variation (`DarkPanel`, `ParchmentCard`) to a `Button` — a
Button looks up `normal/hover/pressed`, which a `panel`-only variation lacks, so
it renders unskinned. M3-b added Button-base `DockButton` and `ParchmentButton`
to the theme for exactly this.
- **RichTextLabel prose reads its style from the theme.** `RichTextLabel` does
**not** inherit `default_font`/`default_color`, so without a theme entry its
text renders near-white (unreadable on parchment). The theme now sets a base
`RichTextLabel` style (dark `INK_BODY` serif + italic face). All colours still
come from `Palette` via the theme builder — no hex in scripts or `.tscn`.
## Consequences
**Easier:**
- The human arranges any screen directly in the Godot editor; layout persists in
the `.tscn`. Fine-tuning is drag-and-drop, not code-run-eyeball.
- Screens preview with themed, representative content on open.
- Scripts shrink to logic; less code, easier to review, focused files.
- The look stays one system (theme assigned per scene root).
**Caveats / harder:**
- **Shader surfaces render only at runtime.** `DarkBay`/`VignetteOverlay` set
their `ShaderMaterial` in `_ready()` (M3-a accepted deviation), so their
texture appears only on an F6 run — the editor shows layout but a plain panel
behind it. Layout still previews fine.
- Tests reference nodes by their authored `@onready` paths; renaming/moving a
node in the editor means updating the path (and any test that reads it).
- The visual gate stays a **human F6 eyeball** — headless tests prove structure
(nodes mount, sizes are non-zero, bindings run) but not rendering.
## Reference implementation
M3-b shell scenes: `client/scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
with their `client/scripts/ui/shell/*.gd` scripts. Practical how-to lives in
[`client/docs/README.md`](../../client/docs/README.md) → "UI scene conventions."

View File

@@ -4,7 +4,7 @@ One file per decision: `NNNN-short-title.md`. Record the context, the decision,
## Index
- _none yet_
- [0001 — UI screens are editor-authored scenes, not code-built](0001-editor-first-ui-scenes.md) (accepted)
<!-- Template:
## NNNN — Title

View File

@@ -55,9 +55,9 @@ M0M2 built the **engine** (the contract, the client, the server loop, bounded
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each `.dc.html`'s `Component` class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
### M3 — Visual foundation & shell ◀ *here*
- **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
- **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
-**Title screen** — new game / continue / load / settings; the entry point. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
- **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
- **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
-**Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). *§2: n/a · depends on the Theme · goal: first impression, into the world.*
### M4 — Character creation
-**Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*

View File

@@ -0,0 +1,888 @@
# Main Window shell (2a) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the exploration HUD — a two-panel "tabletop split" frame with an isometric world-view slot, a permanent DM narration book wired to the proven `/dm/narrate` loop (prose-only + refire), a slide-in system dock, and a command bar — over a thin, tested `ShellState`.
**Architecture:** House pattern — pure, tested cores behind thin scene shims (like `DmService`/`ConsideringPhrases`). `ShellState` is a tested value model; `SystemDock`/`NarrationBook`/`MainWindowShell` are Control scenes whose logic seams (signal emission, prose consume, dep injection) are unit-tested and whose layout is eyeball-gated by an F6 run. The narration book is the one live seam; everything else is faithful placeholder.
**Tech Stack:** Godot 4.7, GDScript, GUT (headless). Reuses the M3-a Theme (`game_theme.tres`, `ThemeKeys`, `Palette`, surface scenes) and the M2 DM loop (`DmService`, `HttpTransport`, `FallbackLibrary`, `ConsideringIndicator`, `CanonLog`).
## Global Constraints
- **Godot 4.7 / GDScript.** No C#. Match surrounding style.
- **`Palette` is the only home for hex colours.** No colour literal may live in any new file — read `Palette.*`.
- **Theme variations via `ThemeKeys`** — set `theme_type_variation` to a `ThemeKeys` constant, never a raw string.
- **`§2`: code owns state, AI owns text.** `ShellState` and HUD numbers are state (client-owned); narration prose is text (consumed from `/dm/narrate`, never a source of truth — only harvested `[FACT]` tags become state).
- **Degrade, never error (§13).** Any non-200 → `DmService` already returns the authored fallback. Add no new failure surface.
- **Never surface LCK / any Luck number (§7).** Not in the HUD, not in the seed log's rendered fields.
- **New `class_name` scripts need an import pass before GUT resolves them.** `./run_tests.sh` runs `godot --headless --import` first, so always test through it.
- **Canvas is 1920×1080.** World panel 1180 wide, narration book 740 wide.
- **Root-Control viewport-fit gotcha:** a root `Control` run via F6 is not sized by full-rect anchors — it collapses to `(0,0)`. `MainWindowShell` must set `size = get_viewport_rect().size` in `_ready()` and reconnect on `size_changed`. Its `.tscn` root must NOT set `anchors_preset`.
---
### Task 1: `ShellState` + `TurnEntry` (the HUD state model)
**Files:**
- Create: `client/scripts/ui/shell/turn_entry.gd`
- Create: `client/scripts/ui/shell/shell_state.gd`
- Test: `client/tests/unit/test_shell_state.gd`
**Interfaces:**
- Consumes: nothing.
- Produces:
- `class_name TurnEntry extends RefCounted``_init(initials: String, initiative: int, side: StringName, is_active := false, is_downed := false)`; fields `initials: String`, `initiative: int`, `side: StringName` (`&"you"`/`&"ally"`/`&"enemy"`), `is_active: bool`, `is_downed: bool`.
- `class_name ShellState extends RefCounted` — fields `vitals: Dictionary` (keys `hp`,`hp_max`,`mp`,`mp_max`,`gold`), `turn_order: Array[TurnEntry]`, `consumables: Array` (8 dicts `{hotkey:int, label:String}`; `label==""` is an empty slot), `round_label: String`, `location_label: String`, `dock_open: bool`. Methods `func toggle_dock() -> bool` (flips `dock_open`, returns new value); `static func seed() -> ShellState` (the mock-2a values).
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_shell_state.gd`:
```gdscript
extends "res://addons/gut/test.gd"
func test_turn_entry_holds_fields():
var e := TurnEntry.new("DW", 12, &"ally")
assert_eq(e.initials, "DW")
assert_eq(e.initiative, 12)
assert_eq(e.side, &"ally")
assert_false(e.is_active)
assert_false(e.is_downed)
func test_seed_vitals():
var s := ShellState.seed()
assert_eq(s.vitals["hp"], 42)
assert_eq(s.vitals["hp_max"], 60)
assert_eq(s.vitals["mp"], 18)
assert_eq(s.vitals["mp_max"], 30)
assert_eq(s.vitals["gold"], 1240)
func test_seed_turn_order():
var s := ShellState.seed()
assert_eq(s.turn_order.size(), 4)
assert_eq(s.turn_order[0].initials, "EL")
assert_true(s.turn_order[0].is_active, "first combatant is the active turn")
assert_true(s.turn_order[3].is_downed, "last combatant is downed")
func test_seed_consumables():
var s := ShellState.seed()
assert_eq(s.consumables.size(), 8)
assert_eq(s.consumables[0]["label"], "Salve")
assert_eq(s.consumables[0]["hotkey"], 1)
assert_eq(s.consumables[6]["label"], "", "slot 7 is empty")
func test_toggle_dock_flips():
var s := ShellState.seed()
assert_true(s.dock_open, "seed opens with the dock out")
assert_false(s.toggle_dock())
assert_false(s.dock_open)
assert_true(s.toggle_dock())
assert_true(s.dock_open)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd`
Expected: FAIL — `TurnEntry`/`ShellState` are unknown identifiers (parse/compile error reported by GUT).
- [ ] **Step 3: Write minimal implementation**
Create `client/scripts/ui/shell/turn_entry.gd`:
```gdscript
class_name TurnEntry
extends RefCounted
## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the
## combat turn manager (M5) becomes its writer. `side` colours the rail:
## &"you" gold, &"ally" teal-muted, &"enemy" blood.
var initials: String
var initiative: int
var side: StringName
var is_active: bool
var is_downed: bool
func _init(p_initials: String, p_initiative: int, p_side: StringName,
p_is_active := false, p_is_downed := false) -> void:
initials = p_initials
initiative = p_initiative
side = p_side
is_active = p_is_active
is_downed = p_is_downed
```
Create `client/scripts/ui/shell/shell_state.gd`:
```gdscript
class_name ShellState
extends RefCounted
## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock
## 2a; later systems (combat vitals, inventory consumables) become the writers.
## No Luck/LCK value lives here — it is never surfaced (§7).
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
var turn_order: Array[TurnEntry] = []
var consumables: Array = []
var round_label: String = ""
var location_label: String = ""
var dock_open: bool = true
func toggle_dock() -> bool:
dock_open = not dock_open
return dock_open
static func seed() -> ShellState:
var s := ShellState.new()
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
s.turn_order = [
TurnEntry.new("EL", 17, &"you", true, false),
TurnEntry.new("DW", 12, &"ally"),
TurnEntry.new("HU", 9, &"ally"),
TurnEntry.new("GK", 0, &"enemy", false, true),
]
var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""]
s.consumables = []
for i in range(labels.size()):
s.consumables.append({"hotkey": i + 1, "label": labels[i]})
s.round_label = "ROUND 4"
s.location_label = "THE LOWER WARD"
s.dock_open = true
return s
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd`
Expected: PASS — 5 passing tests, 0 failing.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/ui/shell/turn_entry.gd client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd client/scripts/ui/shell/*.uid client/tests/unit/test_shell_state.gd.uid
git commit -m "feat(shell): ShellState + TurnEntry HUD state model"
```
---
### Task 2: `SystemDock` (slide-in dock, inert routing seam)
**Files:**
- Create: `client/scripts/ui/shell/system_dock.gd`
- Create: `client/scenes/shell/SystemDock.tscn`
- Test: `client/tests/unit/test_system_dock.gd`
**Interfaces:**
- Consumes: `ShellState` (Task 1) — reads/flips `dock_open`.
- Produces:
- `class_name SystemDock extends Control` — scene `res://scenes/shell/SystemDock.tscn`.
- `signal screen_requested(id: StringName)` — emitted when a dock button is pressed (inert this milestone; the routing seam).
- `const SCREENS := [[&"inventory","Inventory","I"], [&"character","Character","C"], [&"quest_log","Quest Log","J"], [&"spellbook","Spellbook","K"], [&"world_map","World Map","M"], [&"party","Party","P"]]`
- `var _screen_buttons: Dictionary``id: StringName -> Button`, populated in `_ready()`.
- `func setup(state: ShellState) -> void` — binds state, applies initial open.
- `func _on_handle_pressed() -> void` — flips `state.dock_open`, animates.
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_system_dock.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/SystemDock.tscn"
func _dock() -> SystemDock:
var d = load(SCENE).instantiate()
add_child_autofree(d) # _ready() builds the buttons
return d
func test_builds_all_six_screen_buttons():
var d := _dock()
assert_eq(d._screen_buttons.size(), 6)
assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity")
assert_true(d._screen_buttons.has(&"party"))
func test_pressing_a_button_emits_its_id():
var d := _dock()
watch_signals(d)
d._screen_buttons[&"inventory"].emit_signal("pressed")
assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"])
func test_handle_toggles_state():
var d := _dock()
var state := ShellState.seed()
d.setup(state)
assert_true(state.dock_open)
d._on_handle_pressed()
assert_false(state.dock_open, "handle flips the shared state")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd`
Expected: FAIL — cannot load `SystemDock.tscn` / `SystemDock` unknown.
- [ ] **Step 3: Write minimal implementation**
Create `client/scripts/ui/shell/system_dock.gd`:
```gdscript
class_name SystemDock
extends Control
## The slide-in system dock (mock 2a): a gold handle toggles a column of screen
## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons
## are the routing seam — they emit screen_requested; the screens they open land
## in later milestones, so the signal is currently a no-op for the shell.
signal screen_requested(id: StringName)
const SCREENS := [
[&"inventory", "Inventory", "I"],
[&"character", "Character", "C"],
[&"quest_log", "Quest Log", "J"],
[&"spellbook", "Spellbook", "K"],
[&"world_map", "World Map", "M"],
[&"party", "Party", "P"],
]
const SLIDE_SECONDS := 0.32
const HIDDEN_SHIFT := 230.0
var _screen_buttons: Dictionary = {}
var _state: ShellState
var _menu: VBoxContainer
var _handle: Button
func _ready() -> void:
_menu = VBoxContainer.new()
_menu.add_theme_constant_override("separation", 10)
add_child(_menu)
for entry in SCREENS:
var id: StringName = entry[0]
var button := Button.new()
button.text = "%s %s" % [entry[2], entry[1]]
button.theme_type_variation = ThemeKeys.DARK_PANEL
button.pressed.connect(func(): screen_requested.emit(id))
_menu.add_child(button)
_screen_buttons[id] = button
_handle = Button.new()
_handle.text = ""
_handle.theme_type_variation = ThemeKeys.TAB_ACTIVE
_handle.pressed.connect(_on_handle_pressed)
add_child(_handle)
func setup(state: ShellState) -> void:
_state = state
_apply_open(_state.dock_open)
func _on_handle_pressed() -> void:
if _state == null:
return
_apply_open(_state.toggle_dock())
func _apply_open(open: bool) -> void:
_handle.text = "" if open else ""
var target := Vector2.ZERO if open else Vector2(HIDDEN_SHIFT, 0.0)
var alpha := 1.0 if open else 0.0
if not is_inside_tree():
_menu.position = target
_menu.modulate.a = alpha
return
var tween := create_tween().set_parallel(true)
tween.tween_property(_menu, "position", target, SLIDE_SECONDS)
tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS)
```
Create `client/scenes/shell/SystemDock.tscn`:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
[node name="SystemDock" type="Control"]
script = ExtResource("1")
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd`
Expected: PASS — 3 passing tests, 0 failing.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/ui/shell/system_dock.gd client/scenes/shell/SystemDock.tscn client/tests/unit/test_system_dock.gd client/scripts/ui/shell/system_dock.gd.uid client/scenes/shell/SystemDock.tscn.uid client/tests/unit/test_system_dock.gd.uid
git commit -m "feat(shell): SystemDock slide-in with inert screen-routing seam"
```
---
### Task 3: `NarrationBook` (the live DM panel + refire)
**Files:**
- Create: `client/scripts/ui/shell/narration_book.gd`
- Create: `client/scenes/shell/NarrationBook.tscn`
- Test: `client/tests/unit/test_narration_book.gd`
**Interfaces:**
- Consumes: `DmService` (`narrate(canon_log) -> NarrateResult`), `CanonLog`, `ShellState`, `ConsideringIndicator`, `NarrateResult` (`display_text`, `facts`, `degraded`).
- Produces:
- `class_name NarrationBook extends Control` — scene `res://scenes/shell/NarrationBook.tscn`.
- `func setup(service: DmService, log: CanonLog, state: ShellState) -> void` — injects deps, fills the header.
- `func narrate() -> void`**async**; runs the refire flow (considering-state → await → prose → fact-harvest).
- `func show_prose(result: NarrateResult) -> void` — sets the prose text, records `last_degraded`.
- `var last_degraded: bool` — set by `show_prose` (test seam + degraded UI).
- `var _prose: RichTextLabel` — the narration surface.
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_narration_book.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/NarrationBook.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _book_with(response) -> NarrationBook:
var transport = FakeTransport.new()
transport.set_response(response)
var service := DmService.new(transport, FallbackLibrary.new())
var book = load(SCENE).instantiate()
add_child_autofree(book) # _ready() builds nodes + the considering indicator
book.setup(service, CanonLog.new(), ShellState.seed())
return book
func test_narrate_shows_prose_and_harvests_facts():
var book := _book_with(DmResponse.ok(200,
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
var log := CanonLog.new()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200,
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed())
await book.narrate()
assert_string_contains(book._prose.text, "The rain runs black into the gutter.")
assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose")
assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log")
assert_false(book.last_degraded)
func test_degraded_response_shows_fallback_and_sets_flag():
var book := _book_with(DmResponse.failed("proxy down"))
await book.narrate()
assert_true(book.last_degraded, "a failed call degrades")
assert_ne(book._prose.text, "", "the fallback line is still shown (§13)")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd`
Expected: FAIL — cannot load `NarrationBook.tscn` / `NarrationBook` unknown.
- [ ] **Step 3: Write minimal implementation**
Create `client/scripts/ui/shell/narration_book.gd`:
```gdscript
class_name NarrationBook
extends Control
## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes
## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are
## written back to the canon log (§11). Prose-only this milestone: the numbered
## response choices and the "describe your own action" line are faithful but inert
## (no action system yet); one in-world refire affordance re-runs narration.
var last_degraded: bool = false
var _service: DmService
var _log: CanonLog
var _state: ShellState
var _header: Label
var _prose: RichTextLabel
var _status: Label
var _refire: Button
var _degraded_tag: Label
var _indicator: ConsideringIndicator
func _ready() -> void:
var col := VBoxContainer.new()
col.set_anchors_preset(Control.PRESET_FULL_RECT)
col.add_theme_constant_override("separation", 18)
add_child(col)
_header = Label.new()
_header.theme_type_variation = ThemeKeys.ACCENT
col.add_child(_header)
_prose = RichTextLabel.new()
_prose.bbcode_enabled = true
_prose.fit_content = true
_prose.size_flags_vertical = Control.SIZE_EXPAND_FILL
_prose.custom_minimum_size = Vector2(660, 320)
col.add_child(_prose)
_degraded_tag = Label.new()
_degraded_tag.theme_type_variation = ThemeKeys.MONO
_degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded
_degraded_tag.visible = false
col.add_child(_degraded_tag)
# Faithful-but-inert placeholders (charter: prose-only this milestone).
var prompt := Label.new()
prompt.theme_type_variation = ThemeKeys.ACCENT
prompt.text = "▸ Your turn. What do you do?"
col.add_child(prompt)
for line in [
"1 · Pay the fence double and keep your hand where he can see it",
"2 · Draw your blade — he's closer than his guards",
"3 · Lie — tell him the rest is coming with your dwarf"]:
var choice := Button.new()
choice.theme_type_variation = ThemeKeys.PARCHMENT_CARD
choice.text = line
choice.disabled = true # inert until combat/dialogue give choices meaning
col.add_child(choice)
var free_text := Label.new()
free_text.theme_type_variation = ThemeKeys.MONO
free_text.text = "⌨ …or describe your own action"
col.add_child(free_text)
# The one live control: refire the narration.
_refire = Button.new()
_refire.theme_type_variation = ThemeKeys.PARCHMENT_CARD
_refire.text = "▸ The Master continues…"
_refire.pressed.connect(_on_refire_pressed)
col.add_child(_refire)
_status = Label.new()
_status.theme_type_variation = ThemeKeys.MONO
col.add_child(_status)
_indicator = ConsideringIndicator.new()
add_child(_indicator)
func setup(service: DmService, log: CanonLog, state: ShellState) -> void:
_service = service
_log = log
_state = state
_header.text = "%s · %s" % [state.round_label, state.location_label]
func narrate() -> void:
if _service == null or _log == null:
return
_refire.disabled = true
_indicator.start(_status)
var result: NarrateResult = await _service.narrate(_log)
_indicator.stop()
show_prose(result)
for f in result.facts:
_log.add_fact(f) # §11: code applies the state write, explicitly
_refire.disabled = false
func show_prose(result: NarrateResult) -> void:
_prose.text = result.display_text
last_degraded = result.degraded
_degraded_tag.visible = result.degraded
func _on_refire_pressed() -> void:
narrate()
```
Create `client/scenes/shell/NarrationBook.tscn`:
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
[node name="NarrationBook" type="Control"]
script = ExtResource("1")
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd`
Expected: PASS — 2 passing tests, 0 failing.
- [ ] **Step 5: Commit**
```bash
git add client/scripts/ui/shell/narration_book.gd client/scenes/shell/NarrationBook.tscn client/tests/unit/test_narration_book.gd client/scripts/ui/shell/narration_book.gd.uid client/scenes/shell/NarrationBook.tscn.uid client/tests/unit/test_narration_book.gd.uid
git commit -m "feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest"
```
---
### Task 4: `MainWindowShell` (assemble the frame + wire the DM loop)
**Files:**
- Create: `client/scripts/ui/shell/main_window_shell.gd`
- Create: `client/scenes/shell/MainWindowShell.tscn`
- Test: `client/tests/unit/test_main_window_shell.gd`
**Interfaces:**
- Consumes: `ShellState` (Task 1), `SystemDock` (Task 2), `NarrationBook` (Task 3), `DmService`/`HttpTransport`/`FallbackLibrary`, `CanonLog`/`LogPlayer`/`PartyMember`/`Quest`, the `DarkBay` surface scene, `Palette`, `ThemeKeys`.
- Produces:
- `class_name MainWindowShell extends Control` — scene `res://scenes/shell/MainWindowShell.tscn`; the F6 target.
- `var service: DmService = null`**injection seam**: settable between `instantiate()` and `add_child()`; when null, `_ready()` builds the real `HttpTransport`-backed service. Tests inject a `FakeTransport`-backed service so the initial narrate is hermetic.
- `var book: NarrationBook`, `var dock: SystemDock`, `var _state: ShellState`, `var _log: CanonLog`.
- [ ] **Step 1: Write the failing test**
Create `client/tests/unit/test_main_window_shell.gd`:
```gdscript
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
const DmResponse = preload("res://scripts/net/dm_response.gd")
func _shell() -> MainWindowShell:
# Inject a fake service BEFORE _ready (add_child) so the initial narrate does
# not touch the network — instantiate() does not run _ready(); add_child does.
var node = load(SCENE).instantiate()
var transport = FakeTransport.new()
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
node.service = DmService.new(transport, FallbackLibrary.new())
add_child_autofree(node)
return node
func test_shell_applies_theme_and_fits_viewport():
var packed = load(SCENE)
assert_true(packed is PackedScene)
var node := _shell()
assert_true(node is Control)
assert_true(node.theme is Theme, "shell applies game_theme")
assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)")
func test_shell_mounts_both_panels():
var node := _shell()
assert_not_null(node.book, "narration book mounted")
assert_not_null(node.dock, "system dock mounted")
func test_shell_builds_a_code_owned_seed_log():
var node := _shell()
assert_not_null(node._log, "a canon log exists to post (§11)")
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd`
Expected: FAIL — cannot load `MainWindowShell.tscn` / `MainWindowShell` unknown.
- [ ] **Step 3: Write minimal implementation**
Create `client/scripts/ui/shell/main_window_shell.gd`:
```gdscript
class_name MainWindowShell
extends Control
## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel
## tabletop split: a dark isometric world slot (turn rail, minimap, system dock,
## command bar) + the permanent DM narration book. §2: the shell owns HUD state
## (ShellState) and consumes DM text (never owns narrative truth). It reuses the
## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented.
const GAME_THEME := "res://assets/theme/game_theme.tres"
const DARK_BAY := "res://scenes/theme/surfaces/DarkBay.tscn"
const SYSTEM_DOCK := "res://scenes/shell/SystemDock.tscn"
const NARRATION_BOOK := "res://scenes/shell/NarrationBook.tscn"
const WORLD_WIDTH := 1180.0
const BOOK_WIDTH := 740.0
var service: DmService = null # injection seam; real HttpTransport built if null
var book: NarrationBook
var dock: SystemDock
var _state: ShellState
var _log: CanonLog
var _http: HTTPRequest
func _ready() -> void:
theme = load(GAME_THEME)
# Root Control is not sized by full-rect anchors under F6 — size explicitly,
# and no anchors_preset on the .tscn root (M3-a collapse bug).
_fit_to_viewport()
get_viewport().size_changed.connect(_fit_to_viewport)
_state = ShellState.seed()
_log = _build_seed_log()
_build_layout()
if service == null:
_http = HTTPRequest.new()
add_child(_http)
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
book.setup(service, _log, _state)
dock.setup(_state)
dock.screen_requested.connect(_on_screen_requested)
book.narrate() # initial narration; async, fire-and-forget
func _fit_to_viewport() -> void:
size = get_viewport_rect().size
func _build_layout() -> void:
var split := HBoxContainer.new()
split.set_anchors_preset(Control.PRESET_FULL_RECT)
split.add_theme_constant_override("separation", 0)
add_child(split)
var world := _build_world()
world.size_flags_horizontal = Control.SIZE_EXPAND_FILL
split.add_child(world)
book = load(NARRATION_BOOK).instantiate()
book.custom_minimum_size = Vector2(BOOK_WIDTH, 0)
var book_panel := PanelContainer.new()
book_panel.theme_type_variation = ThemeKeys.PARCHMENT_CARD
book_panel.custom_minimum_size = Vector2(BOOK_WIDTH, 0)
book_panel.add_child(book)
split.add_child(book_panel)
func _build_world() -> Control:
var world := Control.new()
world.custom_minimum_size = Vector2(WORLD_WIDTH, 0)
world.clip_contents = true
var bay = load(DARK_BAY).instantiate()
bay.set_anchors_preset(Control.PRESET_FULL_RECT)
world.add_child(bay)
var caption := Label.new()
caption.theme_type_variation = ThemeKeys.MONO
caption.text = "ISOMETRIC WORLD VIEW"
caption.set_anchors_preset(Control.PRESET_CENTER)
world.add_child(caption)
var rail := _build_turn_rail()
rail.position = Vector2(28, 28)
world.add_child(rail)
var minimap := PanelContainer.new()
minimap.theme_type_variation = ThemeKeys.DARK_PANEL
minimap.custom_minimum_size = Vector2(200, 200)
minimap.set_anchors_preset(Control.PRESET_TOP_RIGHT)
minimap.position = Vector2(-228, 28)
var minimap_label := Label.new()
minimap_label.theme_type_variation = ThemeKeys.MONO
minimap_label.text = "MINIMAP"
minimap.add_child(minimap_label)
world.add_child(minimap)
dock = load(SYSTEM_DOCK).instantiate()
dock.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
dock.position = Vector2(-260, 0)
world.add_child(dock)
var command_bar := _build_command_bar()
command_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
command_bar.position = Vector2(0, -150)
world.add_child(command_bar)
return world
func _build_turn_rail() -> Control:
var panel := PanelContainer.new()
panel.theme_type_variation = ThemeKeys.DARK_PANEL
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 8)
panel.add_child(col)
var title := Label.new()
title.theme_type_variation = ThemeKeys.MONO
title.text = "TURN ORDER"
col.add_child(title)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
col.add_child(row)
for e in _state.turn_order:
var token := Label.new()
token.theme_type_variation = ThemeKeys.ACCENT
token.text = "" if e.is_downed else e.initials
token.add_theme_color_override("font_color", _side_color(e))
row.add_child(token)
return panel
func _side_color(e: TurnEntry) -> Color:
if e.is_downed:
return Palette.BLOOD
if e.is_active:
return Palette.GOLD
match e.side:
&"enemy": return Palette.BLOOD_BRIGHT
&"ally": return Palette.STEEL
_: return Palette.CREAM
func _build_command_bar() -> Control:
var bar := HBoxContainer.new()
bar.add_theme_constant_override("separation", 24)
bar.custom_minimum_size = Vector2(0, 150)
var vitals := VBoxContainer.new()
var hp := Label.new()
hp.theme_type_variation = ThemeKeys.MONO
hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
var mp := Label.new()
mp.theme_type_variation = ThemeKeys.MONO
mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
var gold := Label.new()
gold.theme_type_variation = ThemeKeys.MONO
gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
gold.text = "%d gp" % _state.vitals["gold"]
for l in [hp, mp, gold]:
vitals.add_child(l)
bar.add_child(vitals)
var slots := HBoxContainer.new()
slots.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slots.add_theme_constant_override("separation", 9)
for c in _state.consumables:
var slot := PanelContainer.new()
slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY
slot.custom_minimum_size = Vector2(62, 62)
var name_label := Label.new()
name_label.theme_type_variation = ThemeKeys.MONO
name_label.text = c["label"]
slot.add_child(name_label)
slots.add_child(slot)
bar.add_child(slots)
var end_turn := Button.new()
end_turn.theme_type_variation = ThemeKeys.PRIMARY_CTA
end_turn.text = "END TURN"
bar.add_child(end_turn)
return bar
func _on_screen_requested(_id: StringName) -> void:
pass # routing seam — the dock's screens land in later milestones (§17)
func _build_seed_log() -> CanonLog:
# Code owns the canon log (§11). Hand-built until character creation (M4) and
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
# number is rendered anywhere (§7); the descriptor is prose-only.
var log := CanonLog.new()
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
log.set_location("lower_ward", "the Lower Ward")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
log.push_event("Followed the beastfolk fence into the Lower Ward after dark")
log.push_event("The sewer bite on Vexcca's left arm has begun to turn")
log.add_fact("the fence deals out of the Lower Ward")
log.add_fact("Vexcca carries an infected bite on the left arm")
log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence"))
log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4)
return log
```
Create `client/scenes/shell/MainWindowShell.tscn` (note: **no `anchors_preset` on the root** — the viewport-fit gotcha):
```
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
[node name="MainWindowShell" type="Control"]
script = ExtResource("1")
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd`
Expected: PASS — 3 passing tests, 0 failing.
- [ ] **Step 5: Run the full suite (no regressions)**
Run: `cd client && ./run_tests.sh`
Expected: PASS — all prior tests (110+) plus the new shell tests, 0 failing.
- [ ] **Step 6: Commit**
```bash
git add client/scripts/ui/shell/main_window_shell.gd client/scenes/shell/MainWindowShell.tscn client/tests/unit/test_main_window_shell.gd client/scripts/ui/shell/main_window_shell.gd.uid client/scenes/shell/MainWindowShell.tscn.uid client/tests/unit/test_main_window_shell.gd.uid
git commit -m "feat(shell): MainWindowShell assembles the frame and wires the DM loop"
```
---
### Task 5: Visual gate — human F6 run
**This task is not automatable.** Headless GUT cannot prove rendering (the M3-a lesson: a root Control collapsed to 0×0 and every test still passed). A human must eyeball the scene.
- [ ] **Step 1: Open the scene in the Godot editor and run it**
Open `client/scenes/shell/MainWindowShell.tscn`, press **F6**. For the live DM prose, have the proxy + Ollama up (see `client/docs/README.md` live-smoke steps); without them the book must degrade to the authored fallback line (§13), not error.
- [ ] **Step 2: Confirm the checklist by eye**
- The two-panel split fills 1920×1080 — world on the left (dark stripe + vignette), parchment book on the right. **Not** collapsed / blank.
- Turn-order rail (top-left), minimap slot (top-right), command bar (bottom: HP/MP/gold, 8 consumable slots, END TURN).
- The gold dock handle slides the dock in/out when clicked; the arrow flips / .
- The book shows the header (`ROUND 4 · THE LOWER WARD`), then either live DM prose or the fallback line; the considering line rotates while a call is in flight.
- Pressing **▸ The Master continues…** refires narration (or degrades cleanly if the proxy is down).
- [ ] **Step 3: Report the outcome**
If anything is collapsed, mis-coloured (hardcoded hex would show here), or errors, fix and re-run before the branch is considered done. When it looks right, the milestone is eyeball-confirmed and ready to merge to `dev`.
---
## Self-Review
**Spec coverage:**
- Iso world slot (art slot) → Task 4 `_build_world` (DarkBay + caption). ✓
- DM narration book wired to `/dm/narrate` + considering-state → Task 3. ✓
- Turn-order rail → Task 4 `_build_turn_rail` from `ShellState.turn_order`. ✓
- Minimap slot → Task 4 (placeholder Panel). ✓
- Slide-in system dock (6 toggles incl. Spellbook, inert) → Task 2. ✓
- Command bar (HP/MP/gold, consumables, End Turn) → Task 4 `_build_command_bar`. ✓
- Thin tested `ShellState` → Task 1. ✓
- Refire control, prose-only, inert choices/free-text → Task 3. ✓
- Degrade on non-200 (§13) → reused `DmService`, asserted in Task 3. ✓
- Fact-harvest (§11) → Task 3 `narrate()`, asserted. ✓
- Viewport-fit gotcha → Task 4, asserted in test + Task 5 eyeball. ✓
- Visual gate → Task 5. ✓
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to". Every code step shows full code. ✓
**Type consistency:** `ShellState.seed()`, `TurnEntry.new(...)`, `SystemDock.setup`/`_on_handle_pressed`/`_screen_buttons`/`screen_requested`, `NarrationBook.setup`/`narrate`/`show_prose`/`last_degraded`/`_prose`, `MainWindowShell.service`/`book`/`dock`/`_log` — names match across the tasks that reference them. `NarrateResult.display_text`/`facts`/`degraded`, `DmResponse.ok`/`failed`, `FakeTransport.set_response`, `CanonLog.add_fact`/`established_facts`/`set_location`/`push_event`/`active_quests`/`add_humiliation`, `LogPlayer.new`/`PartyMember.new`/`Quest.new` — all match the existing code read during planning. ✓

View File

@@ -0,0 +1,96 @@
# Main Window shell (2a) — design
**Milestone:** M3-b (roadmap "Main Window shell (2a)").
**Charter:** §2 (code owns state, AI owns text), §16 (mockups are the UI bible), §13 (degraded DM, never an error), §11 (canon log).
**Mockup:** `mockups/Main Window + Wireframes.dc.html`, option **2a** (the four low-fi wireframes below it are ignored).
**Branch:** `feature/main-window-shell` off `dev`.
## 1. Goal
The exploration HUD — the frame every other screen lives in. A two-panel "tabletop split": an isometric world view (art slot) on the left, a permanent DM "narration book" on the right wired to the **proven** `/dm/narrate` loop. Around the world view: a turn-order rail, a minimap slot, a slide-in system dock, and a bottom command bar (vitals/gold, quick consumables, End Turn).
This milestone builds the **frame** and its **one live seam** (the DM narration panel). Everything else is faithful placeholder until the milestones that give it meaning (combat, inventory, the individual screens).
## 2. Scope decisions
Settled during brainstorming:
- **DM wire = prose only, with a refire control.** Narrate fires once on scene load to fill the book (considering-state + authored fallback + fact-harvest). One in-world refire affordance (`▸ The Master continues…`) refires it. The numbered response choices and the "describe your own action" free-text line are rendered faithfully but **inert** — there is no action system yet (combat/dialogue are later).
- **HUD readouts come from a thin, tested `ShellState` model**, not hardcoded scene values. Seed values now; later systems (combat, inventory) become its writers. This is the §2 state seam and the testable core.
- **System dock buttons are inert.** They emit `screen_requested(id)` (the routing seam) which is a no-op this milestone; the individual screens land later.
- **Dock slide animation is live** — it is the dock's visual identity and cheap.
- **Keybind navigation (I/C/J/K/M/P) is deferred** — a shortcut belongs with the screen it opens.
- **Dock has 6 items** (Inventory / Character / Quest Log / Spellbook / World Map / Party) — faithful to mock 2a. The brief listed 5 (no Spellbook); kept all 6 since the buttons are inert and it costs nothing. Flagged as a reconcile point — Spellbook is not one of the 11 mock screens.
## 3. Architecture
House pattern: **pure, tested cores behind thin scene shims** — the same shape as `DmService`, `ConsideringPhrases`, `MoveValidator` (pure logic unit-tested; the scene/tree wiring is the untested shim, eyeball-gated). Units, each with one purpose:
| Unit | Kind | §2 | Purpose |
|---|---|---|---|
| `ShellState` | `RefCounted`, **tested** | state | The HUD data the shell owns: `vitals {hp, hp_max, mp, mp_max, gold}`, `turn_order[]`, `consumables[]`, `round_label`, `location_label`, `dock_open`. Getters + `toggle_dock()`. Seeded now; later systems write it. |
| `TurnEntry` | `RefCounted` (or a typed dict inside `ShellState`), **tested** | state | One turn-order combatant: `initials`, `initiative`, `is_active`, `is_downed`, `side` (`you`/`ally`/`enemy`). |
| `SystemDock` | Control scene + script, thin shim | state | The 6 toggle buttons + gold handle. Slide+opacity `Tween`, arrow flip. Emits `screen_requested(id: StringName)` (inert). Toggle drives `ShellState.dock_open`. |
| `NarrationBook` | Control scene + script, thin shim | text | Right parchment panel. DM header (round · location), prose `RichTextLabel`, placeholder response-choices, inert free-text line, one refire affordance. Consumes a `NarrateResult`; injectable service seam so `FakeTransport` drives it in tests. |
| `MainWindowShell` | Control scene root, thin shim | — | The two-panel split (1180 world / 740 book). Builds the world side inline (DarkBay iso slot + caption, turn rail from state, minimap slot, mounts `SystemDock`, command bar). Constructs `DmService` + `ConsideringIndicator` and owns the narrate loop. Handles viewport-fit. |
### Reused, not rebuilt
- **Theme:** `client/assets/theme/game_theme.tres`, type-variation names from `ThemeKeys`, colours from `Palette`. Surfaces `ParchmentPanel` / `DarkBay` / `VignetteOverlay` (`client/scenes/theme/surfaces/`).
- **DM loop:** `DmService.narrate(canon_log) -> NarrateResult{display_text, facts, degraded}`, via `HttpTransport(HTTPRequest)` + `FallbackLibrary`. `DmTransport` is the injectable seam; `FakeTransport` (test double) already exists.
- **Considering-state:** `ConsideringIndicator.start(label)` / `.stop()` rotates authored phrases at 3.0s.
- **Seed canon log:** hand-built like `narrate_harness.gd`'s `_build_scene_log()` (`CanonLog` + `LogPlayer` + `PartyMember` + `Quest` + facts/humiliations), until character-creation (M4) and save/load (M9) construct the real one.
### The viewport-fit gotcha (from M3-a)
A root `Control` run directly (F6) is **not** reliably sized by full-rect anchors — it collapses to `(0,0)` and clips everything. `MainWindowShell._ready()` must set `size = get_viewport_rect().size` explicitly (no full-rect anchors on the root) and reconnect on `get_viewport().size_changed`, exactly as `theme_showcase.gd` does. The scene is authored at 1920×1080; display is already `canvas_items` stretch @ 1920×1080, window override 1600×900.
## 4. Data flow
**Load (`MainWindowShell._ready()`):**
1. `theme = load(game_theme.tres)`; fit to viewport; connect `size_changed`.
2. Build seed `ShellState` and seed `CanonLog`.
3. Build the world side (from `ShellState`) and mount `NarrationBook` + `SystemDock`.
4. Construct `DmService(HttpTransport(HTTPRequest), FallbackLibrary)` and `ConsideringIndicator`.
5. Fire the initial narrate (below).
**Narrate / refire:**
1. Disable the refire affordance.
2. `indicator.start(status_label)`.
3. `var r := await service.narrate(seed_log)`.
4. `indicator.stop()`.
5. `book.show_prose(r)` — set the `RichTextLabel`, surface `r.degraded` subtly.
6. `for f in r.facts: seed_log.add_fact(f)` (§11 — the caller applies the state write, explicit).
7. Re-enable the affordance.
Any non-200 → `DmService` already returns the authored fallback (§13); the 35s `HttpTransport` timeout bounds a hang. **No new failure surface.**
**Dock toggle:** handle pressed → `ShellState.toggle_dock()``SystemDock` Tweens slide/opacity and flips the handle arrow.
## 5. §2 ledger
- `ShellState`, `dock_open`, HUD numbers, turn order, consumable slots — **state**, client-owned.
- Narration prose — **text**, consumed from `/dm/narrate`, never a source of truth; only harvested `[FACT]` tags become state (§11).
- `screen_requested` routing, response choices, free-text line — inert seams; no state written, no text consumed, this milestone.
Nothing in the shell lets an AI response set persistent state directly.
## 6. Testing
TDD via GUT (`cd client && ./run_tests.sh`; `.gutconfig` promotes engine errors to failures — load defensively). New `class_name`s need `godot --headless --import` before GUT sees them.
- **`ShellState`** — seed shape; `toggle_dock()` flips `dock_open`; turn-order and consumable entries have the expected fields/values.
- **`TurnEntry`** — field round-trip; `side`/`is_active`/`is_downed` flags.
- **`SystemDock`** — emits `screen_requested` with the correct id per button; toggling updates state (signal + state assertions, no render needed).
- **`NarrationBook`** — given a `NarrateResult`, sets the prose text and surfaces `degraded`; the refire affordance invokes the injected service (drive with `FakeTransport`, assert prose updates + facts flow out).
- **`MainWindowShell`** — node-presence smoke (both panels + dock mount); viewport-fit yields non-zero `size`.
**Visual gate (required):** a human F6 run of the shell scene. Headless tests cannot prove rendering — this catches layout/collapse regressions the unit tests can't (the M3-a lesson).
## 7. Out of scope (this milestone)
- Any of the individual screens the dock opens (Inventory, Character, Quest, Map, Party, Spellbook) — inert buttons only.
- A real action system behind the response choices / free-text line — combat (M5) and dialogue (M6).
- Real HUD writers (combat vitals, inventory consumables) — seed values only.
- Keybind navigation, real isometric render, real minimap, real portraits — art slots / later.
- Save/load of shell state — M9.