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

33 KiB
Raw Blame History

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:

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:

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:

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
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: Dictionaryid: 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:

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:

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
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() -> voidasync; 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:

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:

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
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 = nullinjection 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:

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:

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
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. ✓