docs(plan): M3-b Main Window shell implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
888
docs/superpowers/plans/2026-07-11-main-window-shell.md
Normal file
888
docs/superpowers/plans/2026-07-11-main-window-shell.md
Normal 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. ✓
|
||||
Reference in New Issue
Block a user