Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-11-main-window-shell.md
2026-07-11 08:43:29 -05:00

889 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. ✓