Compare commits
36 Commits
b4b72e472e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fb173008 | |||
| f5e6c59f2a | |||
| 7f155b8de2 | |||
| d553213563 | |||
| 5a9f39ea48 | |||
| 583d32456a | |||
| 6b10b6ea6c | |||
| 8d963e8030 | |||
| e169885d90 | |||
| f763aa0c31 | |||
| 10f89baef9 | |||
| aaff2c8554 | |||
| a106be673f | |||
| 1ae62fe0db | |||
| 8f3aa75fa9 | |||
| 63cbc8c16e | |||
| 7471d2e37f | |||
| 6291f21a00 | |||
| 34bf064904 | |||
| 31759ef848 | |||
| 4494856a44 | |||
| c5f127e0f1 | |||
| 6589d7c4ed | |||
| 2227d703f6 | |||
| fe8e24cf39 | |||
| 0bbf94289a | |||
| 1621a38b29 | |||
| 866158cc0d | |||
| 1c4d19492d | |||
| 537b0e1d18 | |||
| 057b83679b | |||
| c6b8a651aa | |||
| e4db022c4b | |||
| 17ef142689 | |||
| 7a74a8de94 | |||
| 333d901931 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,9 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Local scratch — never versioned
|
||||
# ─────────────────────────────────────────────
|
||||
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
|
||||
/screenshots/
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Godot 4.7 (client/)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
3
clean_superpowers.sh
Executable file
3
clean_superpowers.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/env bash
|
||||
rm .superpowers/sdd/*.diff
|
||||
rm .superpowers/sdd/*.md
|
||||
@@ -1,13 +1,29 @@
|
||||
[gd_resource type="Theme" format=3]
|
||||
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"]
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"]
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="2_4lwr2"]
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="3_eckwq"]
|
||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
|
||||
content_margin_left = 10.0
|
||||
content_margin_top = 16.0
|
||||
content_margin_right = 10.0
|
||||
content_margin_bottom = 14.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q5gpk"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -23,7 +39,7 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -39,7 +55,7 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -55,7 +71,19 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
|
||||
content_margin_left = 8.0
|
||||
content_margin_top = 3.0
|
||||
content_margin_right = 8.0
|
||||
content_margin_bottom = 3.0
|
||||
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||
corner_radius_top_left = 5
|
||||
corner_radius_top_right = 5
|
||||
corner_radius_bottom_right = 5
|
||||
corner_radius_bottom_left = 5
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -71,9 +99,9 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"]
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -89,7 +117,7 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -105,7 +133,7 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -121,7 +149,7 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -137,7 +165,7 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -154,7 +182,7 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -170,46 +198,14 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_dtr07"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
@@ -230,13 +226,45 @@ border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -252,7 +280,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -268,7 +296,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -284,7 +312,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -300,7 +328,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -316,41 +344,53 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 4
|
||||
corner_radius_bottom_left = 4
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
content_margin_left = 10.0
|
||||
content_margin_top = 16.0
|
||||
content_margin_right = 10.0
|
||||
content_margin_bottom = 14.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
|
||||
content_margin_left = 9.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 9.0
|
||||
content_margin_bottom = 2.0
|
||||
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
corner_radius_top_left = 5
|
||||
corner_radius_top_right = 5
|
||||
corner_radius_bottom_right = 5
|
||||
corner_radius_bottom_left = 5
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xdylb"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -360,31 +400,95 @@ border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 3
|
||||
border_width_top = 3
|
||||
border_width_right = 3
|
||||
border_width_bottom = 3
|
||||
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_onsig"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gw0t7"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u6gcs"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -400,7 +504,109 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0d7vs"]
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3fnfk"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ta3oh"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ismtf"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 6.0
|
||||
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gs634"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -417,80 +623,136 @@ corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("3_eckwq")
|
||||
default_font = ExtResource("2_4lwr2")
|
||||
default_font_size = 18
|
||||
AbilityCard/base_type = &"PanelContainer"
|
||||
AbilityCard/styles/panel = SubResource("StyleBoxFlat_cqbvn")
|
||||
Accent/base_type = &"Label"
|
||||
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
Accent/font_sizes/font_size = 24
|
||||
Accent/fonts/font = ExtResource("1_605w4")
|
||||
CardBody/base_type = &"Label"
|
||||
CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||
CardBody/font_sizes/font_size = 14
|
||||
CardBody/fonts/font = ExtResource("2_4lwr2")
|
||||
CardTitle/base_type = &"Label"
|
||||
CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||
CardTitle/font_sizes/font_size = 20
|
||||
CardTitle/fonts/font = ExtResource("2_4lwr2")
|
||||
Chip/base_type = &"Button"
|
||||
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
Chip/font_sizes/font_size = 11
|
||||
Chip/fonts/font = ExtResource("2_4lwr2")
|
||||
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
|
||||
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
|
||||
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
|
||||
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
|
||||
Chip/font_sizes/font_size = 12
|
||||
Chip/fonts/font = ExtResource("3_eckwq")
|
||||
Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk")
|
||||
Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc")
|
||||
Chip/styles/normal = SubResource("StyleBoxFlat_73lme")
|
||||
Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr")
|
||||
ChosenTag/base_type = &"Label"
|
||||
ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
ChosenTag/font_sizes/font_size = 11
|
||||
ChosenTag/fonts/font = ExtResource("3_eckwq")
|
||||
ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r")
|
||||
DarkPanel/base_type = &"PanelContainer"
|
||||
DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr")
|
||||
DarkPanel/styles/panel = SubResource("StyleBoxFlat_2tooq")
|
||||
DockButton/base_type = &"Button"
|
||||
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
|
||||
DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r")
|
||||
DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq")
|
||||
DockButton/styles/normal = SubResource("StyleBoxFlat_4kten")
|
||||
DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm")
|
||||
DockButton/styles/focus = SubResource("StyleBoxEmpty_4kten")
|
||||
DockButton/styles/hover = SubResource("StyleBoxFlat_p6wtm")
|
||||
DockButton/styles/normal = SubResource("StyleBoxFlat_0fjdt")
|
||||
DockButton/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
|
||||
Heading/base_type = &"Label"
|
||||
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||
Heading/font_sizes/font_size = 34
|
||||
Heading/fonts/font = ExtResource("3_eckwq")
|
||||
Heading/fonts/font = ExtResource("2_4lwr2")
|
||||
ItemTile/base_type = &"PanelContainer"
|
||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
|
||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r")
|
||||
ItemTileEmpty/base_type = &"PanelContainer"
|
||||
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
|
||||
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk")
|
||||
Mono/base_type = &"Label"
|
||||
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
Mono/font_sizes/font_size = 13
|
||||
Mono/fonts/font = ExtResource("2_4lwr2")
|
||||
Mono/font_sizes/font_size = 14
|
||||
Mono/fonts/font = ExtResource("3_eckwq")
|
||||
ParchmentButton/base_type = &"Button"
|
||||
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r")
|
||||
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk")
|
||||
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7")
|
||||
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07")
|
||||
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx")
|
||||
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_nh0e7")
|
||||
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_dtr07")
|
||||
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nmowx")
|
||||
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_oi067")
|
||||
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_ukblj")
|
||||
ParchmentCard/base_type = &"PanelContainer"
|
||||
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
|
||||
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_utt73")
|
||||
ParchmentInset/base_type = &"PanelContainer"
|
||||
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
|
||||
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_5u8fh")
|
||||
PrimaryCTA/base_type = &"Button"
|
||||
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
|
||||
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
|
||||
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
|
||||
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak")
|
||||
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_p0qbh")
|
||||
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_6y6ak")
|
||||
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_txxuc")
|
||||
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_7d6ug")
|
||||
PrimaryCard/base_type = &"PanelContainer"
|
||||
PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk")
|
||||
PrimaryTag/base_type = &"Label"
|
||||
PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
PrimaryTag/font_sizes/font_size = 10
|
||||
PrimaryTag/fonts/font = ExtResource("3_eckwq")
|
||||
PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k")
|
||||
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||
RichTextLabel/font_sizes/normal_font_size = 20
|
||||
RichTextLabel/font_sizes/normal_font_size = 22
|
||||
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
|
||||
RichTextLabel/fonts/normal_font = ExtResource("3_eckwq")
|
||||
RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2")
|
||||
SectionLabel/base_type = &"Label"
|
||||
SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1)
|
||||
SectionLabel/font_sizes/font_size = 14
|
||||
SectionLabel/fonts/font = ExtResource("3_eckwq")
|
||||
SelectCard/base_type = &"Button"
|
||||
SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||
SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||
SelectCard/styles/disabled = SubResource("StyleBoxFlat_wmbj4")
|
||||
SelectCard/styles/focus = SubResource("StyleBoxEmpty_xdylb")
|
||||
SelectCard/styles/hover = SubResource("StyleBoxFlat_pjnll")
|
||||
SelectCard/styles/normal = SubResource("StyleBoxFlat_klelq")
|
||||
SelectCard/styles/pressed = SubResource("StyleBoxFlat_i2sl5")
|
||||
SkillChip/base_type = &"Button"
|
||||
SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||
SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||
SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||
SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
SkillChip/font_sizes/font_size = 14
|
||||
SkillChip/fonts/font = ExtResource("3_eckwq")
|
||||
SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig")
|
||||
SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7")
|
||||
SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g")
|
||||
SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac")
|
||||
SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs")
|
||||
SkillChipGranted/base_type = &"Button"
|
||||
SkillChipGranted/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
SkillChipGranted/colors/font_disabled_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
SkillChipGranted/font_sizes/font_size = 14
|
||||
SkillChipGranted/fonts/font = ExtResource("3_eckwq")
|
||||
SkillChipGranted/styles/disabled = SubResource("StyleBoxFlat_nufy3")
|
||||
SkillChipGranted/styles/focus = SubResource("StyleBoxEmpty_0d7vs")
|
||||
SkillChipGranted/styles/hover = SubResource("StyleBoxFlat_nufy3")
|
||||
SkillChipGranted/styles/normal = SubResource("StyleBoxFlat_nufy3")
|
||||
SkillChipGranted/styles/pressed = SubResource("StyleBoxFlat_nufy3")
|
||||
Tab/base_type = &"Button"
|
||||
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
|
||||
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
|
||||
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
|
||||
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
|
||||
Tab/styles/focus = SubResource("StyleBoxEmpty_3fnfk")
|
||||
Tab/styles/hover = SubResource("StyleBoxFlat_2txdw")
|
||||
Tab/styles/normal = SubResource("StyleBoxFlat_w7qkp")
|
||||
Tab/styles/pressed = SubResource("StyleBoxFlat_rthnr")
|
||||
TabActive/base_type = &"Button"
|
||||
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
|
||||
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
|
||||
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
|
||||
TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq")
|
||||
TabActive/styles/focus = SubResource("StyleBoxEmpty_ta3oh")
|
||||
TabActive/styles/hover = SubResource("StyleBoxFlat_ohwwq")
|
||||
TabActive/styles/normal = SubResource("StyleBoxFlat_ismtf")
|
||||
TabActive/styles/pressed = SubResource("StyleBoxFlat_gs634")
|
||||
TitleKicker/base_type = &"Label"
|
||||
TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||
TitleKicker/font_sizes/font_size = 13
|
||||
TitleKicker/fonts/font = ExtResource("2_4lwr2")
|
||||
TitleKicker/fonts/font = ExtResource("3_eckwq")
|
||||
TitleLogo/base_type = &"Label"
|
||||
TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1)
|
||||
TitleLogo/font_sizes/font_size = 116
|
||||
TitleLogo/fonts/font = ExtResource("3_eckwq")
|
||||
TitleLogo/fonts/font = ExtResource("2_4lwr2")
|
||||
|
||||
@@ -17,7 +17,15 @@ Cross-cutting design (anything touching both client and api) goes in the root [`
|
||||
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
|
||||
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
|
||||
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
|
||||
+ `scripts/ui/shell/*.gd`.
|
||||
+ `scripts/ui/shell/*.gd`, and `scenes/creation/CharacterCreation.tscn` +
|
||||
`scripts/ui/creation/{character_creation,creation_draft,creation_copy}.gd` (M4-b).
|
||||
|
||||
- **Clickable cards are Buttons with `mouse_filter = 2` on every child.** A card that
|
||||
holds a name, a blurb and a trait line is a `Button` (Button-base variation, so
|
||||
`normal`/`hover`/`pressed` actually skin it) with a `VBoxContainer` of Labels inside.
|
||||
Every child needs `mouse_filter = 2` (IGNORE) or the Label swallows the click and the
|
||||
card never toggles. `pressed` on a `toggle_mode` Button *is* the "chosen" state — no
|
||||
script-side stylebox swapping.
|
||||
|
||||
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
|
||||
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the
|
||||
|
||||
@@ -11,19 +11,15 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="coc-rpg"
|
||||
config/version="0.01-alpha"
|
||||
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
|
||||
run/main_scene="res://scenes/title/TitleScreen.tscn"
|
||||
config/version="0.01-alpha"
|
||||
run/main_scene="res://scenes/Main.tscn"
|
||||
config/features=PackedStringArray("4.7")
|
||||
|
||||
[display]
|
||||
|
||||
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1080
|
||||
; Open a smaller 1600x900 window that scales the 1920x1080 canvas down, so it
|
||||
; doesn't fill the whole screen. canvas_items + keep aspect does the scaling.
|
||||
window/size/window_width_override=1600
|
||||
window/size/window_height_override=900
|
||||
window/size/window_width_override=1280
|
||||
window/size/window_height_override=720
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="keep"
|
||||
|
||||
6
client/scenes/Main.tscn
Normal file
6
client/scenes/Main.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
|
||||
|
||||
[node name="Main" type="Node"]
|
||||
script = ExtResource("1")
|
||||
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,17 +99,17 @@ static func from_dict(d: Dictionary) -> CanonLog:
|
||||
if v != SCHEMA_VERSION:
|
||||
push_error("unsupported canon_log schema_version: %s" % v)
|
||||
return null
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.from_dict(d.get("player", {}))
|
||||
log.location = LogLocation.from_dict(d.get("location", {}))
|
||||
var out := CanonLog.new()
|
||||
out.player = LogPlayer.from_dict(d.get("player", {}))
|
||||
out.location = LogLocation.from_dict(d.get("location", {}))
|
||||
for m in d.get("party", []):
|
||||
log.party.append(PartyMember.from_dict(m))
|
||||
out.party.append(PartyMember.from_dict(m))
|
||||
for e in d.get("recent_events", []):
|
||||
log.push_event(e)
|
||||
out.push_event(e)
|
||||
for f in d.get("established_facts", []):
|
||||
log.add_fact(f)
|
||||
out.add_fact(f)
|
||||
for q in d.get("active_quests", []):
|
||||
log.active_quests.append(Quest.from_dict(q))
|
||||
out.active_quests.append(Quest.from_dict(q))
|
||||
for h in d.get("humiliations", []):
|
||||
log.humiliations.append(Humiliation.from_dict(h))
|
||||
return log
|
||||
out.humiliations.append(Humiliation.from_dict(h))
|
||||
return out
|
||||
|
||||
93
client/scripts/main.gd
Normal file
93
client/scripts/main.gd
Normal file
@@ -0,0 +1,93 @@
|
||||
class_name Main
|
||||
extends Node
|
||||
## The single home for the game's screen sequence: Title → Creation → Shell.
|
||||
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
|
||||
## editor-first rule does not apply (that rule governs layout scenes). It owns one
|
||||
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
|
||||
## the injection seam change_scene_to_file cannot provide.
|
||||
##
|
||||
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
|
||||
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
|
||||
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
|
||||
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
|
||||
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
|
||||
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
|
||||
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
|
||||
|
||||
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
|
||||
# do for their own deps. _ready builds the real ones when nothing was supplied.
|
||||
var world: ContentDB = null
|
||||
var service: DmService = null
|
||||
var origin: Dictionary = {}
|
||||
|
||||
var _current: Node = null
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if world == null:
|
||||
world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
if origin.is_empty():
|
||||
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
if service == null:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||
_show_title()
|
||||
|
||||
|
||||
func _show(next: PackedScene, inject: Callable) -> void:
|
||||
## The one swap primitive. inject runs after instantiate() (which does NOT run
|
||||
## _ready) and before add_child (which does) — the injection window. The outgoing
|
||||
## screen is queue_free'd (deferred): a signal handler must never free the node
|
||||
## still executing up its own call stack.
|
||||
var old := _current
|
||||
var screen := next.instantiate()
|
||||
inject.call(screen)
|
||||
_current = screen
|
||||
add_child(screen)
|
||||
if old != null:
|
||||
old.queue_free()
|
||||
|
||||
|
||||
func _show_title() -> void:
|
||||
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
|
||||
|
||||
|
||||
func _show_creation() -> void:
|
||||
_show(CREATION, func(s):
|
||||
s.world = world
|
||||
s.origin = origin
|
||||
s.creation_confirmed.connect(_start_campaign))
|
||||
|
||||
|
||||
func _show_shell(log: CanonLog, state: GameState) -> void:
|
||||
_show(SHELL, func(s):
|
||||
s.service = service
|
||||
s.injected_log = log
|
||||
s.injected_state = state)
|
||||
|
||||
|
||||
func _on_menu(id: StringName) -> void:
|
||||
match id:
|
||||
&"new_game":
|
||||
_show_creation()
|
||||
&"quit":
|
||||
get_tree().quit()
|
||||
_:
|
||||
pass # continue / load land in later milestones (M9); inert for now
|
||||
|
||||
|
||||
func _start_campaign(creation: Dictionary) -> void:
|
||||
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
|
||||
## a saga can synthesize one and call this directly (skipping creation).
|
||||
var result := NewGame.construct(origin, world, creation)
|
||||
if not result.get("ok", false):
|
||||
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
|
||||
# the caller and §13 forbids surfacing an error. Log and stay put.
|
||||
push_error("Main: construct rejected a creation: %s" % str(result.get("errors", [])))
|
||||
return
|
||||
_show_shell(result["log"], result["state"])
|
||||
1
client/scripts/main.gd.uid
Normal file
1
client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://du7rwup648n60
|
||||
@@ -30,9 +30,9 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
|
||||
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
|
||||
# Luck. Change the order and every existing seed produces a different character.
|
||||
var attrs: Dictionary = {}
|
||||
for stat in Attributes.IDS:
|
||||
attrs[stat] = _roll_attribute(rng)
|
||||
# roll_attributes() is the ONE implementation — the creation screen calls the same
|
||||
# function to DISPLAY the roll, so what the player saw is what he gets (§2/§10).
|
||||
var attrs: Dictionary = _roll_five(rng)
|
||||
|
||||
var bc: Dictionary = origin.get("build_constraints", {})
|
||||
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
||||
@@ -58,31 +58,31 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
sheet.mp = sheet.max_mp()
|
||||
state.sheet = sheet
|
||||
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
|
||||
var canon := CanonLog.new()
|
||||
canon.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
|
||||
sheet.calling_id, state.luck_descriptor())
|
||||
|
||||
var loc: Dictionary = world.location(origin["start_location_id"])
|
||||
log.set_location(loc.get("id", ""), loc.get("name", ""))
|
||||
canon.set_location(loc.get("id", ""), loc.get("name", ""))
|
||||
|
||||
var overrides: Dictionary = origin.get("disposition_overrides", {})
|
||||
var companion_ids := {}
|
||||
for c in world.companions():
|
||||
companion_ids[c["id"]] = true
|
||||
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
||||
canon.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
||||
for npc_id in overrides:
|
||||
if not companion_ids.has(npc_id):
|
||||
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
|
||||
|
||||
for line in origin.get("situation", []):
|
||||
log.push_event(line)
|
||||
canon.push_event(line)
|
||||
for fact in origin.get("opening_facts", []):
|
||||
log.add_fact(fact)
|
||||
canon.add_fact(fact)
|
||||
|
||||
var quest_id: Variant = origin.get("start_quest_id", null)
|
||||
if quest_id != null:
|
||||
var qd: Dictionary = world.quest(quest_id)
|
||||
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
|
||||
canon.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
|
||||
qd.get("objective", "")))
|
||||
|
||||
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
|
||||
@@ -90,7 +90,31 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
for g in origin.get("inventory_grants", []):
|
||||
state.grant(g.get("item_id", ""), int(g.get("qty", 0)))
|
||||
|
||||
return {"ok": true, "errors": [], "log": log, "state": state}
|
||||
return {"ok": true, "errors": [], "log": canon, "state": state}
|
||||
|
||||
|
||||
static func roll_attributes(character_seed: int) -> Dictionary:
|
||||
## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct
|
||||
## calls it to BUILD it. One implementation, so the two can never disagree.
|
||||
## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity
|
||||
## call into a silent miscompile that binds the wrong value into the wrong slot.
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = character_seed
|
||||
return _roll_five(rng)
|
||||
|
||||
|
||||
static func _roll_five(rng: RandomNumberGenerator) -> Dictionary:
|
||||
var attrs: Dictionary = {}
|
||||
for stat in Attributes.IDS:
|
||||
attrs[stat] = _roll_attribute(rng)
|
||||
return attrs
|
||||
|
||||
|
||||
static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||||
## PUBLIC. The creation screen gates its CTA on this, so the player cannot press
|
||||
## his way into a rejection. construct() still calls it and still does not trust
|
||||
## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS.
|
||||
return _validate(origin, world, creation)
|
||||
|
||||
|
||||
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
|
||||
|
||||
@@ -31,10 +31,12 @@ static func format(copper: int) -> String:
|
||||
return "0c"
|
||||
var parts: Array[String] = []
|
||||
var left := copper
|
||||
var gold := left / GOLD # int division
|
||||
@warning_ignore("integer_division") # denomination math — the remainder is carried, not lost
|
||||
var gold := left / GOLD
|
||||
if gold > 0:
|
||||
parts.append("%dg" % gold)
|
||||
left -= gold * GOLD
|
||||
@warning_ignore("integer_division")
|
||||
var silver := left / SILVER
|
||||
if silver > 0:
|
||||
parts.append("%ds" % silver)
|
||||
|
||||
@@ -41,11 +41,33 @@ static func build_theme() -> Theme:
|
||||
_dock_button(theme)
|
||||
_parchment_button(theme)
|
||||
_chip(theme)
|
||||
_section_label(theme)
|
||||
_select_card(theme)
|
||||
_skill_chip(theme)
|
||||
_skill_chip_granted(theme)
|
||||
_tags_and_primary(theme)
|
||||
_tiles(theme)
|
||||
_cards_and_panels(theme)
|
||||
return theme
|
||||
|
||||
|
||||
static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat:
|
||||
# A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and
|
||||
# button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the
|
||||
# control, which is what made the badge swallow the ability card's rolled value.
|
||||
# The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN).
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = bg
|
||||
s.border_color = bg
|
||||
s.set_border_width_all(0)
|
||||
s.set_corner_radius_all(radius)
|
||||
s.content_margin_left = margin_x
|
||||
s.content_margin_right = margin_x
|
||||
s.content_margin_top = margin_y
|
||||
s.content_margin_bottom = margin_y
|
||||
return s
|
||||
|
||||
|
||||
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = bg
|
||||
@@ -77,8 +99,22 @@ static func _fonts(theme: Theme) -> void:
|
||||
# Mono chrome role.
|
||||
theme.set_type_variation(ThemeKeys.MONO, "Label")
|
||||
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
|
||||
theme.set_font_size(&"font_size", ThemeKeys.MONO, 13)
|
||||
theme.set_font_size(&"font_size", ThemeKeys.MONO, 14)
|
||||
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
|
||||
# Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so
|
||||
# they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a
|
||||
# Label left bare inherits it: the race and calling names shipped invisible on the
|
||||
# parchment sheet for exactly that reason. There is no such thing as an unstyled
|
||||
# Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c
|
||||
# title, 13px italic-serif #6a5a3a blurb.)
|
||||
theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label")
|
||||
theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif)
|
||||
theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 20)
|
||||
theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING)
|
||||
theme.set_type_variation(ThemeKeys.CARD_BODY, "Label")
|
||||
theme.set_font(&"font", ThemeKeys.CARD_BODY, serif)
|
||||
theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 14)
|
||||
theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED)
|
||||
|
||||
|
||||
static func _title_type(theme: Theme) -> void:
|
||||
@@ -103,7 +139,7 @@ static func _rich_text(theme: Theme) -> void:
|
||||
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
|
||||
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
|
||||
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
|
||||
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
|
||||
theme.set_font_size(&"normal_font_size", "RichTextLabel", 22)
|
||||
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
|
||||
|
||||
|
||||
@@ -175,10 +211,112 @@ static func _chip(theme: Theme) -> void:
|
||||
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
|
||||
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||
theme.set_font(&"font", k, load(MONO))
|
||||
theme.set_font_size(&"font_size", k, 11)
|
||||
theme.set_font_size(&"font_size", k, 12)
|
||||
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
|
||||
|
||||
|
||||
static func _section_label(theme: Theme) -> void:
|
||||
# The mono section headers on the parchment sheet (RACE / CALLING / NAME …).
|
||||
# Mono's own colour is for dark surfaces; on parchment it needs the muted ink.
|
||||
theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label")
|
||||
theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO))
|
||||
theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 14)
|
||||
theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED)
|
||||
|
||||
|
||||
static func _select_card(theme: Theme) -> void:
|
||||
# Race + calling cards. Button-base (a PanelContainer variation's lone `panel`
|
||||
# stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state:
|
||||
# the card is a toggle in a ButtonGroup, so `pressed` persists after the click.
|
||||
var k := ThemeKeys.SELECT_CARD
|
||||
theme.set_type_variation(k, "Button")
|
||||
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12))
|
||||
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12))
|
||||
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12))
|
||||
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12))
|
||||
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||
theme.set_color(&"font_color", k, Palette.INK_HEADING)
|
||||
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
|
||||
|
||||
|
||||
static func _skill_chip(theme: Theme) -> void:
|
||||
# A proficiency chip is a TOGGLE with three states the player must be able to
|
||||
# read at a glance: unpicked (parchment), picked (crimson), and granted-by-race
|
||||
# (dim + inert — the elf already has perception, so the chip cannot be pressed).
|
||||
var k := ThemeKeys.SKILL_CHIP
|
||||
theme.set_type_variation(k, "Button")
|
||||
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14))
|
||||
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14))
|
||||
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
|
||||
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14))
|
||||
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||
theme.set_font(&"font", k, load(MONO))
|
||||
theme.set_font_size(&"font_size", k, 14)
|
||||
theme.set_color(&"font_color", k, Palette.INK_LABEL)
|
||||
theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT)
|
||||
theme.set_color(&"font_hover_color", k, Palette.INK_LABEL)
|
||||
theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3)
|
||||
|
||||
|
||||
static func _skill_chip_granted(theme: Theme) -> void:
|
||||
# The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A
|
||||
# race-GRANTED skill (the elf already has perception) must read "you already have
|
||||
# this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the
|
||||
# binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled.
|
||||
# It is inert like a disabled chip, so every interactive state paints the owned box.
|
||||
var k := ThemeKeys.SKILL_CHIP_GRANTED
|
||||
theme.set_type_variation(k, "Button")
|
||||
var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14)
|
||||
theme.set_stylebox(&"normal", k, owned)
|
||||
theme.set_stylebox(&"hover", k, owned)
|
||||
theme.set_stylebox(&"pressed", k, owned)
|
||||
theme.set_stylebox(&"disabled", k, owned)
|
||||
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||
theme.set_font(&"font", k, load(MONO))
|
||||
theme.set_font_size(&"font_size", k, 14)
|
||||
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
|
||||
theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT)
|
||||
|
||||
|
||||
static func _tags_and_primary(theme: Theme) -> void:
|
||||
# The two little flags that ride on a card's top edge, and the gold ring the
|
||||
# calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4.
|
||||
var chosen := ThemeKeys.CHOSEN_TAG
|
||||
theme.set_type_variation(chosen, "Label")
|
||||
theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3))
|
||||
theme.set_font(&"font", chosen, load(MONO))
|
||||
theme.set_font_size(&"font_size", chosen, 11)
|
||||
theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT)
|
||||
|
||||
var primary_tag := ThemeKeys.PRIMARY_TAG
|
||||
theme.set_type_variation(primary_tag, "Label")
|
||||
theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2))
|
||||
theme.set_font(&"font", primary_tag, load(MONO))
|
||||
theme.set_font_size(&"font_size", primary_tag, 10)
|
||||
theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT)
|
||||
|
||||
# The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY
|
||||
# flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly
|
||||
# under the badge — they collided. The mock's card is padding:16px 10px 14px, and that
|
||||
# 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the
|
||||
# shell shares it, and the shell has no badge.
|
||||
theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD,
|
||||
_ability_card(Palette.PARCHMENT_BORDER, 1))
|
||||
theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer")
|
||||
theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer")
|
||||
theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD,
|
||||
_ability_card(Palette.INK_GOLD, 2))
|
||||
|
||||
|
||||
static func _ability_card(border: Color, width: int) -> StyleBoxFlat:
|
||||
var s := _flat(Palette.PARCHMENT_CARD, border, width, 12)
|
||||
s.content_margin_top = 16
|
||||
s.content_margin_bottom = 14
|
||||
s.content_margin_left = 10
|
||||
s.content_margin_right = 10
|
||||
return s
|
||||
|
||||
|
||||
static func _tiles(theme: Theme) -> void:
|
||||
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
|
||||
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")
|
||||
|
||||
@@ -43,6 +43,7 @@ const INK_BODY := Color("4a3f2c")
|
||||
const INK_MUTED := Color("6a5a3a")
|
||||
const INK_LABEL := Color("5a4326")
|
||||
const INK_GOLD := Color("8f6a2a")
|
||||
const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet)
|
||||
|
||||
# --- Text on dark ---
|
||||
const CREAM := Color("e8ddc8")
|
||||
|
||||
@@ -16,6 +16,17 @@ const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
||||
const PARCHMENT_CARD := &"ParchmentCard"
|
||||
const PARCHMENT_INSET := &"ParchmentInset"
|
||||
const DARK_PANEL := &"DarkPanel"
|
||||
const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN
|
||||
const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / can't-pick-now (dim)
|
||||
const SKILL_CHIP_GRANTED := &"SkillChipGranted" # a race-GRANTED skill: owned (gold + ✓), inert
|
||||
const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag
|
||||
const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag
|
||||
const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary
|
||||
const ABILITY_CARD := &"AbilityCard" # ...and its UNringed state. Not ParchmentCard: the
|
||||
# ability card carries a badge on its top edge and
|
||||
# needs the headroom for it (mock padding-top 16 vs
|
||||
# the generic card's 6), and ParchmentCard is shared
|
||||
# with the shell.
|
||||
|
||||
## Font roles (Label type-variations carrying a font, not a stylebox). Kept
|
||||
## separate from ALL below, which is stylebox variations + their base type only.
|
||||
@@ -24,6 +35,40 @@ const ACCENT := &"Accent"
|
||||
const MONO := &"Mono"
|
||||
const TITLE_LOGO := &"TitleLogo"
|
||||
const TITLE_KICKER := &"TitleKicker"
|
||||
const SECTION_LABEL := &"SectionLabel"
|
||||
const CARD_TITLE := &"CardTitle" # a select-card's name, dark ink ON PARCHMENT
|
||||
const CARD_BODY := &"CardBody" # a select-card's blurb, muted ink ON PARCHMENT
|
||||
|
||||
## Every FONT role + the base Control type it decorates. Deliberately NOT folded
|
||||
## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere
|
||||
## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a
|
||||
## second enumerable set so a guard that means "every variation the builder
|
||||
## touches" can iterate BOTH and never silently cover only half of them: the
|
||||
## drift guard used to iterate ALL alone, so a font, a font size or a font colour
|
||||
## could change in the builder and the committed .tres stay stale, suite green.
|
||||
const FONT_ROLES := {
|
||||
HEADING: "Label",
|
||||
ACCENT: "Label",
|
||||
MONO: "Label",
|
||||
TITLE_LOGO: "Label",
|
||||
TITLE_KICKER: "Label",
|
||||
SECTION_LABEL: "Label",
|
||||
CARD_TITLE: "Label",
|
||||
CARD_BODY: "Label",
|
||||
}
|
||||
|
||||
## Base Control types the builder styles DIRECTLY — no set_type_variation is
|
||||
## ever called for these, so a fresh Theme reports get_type_variation_base("")
|
||||
## for them. Kept as its own set (not folded into ALL or FONT_ROLES) because a
|
||||
## base type is not a variation: nothing sets `theme_type_variation` to
|
||||
## "RichTextLabel" anywhere, the node's own class name is the lookup key.
|
||||
## Today: RichTextLabel, the builder's prose voice (mock README Typography:
|
||||
## serif body/italic emphasis) — the creation screen's origin panel and
|
||||
## calling detail panel are RichTextLabels rendering this styling directly,
|
||||
## i.e. every line of DM prose the screen shows.
|
||||
const BASE_TYPES := {
|
||||
&"RichTextLabel": true,
|
||||
}
|
||||
|
||||
## Every variation name + the base Control type it decorates. The builder and the
|
||||
## test both iterate this so they can never drift apart.
|
||||
@@ -39,4 +84,11 @@ const ALL := {
|
||||
PARCHMENT_CARD: "PanelContainer",
|
||||
PARCHMENT_INSET: "PanelContainer",
|
||||
DARK_PANEL: "PanelContainer",
|
||||
SELECT_CARD: "Button",
|
||||
SKILL_CHIP: "Button",
|
||||
SKILL_CHIP_GRANTED: "Button",
|
||||
CHOSEN_TAG: "Label",
|
||||
PRIMARY_TAG: "Label",
|
||||
PRIMARY_CARD: "PanelContainer",
|
||||
ABILITY_CARD: "PanelContainer",
|
||||
}
|
||||
|
||||
304
client/scripts/ui/creation/character_creation.gd
Normal file
304
client/scripts/ui/creation/character_creation.gd
Normal file
@@ -0,0 +1,304 @@
|
||||
class_name CharacterCreation
|
||||
extends Control
|
||||
## The creation screen (mock: Character Creation). Layout is authored in
|
||||
## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This
|
||||
## script does on-load work only: @onready refs, signal wiring, and binding the
|
||||
## draft into AUTHORED nodes. It creates no nodes and it owns no rules.
|
||||
##
|
||||
## Every rule lives in CreationDraft; every display string derived from a rules
|
||||
## table lives in CreationCopy; the prose lives in ContentDB. This file is glue.
|
||||
##
|
||||
## §2: the screen never hands an attribute value to anything. It emits a seed and
|
||||
## four choices, and NewGame.construct re-rolls from that seed itself.
|
||||
## §7: LCK appears nowhere here. Five ability cards. Never six.
|
||||
|
||||
signal creation_confirmed(creation: Dictionary)
|
||||
|
||||
## Shown under the CTA when the draft is legal. When it is NOT legal, the first
|
||||
## validation error takes this slot instead — the player is told what is wrong, in
|
||||
## the same place, in the same dry voice. §13: even the nag is content.
|
||||
const CTA_HINT := "no second chances out there"
|
||||
|
||||
## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does
|
||||
## for DmService. _ready builds the real ones when nothing was supplied.
|
||||
var world: ContentDB = null
|
||||
var origin: Dictionary = {}
|
||||
|
||||
var draft: CreationDraft
|
||||
|
||||
var _race_cards: Array = []
|
||||
var _calling_cards: Array = []
|
||||
var _pool_chips: Array = []
|
||||
var _bonus_chips: Array = []
|
||||
var _ability_cards: Array = []
|
||||
|
||||
@onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name
|
||||
@onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub
|
||||
@onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text
|
||||
@onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards
|
||||
@onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards
|
||||
@onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb
|
||||
@onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics
|
||||
@onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow
|
||||
@onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count
|
||||
@onready var _bonus_row: HFlowContainer = $Split/Sheet/Body/SkillSection/BonusRow
|
||||
@onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards
|
||||
@onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points
|
||||
@onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll
|
||||
@onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit
|
||||
@onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter
|
||||
@onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# The .tscn root has a fixed size and NO full-rect anchors — a root Control run
|
||||
# via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug).
|
||||
_fit_to_viewport()
|
||||
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||
|
||||
if world == null:
|
||||
world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
if origin.is_empty():
|
||||
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
|
||||
_collect_authored_nodes()
|
||||
_wire()
|
||||
|
||||
draft = CreationDraft.fresh()
|
||||
draft.set_race(Races.IDS[0])
|
||||
var allowed := _allowed_callings()
|
||||
if not allowed.is_empty():
|
||||
draft.set_calling(allowed[0])
|
||||
# A degenerate origin (zero allowed callings, or no build_constraints at all)
|
||||
# leaves calling_id unset rather than crashing — §13, never an error thrown
|
||||
# at the player. The CTA simply stays disabled; there is no calling to invent.
|
||||
_bind()
|
||||
|
||||
|
||||
func _fit_to_viewport() -> void:
|
||||
size = get_viewport_rect().size
|
||||
|
||||
|
||||
func _allowed_callings() -> Array:
|
||||
## The ORIGIN gates the callings. The screen does not hardcode seven — a later
|
||||
## origin may allow fewer, and construct would reject a calling it disallows.
|
||||
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
|
||||
var out: Array = []
|
||||
for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET
|
||||
if id in allowed:
|
||||
out.append(id)
|
||||
return out
|
||||
|
||||
|
||||
func _collect_authored_nodes() -> void:
|
||||
# Fixed authored nodes, gathered — never created (ADR 0001). Spares hide.
|
||||
for i in range(4):
|
||||
_race_cards.append(_race_row.get_node("Race%d" % i))
|
||||
for i in range(7):
|
||||
_calling_cards.append(_calling_row.get_node("Calling%d" % i))
|
||||
for i in range(6):
|
||||
_pool_chips.append(_pool_row.get_node("Pool%d" % i))
|
||||
for i in range(9):
|
||||
_bonus_chips.append(_bonus_row.get_node("Bonus%d" % i))
|
||||
for i in range(5):
|
||||
_ability_cards.append(_ability_row.get_node("Ab%d" % i))
|
||||
|
||||
|
||||
func _wire() -> void:
|
||||
for i in range(_race_cards.size()):
|
||||
_race_cards[i].pressed.connect(_on_race_pressed.bind(i))
|
||||
for i in range(_calling_cards.size()):
|
||||
_calling_cards[i].pressed.connect(_on_calling_pressed.bind(i))
|
||||
for i in range(_pool_chips.size()):
|
||||
_pool_chips[i].pressed.connect(_on_pool_pressed.bind(i))
|
||||
for i in range(_bonus_chips.size()):
|
||||
_bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i))
|
||||
for i in range(_ability_cards.size()):
|
||||
var card: PanelContainer = _ability_cards[i]
|
||||
card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i))
|
||||
card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i))
|
||||
_reroll.pressed.connect(_on_reroll_pressed)
|
||||
_name_edit.text_changed.connect(_on_name_changed)
|
||||
_enter.pressed.connect(_on_enter_pressed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- input -> draft
|
||||
|
||||
func _on_race_pressed(i: int) -> void:
|
||||
draft.set_race(Races.IDS[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_calling_pressed(i: int) -> void:
|
||||
var allowed := _allowed_callings()
|
||||
if i >= allowed.size():
|
||||
return
|
||||
draft.set_calling(allowed[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_pool_pressed(i: int) -> void:
|
||||
var pool := draft.skill_pool()
|
||||
if i < pool.size():
|
||||
draft.toggle_skill(pool[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_bonus_pressed(i: int) -> void:
|
||||
draft.set_bonus_skill(Skills.IDS[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_plus_pressed(i: int) -> void:
|
||||
draft.increment(Attributes.IDS[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_minus_pressed(i: int) -> void:
|
||||
draft.decrement(Attributes.IDS[i])
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_reroll_pressed() -> void:
|
||||
draft.reroll()
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_name_changed(text: String) -> void:
|
||||
draft.name = text
|
||||
_bind()
|
||||
|
||||
|
||||
func _on_enter_pressed() -> void:
|
||||
if not draft.is_complete(origin, world):
|
||||
return
|
||||
creation_confirmed.emit(draft.to_creation())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- draft -> nodes
|
||||
|
||||
func _bind() -> void:
|
||||
_bind_races()
|
||||
_bind_callings()
|
||||
_bind_skills()
|
||||
_bind_abilities()
|
||||
_bind_bay()
|
||||
_bind_cta()
|
||||
|
||||
|
||||
func _bind_races() -> void:
|
||||
for i in range(_race_cards.size()):
|
||||
var id: String = Races.IDS[i]
|
||||
var card: Button = _race_cards[i]
|
||||
card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize()))
|
||||
card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", ""))
|
||||
card.get_node("Box/Trait").text = CreationCopy.race_trait(id)
|
||||
card.button_pressed = (id == draft.race_id)
|
||||
card.get_node("Tag").visible = card.button_pressed
|
||||
|
||||
|
||||
func _bind_callings() -> void:
|
||||
var allowed := _allowed_callings()
|
||||
for i in range(_calling_cards.size()):
|
||||
var card: Button = _calling_cards[i]
|
||||
if i >= allowed.size():
|
||||
card.visible = false # a spare authored slot: hide it (ADR 0001)
|
||||
continue
|
||||
var id: String = allowed[i]
|
||||
card.visible = true
|
||||
card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize()))
|
||||
card.get_node("Box/Chip").text = Callings.primary(id).to_upper()
|
||||
card.button_pressed = (id == draft.calling_id)
|
||||
card.get_node("Tag").visible = card.button_pressed
|
||||
|
||||
_detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", ""))
|
||||
_detail_mech.text = CreationCopy.calling_detail(draft.calling_id)
|
||||
|
||||
|
||||
func _bind_skills() -> void:
|
||||
var pool := draft.skill_pool()
|
||||
_pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()]
|
||||
for i in range(_pool_chips.size()):
|
||||
var chip: Button = _pool_chips[i]
|
||||
if i >= pool.size():
|
||||
chip.visible = false
|
||||
continue
|
||||
var skill: String = pool[i]
|
||||
var granted := draft.is_granted(skill)
|
||||
chip.visible = true
|
||||
# A granted skill reads as OWNED (gold + ✓ via SkillChipGranted), not merely
|
||||
# disabled — otherwise it looks identical to a "picks are spent" chip and reads
|
||||
# as "unavailable" rather than "you already have this".
|
||||
chip.theme_type_variation = ThemeKeys.SKILL_CHIP_GRANTED if granted else ThemeKeys.SKILL_CHIP
|
||||
chip.text = ("✓ " + CreationCopy.skill_label(skill)) if granted else CreationCopy.skill_label(skill)
|
||||
chip.button_pressed = draft.is_picked(skill)
|
||||
# Inert when the race already granted it, or when the picks are spent.
|
||||
chip.disabled = granted or (not draft.is_picked(skill) and not draft.can_pick(skill))
|
||||
|
||||
_bonus_row.visible = draft.wants_bonus_skill()
|
||||
for i in range(_bonus_chips.size()):
|
||||
var skill: String = Skills.IDS[i]
|
||||
var chip: Button = _bonus_chips[i]
|
||||
chip.text = CreationCopy.skill_label(skill)
|
||||
chip.button_pressed = (draft.bonus_skill == skill)
|
||||
chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill
|
||||
|
||||
|
||||
func _bind_abilities() -> void:
|
||||
var rolled := draft.rolled()
|
||||
var final := draft.final()
|
||||
var primary := Callings.primary(draft.calling_id)
|
||||
_points.text = "points left: %d" % draft.points_left()
|
||||
|
||||
for i in range(_ability_cards.size()):
|
||||
var stat: String = Attributes.IDS[i]
|
||||
var card: PanelContainer = _ability_cards[i]
|
||||
var spent := int(draft.spend.get(stat, 0))
|
||||
card.get_node("Box/Key").text = stat.to_upper()
|
||||
card.get_node("Box/Value").text = str(int(final[stat]))
|
||||
card.get_node("Box/Rolled").text = "rolled %d%s" % [
|
||||
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
|
||||
card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat)
|
||||
card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat)
|
||||
# Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container
|
||||
# force-fits every child it has — which stretched the badge across the card and
|
||||
# parked it on top of the rolled value. The Control overlay is not a Container,
|
||||
# so the badge's anchors survive and it rides the card's top edge (ADR 0001).
|
||||
card.get_node("Overlay/Tag").visible = (stat == primary)
|
||||
card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_CARD
|
||||
|
||||
|
||||
func _bind_bay() -> void:
|
||||
var shown_name := draft.name.strip_edges()
|
||||
_plate_name.text = shown_name if shown_name != "" else "Nameless"
|
||||
_plate_sub.text = "%s · %s" % [
|
||||
str(world.race(draft.race_id).get("name", "")),
|
||||
str(world.calling(draft.calling_id).get("name", "")),
|
||||
]
|
||||
_origin_text.text = "[i]%s[/i]" % _origin_prose()
|
||||
|
||||
|
||||
func _origin_prose() -> String:
|
||||
## The DM sizes you up — authored, not an AI call. The DM's real entrance is the
|
||||
## shell's opening narration, one screen later, at the first moment where speaking
|
||||
## means something (§13: authored text is CONTENT, not error handling).
|
||||
var race: Dictionary = world.race(draft.race_id)
|
||||
var calling: Dictionary = world.calling(draft.calling_id)
|
||||
# A missing fragment degrades to the blurb rather than crashing. The content
|
||||
# parity test stops that from ever shipping; this stops it from ever exploding.
|
||||
var race_frag := str(race.get("fragment", race.get("blurb", "")))
|
||||
var calling_frag := str(calling.get("fragment", calling.get("blurb", "")))
|
||||
return "%s Now you carry a %s's work — %s" % [
|
||||
race_frag, str(calling.get("name", "")).to_lower(), calling_frag]
|
||||
|
||||
|
||||
func _bind_cta() -> void:
|
||||
## The errors array is the VALIDATOR's, written for code. What lands on the
|
||||
## label is CreationCopy's, written for the player — "choose two more
|
||||
## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is
|
||||
## in the presentation layer on purpose: NewGame.validate's strings are a
|
||||
## contract, and this screen is not the only thing that reads them.
|
||||
var errors := draft.errors(origin, world)
|
||||
_enter.disabled = not errors.is_empty()
|
||||
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT
|
||||
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3cxsq82gjlfk
|
||||
149
client/scripts/ui/creation/creation_copy.gd
Normal file
149
client/scripts/ui/creation/creation_copy.gd
Normal file
@@ -0,0 +1,149 @@
|
||||
class_name CreationCopy
|
||||
extends RefCounted
|
||||
## Display strings for the creation screen, DERIVED from the rules tables.
|
||||
##
|
||||
## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads
|
||||
## both and repeats neither. A hit die written into a JSON — or typed into a string
|
||||
## literal here — is a second source of truth, and it will eventually disagree with
|
||||
## the table. Every number below is read from Callings/Races/Skills at call time, so
|
||||
## retuning a calling retunes its card.
|
||||
##
|
||||
## §7: nothing here may mention Luck, in any form. §2: presentation only.
|
||||
|
||||
const ARMOR_WORD := {
|
||||
"none": "no armour",
|
||||
"light": "light armour",
|
||||
"medium": "medium armour",
|
||||
"heavy": "heavy armour",
|
||||
}
|
||||
|
||||
## The one line the player reads when the draft will not hold. §13: a fallback is
|
||||
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
|
||||
## DM's voice rather than showing him a validator's grammar.
|
||||
const UNSPOKEN := "something in this does not hold — look again"
|
||||
|
||||
const NUMBER_WORD := {
|
||||
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
||||
6: "six", 7: "seven", 8: "eight", 9: "nine",
|
||||
}
|
||||
|
||||
|
||||
static func calling_detail(id: String) -> String:
|
||||
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
|
||||
if not Callings.exists(id):
|
||||
return ""
|
||||
var saves: Array = []
|
||||
for s in Callings.saves(id):
|
||||
saves.append(s.to_upper())
|
||||
return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [
|
||||
Callings.hit_die(id),
|
||||
armor_word(id),
|
||||
resource_word(id),
|
||||
" + ".join(saves),
|
||||
Callings.skill_count(id),
|
||||
talent_word(id),
|
||||
]
|
||||
|
||||
|
||||
static func talent_word(id: String) -> String:
|
||||
## second_wind -> "second wind". Four of the seven talent IDS carry an
|
||||
## underscore, and the card printed the raw id: the player read "talent
|
||||
## second_wind" off the calling card. Ids are snake_case; humans are not — the
|
||||
## same reason skill_label() exists. Read from the table at call time, so
|
||||
## retuning a talent retunes the card.
|
||||
return Callings.talent(id).replace("_", " ")
|
||||
|
||||
|
||||
static func armor_word(id: String) -> String:
|
||||
## The card says "no armour", not "none". Public so the test can assert against
|
||||
## the DERIVED word rather than the raw table value — "none" is not a substring
|
||||
## of "no armour", and asserting on the raw value only ever passed by accident.
|
||||
return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id))
|
||||
|
||||
|
||||
static func resource_word(id: String) -> String:
|
||||
## Martials live on cooldowns; casters on a pool off their casting stat.
|
||||
if not Callings.is_caster(id):
|
||||
return "cooldowns"
|
||||
return "MP (%s)" % Callings.casting_stat(id).to_upper()
|
||||
|
||||
|
||||
static func race_trait(id: String) -> String:
|
||||
## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight"
|
||||
if not Races.exists(id):
|
||||
return ""
|
||||
var parts: Array = []
|
||||
if Races.save_bonus(id) > 0:
|
||||
parts.append("+%d to every save" % Races.save_bonus(id))
|
||||
for s in Races.granted_skills(id):
|
||||
parts.append(skill_label(s).capitalize())
|
||||
if Races.poison_save_bonus(id) > 0:
|
||||
parts.append("Poison resist")
|
||||
if Races.flag(id, "nightsight"):
|
||||
parts.append("Nightsight")
|
||||
if Races.flag(id, "claws"):
|
||||
parts.append("Claws")
|
||||
if Races.flag(id, "keen_scent"):
|
||||
parts.append("Keen scent")
|
||||
if Races.wants_bonus_skill(id):
|
||||
parts.append("+1 skill of choice")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
static func skill_label(skill: String) -> String:
|
||||
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
|
||||
return skill.replace("_", " ")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- §13: the nag
|
||||
|
||||
static func error_line(error: String, draft: CreationDraft) -> String:
|
||||
## The validator's diagnostics are written for whoever is reading the errors
|
||||
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
|
||||
## a CONTRACT (other code reads those strings), so they are not rewritten; they
|
||||
## are TRANSLATED, here, in the presentation layer, where translating them
|
||||
## belongs. What the player reads on the second screen of the game is authored
|
||||
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
|
||||
##
|
||||
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
|
||||
if error.begins_with("player name is required"):
|
||||
return "name yourself"
|
||||
if error.begins_with("unknown race"):
|
||||
return "decide what blood you carry"
|
||||
if error.begins_with("unknown calling"):
|
||||
return "decide what you do for coin"
|
||||
if error.begins_with("calling not allowed by origin"):
|
||||
return "that trade is closed to you — you left it behind"
|
||||
if error.contains(" picks ") and error.contains(" skills, got "):
|
||||
return _proficiency_line(draft)
|
||||
if error.begins_with("duplicate skill pick"):
|
||||
return "you have taken the same proficiency twice"
|
||||
if error.contains(" is not in the ") and error.ends_with(" pool"):
|
||||
return "your calling never taught you that"
|
||||
if error.contains(" is already granted by "):
|
||||
return "you have that one already — it buys you nothing"
|
||||
if error.contains(" must choose a bonus skill"):
|
||||
return "one more proficiency, any of them — take it"
|
||||
if error.begins_with("unknown bonus skill"):
|
||||
return "no one here would call that a skill"
|
||||
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
|
||||
return "you have that one already — take another"
|
||||
if error.contains(" does not get a bonus skill"):
|
||||
return "your blood grants no proficiency of its own"
|
||||
return UNSPOKEN
|
||||
|
||||
|
||||
static func _proficiency_line(draft: CreationDraft) -> String:
|
||||
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
|
||||
## is the rules table's business and the copy asks it, every time, rather than
|
||||
## carrying a second copy of it that would one day disagree.
|
||||
var left: int = draft.picks_left()
|
||||
if left <= 0:
|
||||
return "you have taken more proficiencies than your calling can carry"
|
||||
if left == 1:
|
||||
return "choose one more proficiency"
|
||||
return "choose %s more proficiencies" % number_word(left)
|
||||
|
||||
|
||||
static func number_word(n: int) -> String:
|
||||
return str(NUMBER_WORD.get(n, n))
|
||||
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://rm6bkgstenf1
|
||||
222
client/scripts/ui/creation/creation_draft.gd
Normal file
222
client/scripts/ui/creation/creation_draft.gd
Normal file
@@ -0,0 +1,222 @@
|
||||
class_name CreationDraft
|
||||
extends RefCounted
|
||||
## The creation screen's whole brain. Pure, node-free, headlessly testable.
|
||||
##
|
||||
## Stores exactly the seven things a creation Dictionary is made of, and nothing
|
||||
## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by
|
||||
## calling NewGame.roll_attributes(seed), the same function construct calls, so
|
||||
## the numbers on screen are the numbers the player gets and the screen still
|
||||
## never hands a number to anything (§2).
|
||||
##
|
||||
## It owns no rules of its own: errors() delegates to NewGame.validate. A second
|
||||
## copy of the rules is a second thing to keep in sync, and it would lose.
|
||||
##
|
||||
## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled
|
||||
## inside construct off the same seed, immediately after MAG, and never surfaces.
|
||||
|
||||
var name: String = ""
|
||||
var race_id: String = ""
|
||||
var calling_id: String = ""
|
||||
var spend: Dictionary = {} # {stat: int} — additive only
|
||||
var skills: Array = [] # the player's picks (NOT the race's grants)
|
||||
var bonus_skill: String = "" # human only
|
||||
|
||||
## NOT named `seed` — `seed()` is a global GDScript function and a member that
|
||||
## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still
|
||||
## "seed"; only the GDScript identifier differs.
|
||||
var character_seed: int = 0
|
||||
|
||||
|
||||
static func fresh() -> CreationDraft:
|
||||
## The ONE place non-determinism enters the entire pipeline — and what it
|
||||
## produces is a seed, which is thereafter the character's anchor forever.
|
||||
var d := CreationDraft.new()
|
||||
d.character_seed = new_seed()
|
||||
return d
|
||||
|
||||
|
||||
static func new_seed() -> int:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
return int(rng.randi())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the numbers
|
||||
|
||||
func rolled() -> Dictionary:
|
||||
return NewGame.roll_attributes(character_seed)
|
||||
|
||||
|
||||
func final() -> Dictionary:
|
||||
var out := rolled()
|
||||
for stat in spend:
|
||||
out[stat] = int(out[stat]) + int(spend[stat])
|
||||
return out
|
||||
|
||||
|
||||
func spent_total() -> int:
|
||||
var total := 0
|
||||
for stat in spend:
|
||||
total += int(spend[stat])
|
||||
return total
|
||||
|
||||
|
||||
func points_left() -> int:
|
||||
## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the
|
||||
## number would be a second thing to keep in sync, and this class's whole contract
|
||||
## is that it keeps none.
|
||||
return NewGame.SPEND_POOL - spent_total()
|
||||
|
||||
|
||||
func can_increment(stat: String) -> bool:
|
||||
return Attributes.exists(stat) and points_left() > 0
|
||||
|
||||
|
||||
func increment(stat: String) -> void:
|
||||
if not can_increment(stat):
|
||||
return
|
||||
spend[stat] = int(spend.get(stat, 0)) + 1
|
||||
|
||||
|
||||
func can_decrement(stat: String) -> bool:
|
||||
## Removes only what YOU spent. "Additive only" is a rule about where the number
|
||||
## can END UP — never below the roll — not about whether a mis-click is
|
||||
## recoverable. Those are different things.
|
||||
return int(spend.get(stat, 0)) > 0
|
||||
|
||||
|
||||
func decrement(stat: String) -> void:
|
||||
if not can_decrement(stat):
|
||||
return
|
||||
var left := int(spend[stat]) - 1
|
||||
if left <= 0:
|
||||
spend.erase(stat)
|
||||
else:
|
||||
spend[stat] = left
|
||||
|
||||
|
||||
func reroll() -> void:
|
||||
## A re-roll IS a new seed. Nothing else. The spend clears because points spent
|
||||
## against a roll that no longer exists are meaningless; race, calling, skills
|
||||
## and name survive because none of them came from the die.
|
||||
##
|
||||
## Note what the player is NOT told: the seed drives the five visible attributes
|
||||
## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every
|
||||
## time. That is §7 working exactly as designed. Say nothing.
|
||||
character_seed = new_seed()
|
||||
spend = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the choices
|
||||
|
||||
func set_race(id: String) -> void:
|
||||
race_id = id
|
||||
# A pick the new race GRANTS now buys nothing, and construct rejects it by name.
|
||||
# Drop it here rather than let the player press a button into a 422.
|
||||
var granted: Array = Races.granted_skills(id)
|
||||
var kept: Array = []
|
||||
for s in skills:
|
||||
if s not in granted:
|
||||
kept.append(s)
|
||||
skills = kept
|
||||
if not Races.wants_bonus_skill(id):
|
||||
bonus_skill = ""
|
||||
|
||||
|
||||
func set_calling(id: String) -> void:
|
||||
# The pool changed underneath the picks. Keeping them leaves the player holding
|
||||
# an invalid draft he did not cause and cannot see.
|
||||
if id != calling_id:
|
||||
skills = []
|
||||
calling_id = id
|
||||
|
||||
|
||||
func skill_pool() -> Array:
|
||||
return Callings.skill_pool(calling_id)
|
||||
|
||||
|
||||
func picks_needed() -> int:
|
||||
return Callings.skill_count(calling_id)
|
||||
|
||||
|
||||
func picks_left() -> int:
|
||||
return picks_needed() - skills.size()
|
||||
|
||||
|
||||
func is_granted(skill: String) -> bool:
|
||||
return skill in Races.granted_skills(race_id)
|
||||
|
||||
|
||||
func is_picked(skill: String) -> bool:
|
||||
return skill in skills
|
||||
|
||||
|
||||
func can_pick(skill: String) -> bool:
|
||||
if is_granted(skill):
|
||||
return false
|
||||
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
|
||||
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
|
||||
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
|
||||
# proficient") — the player holding an invalid draft he did not cause and cannot
|
||||
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
|
||||
# instead; the bonus row is right there and the player can hand it back.
|
||||
if skill == bonus_skill:
|
||||
return false
|
||||
if skill not in skill_pool():
|
||||
return false
|
||||
return picks_left() > 0
|
||||
|
||||
|
||||
func toggle_skill(skill: String) -> void:
|
||||
if is_picked(skill):
|
||||
skills.erase(skill)
|
||||
return
|
||||
if can_pick(skill):
|
||||
skills.append(skill)
|
||||
|
||||
|
||||
func wants_bonus_skill() -> bool:
|
||||
return Races.wants_bonus_skill(race_id)
|
||||
|
||||
|
||||
func can_take_bonus(skill: String) -> bool:
|
||||
if not wants_bonus_skill():
|
||||
return false
|
||||
if not Skills.exists(skill):
|
||||
return false
|
||||
return not is_granted(skill) and not is_picked(skill)
|
||||
|
||||
|
||||
func set_bonus_skill(skill: String) -> void:
|
||||
if bonus_skill == skill:
|
||||
bonus_skill = ""
|
||||
return
|
||||
if can_take_bonus(skill):
|
||||
bonus_skill = skill
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the contract
|
||||
|
||||
func to_creation() -> Dictionary:
|
||||
## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one
|
||||
## of these and skips this screen entirely — so it stays a plain Dictionary, and
|
||||
## this scene must never become the only thing that can produce it.
|
||||
return {
|
||||
"name": name.strip_edges(),
|
||||
"race_id": race_id,
|
||||
"calling_id": calling_id,
|
||||
"seed": character_seed, # the DICT key stays "seed" — the contract is unchanged
|
||||
"spend": spend.duplicate(),
|
||||
"skills": skills.duplicate(),
|
||||
"bonus_skill": bonus_skill,
|
||||
}
|
||||
|
||||
|
||||
func errors(origin: Dictionary, world: ContentDB) -> Array:
|
||||
## Delegates. The screen owns no second copy of the rules — it asks construct's
|
||||
## validator what is wrong, and shows the answer.
|
||||
return NewGame.validate(origin, world, to_creation())
|
||||
|
||||
|
||||
func is_complete(origin: Dictionary, world: ContentDB) -> bool:
|
||||
return errors(origin, world).is_empty()
|
||||
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dkxuw5c4r6tld
|
||||
@@ -19,6 +19,8 @@ extends Control
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
|
||||
var service: DmService = null # injection seam; real HttpTransport built if null
|
||||
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
|
||||
var injected_state: GameState = null # injection seam; ShellState.seed() if null
|
||||
|
||||
var _state: ShellState
|
||||
var _log: CanonLog
|
||||
@@ -40,8 +42,10 @@ func _ready() -> void:
|
||||
_fit_to_viewport()
|
||||
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||
|
||||
_state = ShellState.seed()
|
||||
_log = _build_seed_log()
|
||||
# The injected character wins; the seed calls are the F6/standalone fallback —
|
||||
# same shape as `service == null` building a real transport below.
|
||||
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
|
||||
_log = injected_log if injected_log != null else _build_seed_log()
|
||||
_bind_state()
|
||||
|
||||
if service == null:
|
||||
|
||||
@@ -34,3 +34,19 @@ static func seed() -> ShellState:
|
||||
s.location_label = "THE LOWER WARD"
|
||||
s.dock_open = true
|
||||
return s
|
||||
|
||||
|
||||
static func for_character(state: GameState, log: CanonLog) -> ShellState:
|
||||
## The real HUD state for a constructed character. Vitals + location come from
|
||||
## the character (§2 — code owns them); turn_order/consumables/round_label are
|
||||
## still seed() placeholders because combat (M5) and inventory (M7) own those.
|
||||
var s := ShellState.seed() # start from the placeholders, then overwrite what is real
|
||||
s.vitals = {
|
||||
"hp": state.sheet.hp,
|
||||
"hp_max": state.sheet.max_hp(),
|
||||
"mp": state.sheet.mp,
|
||||
"mp_max": state.sheet.max_mp(),
|
||||
"purse_copper": state.purse_copper,
|
||||
}
|
||||
s.location_label = log.location.name
|
||||
return s
|
||||
|
||||
@@ -9,8 +9,6 @@ extends Control
|
||||
|
||||
signal menu_activated(id: StringName)
|
||||
|
||||
const SHELL := "res://scenes/shell/MainWindowShell.tscn"
|
||||
|
||||
var _rows: Array = []
|
||||
var _sel: int = 0
|
||||
|
||||
@@ -44,7 +42,6 @@ func _ready() -> void:
|
||||
_wire_row_mouse()
|
||||
|
||||
_footer_left.text = Version.new().footer()
|
||||
menu_activated.connect(_on_menu_activated)
|
||||
_update_selection()
|
||||
|
||||
|
||||
@@ -128,16 +125,6 @@ func _activate(i: int) -> void:
|
||||
menu_activated.emit(_rows[i].get_meta("action"))
|
||||
|
||||
|
||||
func _on_menu_activated(id: StringName) -> void:
|
||||
match id:
|
||||
&"new_game":
|
||||
get_tree().change_scene_to_file(SHELL)
|
||||
&"quit":
|
||||
get_tree().quit()
|
||||
_:
|
||||
pass # inert placeholder — the screen lands in a later milestone
|
||||
|
||||
|
||||
func _update_selection() -> void:
|
||||
for i in range(_rows.size()):
|
||||
var row: Control = _rows[i]
|
||||
|
||||
761
client/tests/unit/test_character_creation_screen.gd
Normal file
761
client/tests/unit/test_character_creation_screen.gd
Normal file
@@ -0,0 +1,761 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const SCENE := "res://scenes/creation/CharacterCreation.tscn"
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
|
||||
|
||||
func _world() -> ContentDB:
|
||||
var w := ContentDB.new()
|
||||
w.load_from(ContentDB.default_content_root())
|
||||
return w
|
||||
|
||||
|
||||
func _screen() -> CharacterCreation:
|
||||
# Inject origin + world BEFORE _ready (add_child) — instantiate() does not run
|
||||
# _ready(), add_child does. Same seam the shell uses for DmService.
|
||||
var node = load(SCENE).instantiate()
|
||||
node.world = _world()
|
||||
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
add_child_autofree(node)
|
||||
return node
|
||||
|
||||
|
||||
func _screen_with_origin(origin: Dictionary) -> CharacterCreation:
|
||||
# Same seam as _screen(), but with an origin the CALLER controls — used to
|
||||
# prove the calling-gate actually gates, which the real "deserter" origin
|
||||
# (all 7 callings allowed) cannot do: 7 shown == 7 allowed is trivially true
|
||||
# whether or not the hide branch in _bind_callings exists at all.
|
||||
var node = load(SCENE).instantiate()
|
||||
node.world = _world()
|
||||
node.origin = origin
|
||||
add_child_autofree(node)
|
||||
return node
|
||||
|
||||
|
||||
func _complete_a_valid_draft(s: CharacterCreation) -> void:
|
||||
## Drives the draft to a legal state by pressing the same AUTHORED NODES a
|
||||
## player's clicks fire — not by calling the _on_*_pressed handlers directly.
|
||||
## Calling the handler proves the handler works; it proves nothing about
|
||||
## _wire() actually connecting the node's `pressed` signal to it. Pressing the
|
||||
## node is the only path that exercises both at once.
|
||||
s._name_edit.text = "Aldric"
|
||||
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text in a test.
|
||||
|
||||
s._race_cards[Races.IDS.find("human")].pressed.emit()
|
||||
s._calling_cards[s._allowed_callings().find("sellsword")].pressed.emit()
|
||||
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
for i in range(pool.size()):
|
||||
s._pool_chips[i].pressed.emit()
|
||||
|
||||
for i in range(Skills.IDS.size()):
|
||||
if s.draft.can_take_bonus(Skills.IDS[i]):
|
||||
s._bonus_chips[i].pressed.emit()
|
||||
break
|
||||
|
||||
|
||||
func test_screen_applies_theme_and_fits_viewport():
|
||||
var packed = load(SCENE)
|
||||
assert_true(packed is PackedScene)
|
||||
var s := _screen()
|
||||
assert_true(s is Control)
|
||||
assert_true(s.theme is Theme, "the screen pulls the shared theme")
|
||||
|
||||
# The .tscn root is authored at exactly 1920x1080 (project.godot's viewport
|
||||
# size too) — assert_gt(s.size.x, 0.0) passes on the AUTHORED offsets alone,
|
||||
# even with _fit_to_viewport() and its size_changed connection deleted
|
||||
# outright. Prove the fit is actually live: change the window's reported
|
||||
# resolution to something that is NOT 1920x1080 and assert the root followed.
|
||||
#
|
||||
# get_viewport().size / get_window().size do not move get_viewport_rect() —
|
||||
# this project runs with window/stretch/mode="canvas_items", so a Control's
|
||||
# visible rect tracks content_scale_size, not the raw window pixel size.
|
||||
# content_scale_size is the property that actually reaches Viewport.size_changed
|
||||
# here, headless included.
|
||||
var win := s.get_window()
|
||||
var original_scale_size: Vector2i = win.content_scale_size
|
||||
win.content_scale_size = Vector2i(1600, 900)
|
||||
assert_eq(s.size, Vector2(1600, 900),
|
||||
"the root must follow a resize through _fit_to_viewport()'s size_changed hook (the M3-a collapse guard)")
|
||||
win.content_scale_size = original_scale_size
|
||||
assert_eq(s.size, Vector2(1920, 1080), "restoring the viewport must restore the root, or later tests see a stale size")
|
||||
|
||||
|
||||
func test_a_draft_exists_with_a_seed_on_load():
|
||||
var s := _screen()
|
||||
assert_not_null(s.draft)
|
||||
assert_ne(s.draft.character_seed, 0)
|
||||
|
||||
|
||||
func test_five_ability_cards_never_six():
|
||||
# §7 — LCK has no card, no row, no tooltip. The player must never be able to
|
||||
# CALCULATE that he is cursed.
|
||||
#
|
||||
# _ability_cards is populated by _collect_authored_nodes() looping
|
||||
# `range(5)` — that loop is 5 by construction and would stay 5 even if the
|
||||
# .tscn grew a 6th card. Assert against the SCENE's actual children instead.
|
||||
var s := _screen()
|
||||
assert_eq(s._ability_cards.size(), 5)
|
||||
assert_eq(s._ability_row.get_child_count(), 5,
|
||||
"five authored ability cards — the scene, not the script's range()")
|
||||
assert_null(s._ability_row.get_node_or_null("Ab5"),
|
||||
"there is no sixth ability card, and there never is (§7)")
|
||||
for card in s._ability_cards:
|
||||
assert_false(card.name.to_lower().contains("lck"))
|
||||
|
||||
|
||||
func test_no_node_on_the_screen_says_luck():
|
||||
# Sweep every Control, not just Label — RichTextLabel (_origin_text,
|
||||
# _detail_blurb, the only two nodes rendering authored ContentDB prose)
|
||||
# extends Control, not Label, and a `find_children(..., "Label", ...)` filter
|
||||
# never looks at it. Buttons (`text`) and the LineEdit (`placeholder_text`)
|
||||
# are swept too.
|
||||
#
|
||||
# The two RichTextLabels render race.fragment / calling.fragment /
|
||||
# calling.blurb, which DIFFER per race and per calling. Checking only the
|
||||
# default binding (human + sellsword) means a "luck" in, say, the Reaver's
|
||||
# blurb or the Beastfolk's fragment would reach the screen and never be seen.
|
||||
# Rebind across every race x every allowed calling (the deserter origin used
|
||||
# by _screen() allows all 7, so this is a full 4 x 7 = 28-combination sweep).
|
||||
var s := _screen()
|
||||
var checked_a_rich_text_label := false
|
||||
var combos_checked := 0
|
||||
|
||||
# The word "luck" is not the only thing that leaks Luck. Luck.BANDS holds the
|
||||
# DESCRIPTORS — "the dice are kind today" — and a descriptor on this screen is
|
||||
# strictly worse than the number: the player would re-roll until his Luck read
|
||||
# well, which is Luck made CALCULABLE, the precise §7 failure. It is structurally
|
||||
# unreachable today (the screen holds no GameState and the draft has no LCK), and
|
||||
# it costs three lines to keep it that way.
|
||||
var forbidden: Array = ["luck", "lck"]
|
||||
for band in Luck.BANDS:
|
||||
forbidden.append(str(band["text"]).to_lower())
|
||||
|
||||
for race_i in range(Races.IDS.size()):
|
||||
s._race_cards[race_i].pressed.emit()
|
||||
var allowed: Array = s._allowed_callings()
|
||||
for calling_i in range(allowed.size()):
|
||||
s._calling_cards[calling_i].pressed.emit()
|
||||
combos_checked += 1
|
||||
var combo := "%s + %s" % [Races.IDS[race_i], allowed[calling_i]]
|
||||
|
||||
for node in s.find_children("*", "Control", true, false):
|
||||
if node is RichTextLabel:
|
||||
checked_a_rich_text_label = true
|
||||
for prop in ["text", "placeholder_text", "tooltip_text"]:
|
||||
var value: Variant = node.get(prop)
|
||||
if typeof(value) != TYPE_STRING:
|
||||
continue
|
||||
var lowered: String = value.to_lower()
|
||||
for word in forbidden:
|
||||
assert_false(lowered.contains(word),
|
||||
"%s.%s leaks Luck (§7) at %s: '%s'" % [node.name, prop, combo, word])
|
||||
|
||||
# `checked_a_rich_text_label` alone only proves the sweep REACHED a
|
||||
# RichTextLabel, not that it had anything in it — an empty label would
|
||||
# pass the Luck check for a reason that has nothing to do with §7.
|
||||
#
|
||||
# But _bind_bay() and _bind_callings() both wrap their prose in BBCode
|
||||
# italics ("[i]%s[/i]"), and _origin_prose() always emits the literal
|
||||
# connective " Now you carry a %s's work — " even when every fragment is
|
||||
# blank. So `"[i][/i]".strip_edges().is_empty()` is FALSE — the wrapper
|
||||
# alone would pass this guard even if ContentDB served nothing at all.
|
||||
# Assert the labels render the WORLD's own strings, not just non-blankness.
|
||||
var race_id: String = Races.IDS[race_i]
|
||||
var calling_id: String = allowed[calling_i]
|
||||
var race_frag: String = str(s.world.race(race_id).get("fragment", s.world.race(race_id).get("blurb", "")))
|
||||
var calling_frag: String = str(s.world.calling(calling_id).get("fragment", s.world.calling(calling_id).get("blurb", "")))
|
||||
var calling_blurb: String = str(s.world.calling(calling_id).get("blurb", ""))
|
||||
|
||||
assert_false(race_frag.strip_edges().is_empty(),
|
||||
"race %s has no fragment (or blurb) to render — the content itself must not be blank" % race_id)
|
||||
assert_false(calling_frag.strip_edges().is_empty(),
|
||||
"calling %s has no fragment (or blurb) to render — the content itself must not be blank" % calling_id)
|
||||
assert_false(calling_blurb.strip_edges().is_empty(),
|
||||
"calling %s has no blurb to render — the content itself must not be blank" % calling_id)
|
||||
|
||||
assert_true(s._origin_text.text.contains(race_frag),
|
||||
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [race_id, combo])
|
||||
assert_true(s._origin_text.text.contains(calling_frag),
|
||||
"the DM panel must render %s's own fragment, not just non-empty BBCode, at %s" % [calling_id, combo])
|
||||
assert_true(s._detail_blurb.text.contains(calling_blurb),
|
||||
"the detail panel must render %s's own blurb, not just non-empty BBCode, at %s" % [calling_id, combo])
|
||||
|
||||
assert_true(checked_a_rich_text_label,
|
||||
"the sweep must actually reach the RichTextLabels or it is checking nothing")
|
||||
assert_eq(combos_checked, Races.IDS.size() * Callings.IDS.size(),
|
||||
"every race must be checked against every allowed calling — 4 x 7, not just the default binding")
|
||||
|
||||
|
||||
func test_only_the_origin_s_allowed_callings_are_shown():
|
||||
# The deserter origin allows all 7 callings, so against it "shown == allowed"
|
||||
# is a trivial 7 == 7 that would stay green even with the hide branch in
|
||||
# _bind_callings deleted outright. Use a RESTRICTIVE origin, deliberately out
|
||||
# of Callings.IDS order, so the gate is doing real work.
|
||||
var allowed := ["bonesetter", "sellsword", "cutpurse"]
|
||||
var s := _screen_with_origin({"build_constraints": {"allowed_callings": allowed}})
|
||||
|
||||
var expected_order: Array = []
|
||||
for id in Callings.IDS: # Callings.IDS fixes the ORDER
|
||||
if id in allowed: # the origin fixes the SET
|
||||
expected_order.append(id)
|
||||
assert_eq(expected_order.size(), 3)
|
||||
|
||||
var shown := 0
|
||||
for i in range(s._calling_cards.size()):
|
||||
var card: Button = s._calling_cards[i]
|
||||
if i < expected_order.size():
|
||||
assert_true(card.visible, "calling %s must be shown" % expected_order[i])
|
||||
var expected_name: String = str(
|
||||
s.world.calling(expected_order[i]).get("name", expected_order[i].capitalize()))
|
||||
assert_eq(card.get_node("Box/Name").text, expected_name,
|
||||
"visible cards must appear in Callings.IDS order, not the origin's list order")
|
||||
shown += 1
|
||||
else:
|
||||
assert_false(card.visible,
|
||||
"a calling the origin disallows stays hidden, not deleted")
|
||||
|
||||
assert_eq(shown, 3)
|
||||
|
||||
|
||||
func test_the_deserter_origin_shows_all_seven_callings():
|
||||
var s := _screen()
|
||||
var shown := 0
|
||||
for card in s._calling_cards:
|
||||
if card.visible:
|
||||
shown += 1
|
||||
assert_eq(shown, 7)
|
||||
|
||||
|
||||
func test_a_degenerate_origin_disables_entry_without_throwing():
|
||||
# §13 — a degrade, never a crash. An origin with no build_constraints at all
|
||||
# (or an empty allowed_callings list) must leave the draft with no calling to
|
||||
# invent, every calling card hidden, and the CTA disabled — quietly.
|
||||
#
|
||||
# NOT {} — CharacterCreation._ready() treats an EMPTY origin Dictionary as
|
||||
# "none supplied" and substitutes the deserter origin (`origin.is_empty()`),
|
||||
# which allows all 7 callings and is not degenerate at all. A non-empty
|
||||
# Dictionary that simply carries no build_constraints is the real degenerate
|
||||
# case §13's comment in the source describes.
|
||||
var s := _screen_with_origin({"id": "test-degenerate-origin"})
|
||||
assert_eq(s.draft.calling_id, "",
|
||||
"no allowed callings means no calling to default into")
|
||||
for card in s._calling_cards:
|
||||
assert_false(card.visible, "every calling card hides when none are allowed")
|
||||
assert_true(s._enter.disabled, "a calling-less draft can never be legal")
|
||||
|
||||
|
||||
func test_authored_node_counts_match_the_rules_tables():
|
||||
# _collect_authored_nodes() hardcodes 4/7/6/9/5. A table growing (a 5th race,
|
||||
# an 8th calling, a 10th skill) must go red HERE, before a binder silently
|
||||
# under-renders or indexes a table out of range at runtime.
|
||||
var s := _screen()
|
||||
assert_eq(s._race_cards.size(), Races.IDS.size())
|
||||
assert_eq(s._calling_cards.size(), Callings.IDS.size())
|
||||
assert_eq(s._bonus_chips.size(), Skills.IDS.size())
|
||||
assert_eq(s._ability_cards.size(), Attributes.IDS.size())
|
||||
# The pool row's 6 is the WIDEST calling's skill pool, not a table length —
|
||||
# assert it covers every calling, so a 7-wide pool added later goes red
|
||||
# instead of silently dropping a chip.
|
||||
for id in Callings.IDS:
|
||||
assert_true(s._pool_chips.size() >= Callings.skill_pool(id).size(),
|
||||
"the pool row must fit %s's skill pool" % id)
|
||||
|
||||
# The above ties the SCRIPT's arrays to the tables. It says nothing about the
|
||||
# SCENE — an orphan authored card (a stray "Race4" nobody wired or hid) would
|
||||
# render, unbound and visible, with every assertion above still green. Tie the
|
||||
# rows' actual child counts to the same tables.
|
||||
assert_eq(s._race_row.get_child_count(), Races.IDS.size(),
|
||||
"an orphan authored race card would sit here, unbound and visible")
|
||||
assert_eq(s._calling_row.get_child_count(), Callings.IDS.size(),
|
||||
"an orphan authored calling card would sit here, unbound and visible")
|
||||
# BonusRow's first child is its own section Label ("HUMAN — ONE MORE, ANY
|
||||
# SKILL") — not a chip — so its total is one MORE than Skills.IDS.size().
|
||||
assert_eq(s._bonus_row.get_child_count(), Skills.IDS.size() + 1,
|
||||
"an orphan authored bonus chip would sit here, unbound and visible")
|
||||
|
||||
# PoolRow was the one row the sweep above forgot — a stray authored "Pool6" would
|
||||
# render unbound and visible with every assertion so far still green, which is
|
||||
# exactly the bug this test exists to catch. Its count is not a table LENGTH: it
|
||||
# is the widest calling's pool (plus its own "Count" label, which is not a chip),
|
||||
# so derive that from the rules table rather than writing 6 down again.
|
||||
var widest := 0
|
||||
for id in Callings.IDS:
|
||||
widest = maxi(widest, Callings.skill_pool(id).size())
|
||||
assert_eq(widest, s._pool_chips.size(), "the pool row must hold exactly the widest calling's pool")
|
||||
assert_eq(s._pool_row.get_child_count(), widest + 1,
|
||||
"an orphan authored pool chip would sit here, unbound and visible (PoolRow also holds its Count label)")
|
||||
assert_null(s._pool_row.get_node_or_null("Pool%d" % widest),
|
||||
"there is no chip past the widest calling's pool — a stray Pool%d renders unbound" % widest)
|
||||
assert_eq(s._pool_count.get_parent(), s._pool_row, "the +1 above is the Count label, and nothing else")
|
||||
|
||||
|
||||
func test_cta_is_disabled_until_the_draft_is_legal():
|
||||
var s := _screen()
|
||||
assert_true(s._enter.disabled, "an empty draft cannot enter the world")
|
||||
assert_ne(s._error.text, CharacterCreation.CTA_HINT, "the screen says WHY, dryly")
|
||||
assert_ne(s._error.text.strip_edges(), "")
|
||||
|
||||
_complete_a_valid_draft(s)
|
||||
assert_false(s._enter.disabled)
|
||||
assert_eq(s._error.text, CharacterCreation.CTA_HINT,
|
||||
"a legal draft gets the mock's line back, not a blank")
|
||||
|
||||
|
||||
func test_the_nag_under_the_cta_is_authored_copy_and_never_a_validator_string():
|
||||
# §13. The label used to read `str(errors[0])` — so the second screen of the game
|
||||
# showed the player "sellsword picks 2 skills, got 0". Drive the screen through
|
||||
# every invalid state it can actually reach, by pressing the nodes, and pin what
|
||||
# the label says. Anchored to the authored phrases themselves: "contains no
|
||||
# underscore" alone would pass on a blank label, or on the wrong copy.
|
||||
var s := _screen() # human + sellsword, nameless, nothing picked
|
||||
var seen: Array = []
|
||||
|
||||
assert_eq(s._error.text, "name yourself", "the first thing missing is a name")
|
||||
seen.append(s._error.text)
|
||||
|
||||
s._name_edit.text = "Aldric"
|
||||
s._on_name_changed("Aldric") # LineEdit.text_changed is not raised by setting .text
|
||||
assert_eq(s._error.text, "choose two more proficiencies",
|
||||
"the sellsword picks 2 and has none — and the two is DERIVED from Callings.skill_count")
|
||||
seen.append(s._error.text)
|
||||
|
||||
s._pool_chips[0].pressed.emit()
|
||||
assert_eq(s.draft.skills.size(), 1, "the pick landed, or the line below is about nothing")
|
||||
assert_eq(s._error.text, "choose one more proficiency", "one is singular")
|
||||
seen.append(s._error.text)
|
||||
|
||||
s._pool_chips[1].pressed.emit()
|
||||
assert_eq(s.draft.skills.size(), 2)
|
||||
assert_eq(s._error.text, "one more proficiency, any of them — take it",
|
||||
"the human's bonus skill, in the Margreave's voice, not 'human must choose a bonus skill'")
|
||||
seen.append(s._error.text)
|
||||
|
||||
for i in range(Skills.IDS.size()):
|
||||
if s.draft.can_take_bonus(Skills.IDS[i]):
|
||||
s._bonus_chips[i].pressed.emit()
|
||||
break
|
||||
assert_eq(s._error.text, CharacterCreation.CTA_HINT, "a legal draft gets the mock's line back")
|
||||
|
||||
# Nothing above may be an engineering string, and none of them may repeat.
|
||||
assert_eq(seen.size(), 4)
|
||||
for line in seen:
|
||||
assert_false(line.contains("_"), "a snake_case id reached the player: %s" % line)
|
||||
assert_false(line.contains("got "), "the validator's grammar reached the player: %s" % line)
|
||||
var unique := {}
|
||||
for line in seen:
|
||||
unique[line] = true
|
||||
assert_eq(unique.size(), 4, "each invalid state says a DIFFERENT thing — the nag is telling him what is missing")
|
||||
|
||||
|
||||
func test_a_skill_taken_as_the_bonus_goes_inert_in_the_calling_s_pool():
|
||||
# The one-directional guard: can_take_bonus() refused an already-PICKED skill, but
|
||||
# can_pick() ignored the bonus. Human + Sellsword, take athletics as the bonus,
|
||||
# then press athletics in the pool — the chip was live and the pick LANDED, leaving
|
||||
# a draft NewGame.validate rejects ("bonus skill athletics is already proficient").
|
||||
var s := _screen() # human + sellsword
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
var athletics_i: int = pool.find("athletics")
|
||||
var bonus_i: int = Skills.IDS.find("athletics")
|
||||
assert_ne(athletics_i, -1, "athletics must be in the sellsword's pool or this proves nothing")
|
||||
assert_false(s._pool_chips[athletics_i].disabled, "the chip starts live, or the assertion below is trivially true")
|
||||
|
||||
s._bonus_chips[bonus_i].pressed.emit()
|
||||
assert_eq(s.draft.bonus_skill, "athletics")
|
||||
assert_true(s._pool_chips[athletics_i].disabled,
|
||||
"a skill the bonus already bought must render INERT in the pool")
|
||||
|
||||
# Button.pressed fires even on a disabled Button — the rule lives in the draft, not
|
||||
# in the styling, so press it and prove the pick does not land.
|
||||
s._pool_chips[athletics_i].pressed.emit()
|
||||
assert_false(s.draft.is_picked("athletics"), "the pick must not land")
|
||||
assert_false("bonus skill athletics is already proficient" in s.draft.errors(s.origin, s.world),
|
||||
"the player must not be able to press his way into an invalid draft")
|
||||
|
||||
# And it is recoverable: hand the bonus back and the chip is live again.
|
||||
s._bonus_chips[bonus_i].pressed.emit()
|
||||
assert_eq(s.draft.bonus_skill, "")
|
||||
assert_false(s._pool_chips[athletics_i].disabled, "handing the bonus back makes the pool chip live")
|
||||
s._pool_chips[athletics_i].pressed.emit()
|
||||
assert_true(s.draft.is_picked("athletics"))
|
||||
|
||||
|
||||
func test_pressing_enter_emits_exactly_the_draft_s_creation_dict():
|
||||
var s := _screen()
|
||||
_complete_a_valid_draft(s)
|
||||
watch_signals(s)
|
||||
s._enter.pressed.emit()
|
||||
assert_signal_emitted_with_parameters(s, "creation_confirmed", [s.draft.to_creation()])
|
||||
|
||||
|
||||
func test_the_screen_never_constructs_the_character():
|
||||
# The flow is M4-c's job, and a saga later synthesizes the same Dictionary and
|
||||
# skips this scene entirely. The screen PRODUCES the dict; it does not spend it.
|
||||
var s := _screen()
|
||||
assert_false(s.has_method("construct"))
|
||||
|
||||
|
||||
func test_the_displayed_scores_are_the_draft_s_scores():
|
||||
var s := _screen()
|
||||
_complete_a_valid_draft(s)
|
||||
|
||||
# A fresh, unspent draft has final() == rolled() for EVERY stat, so comparing
|
||||
# the Value label against final() alone cannot tell a `final`/`rolled` swap in
|
||||
# the ability binder apart from the real thing. Spend a point — by pressing
|
||||
# the actual Plus BUTTON, not _on_plus_pressed, so a broken _wire() would also
|
||||
# be caught here — so final() and rolled() diverge and the assertion below has
|
||||
# to actually choose one.
|
||||
var spent_stat: String = Attributes.IDS[0]
|
||||
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(spent_stat, 0)), 1, "the spend actually landed")
|
||||
|
||||
var rolled: Dictionary = s.draft.rolled()
|
||||
var final: Dictionary = s.draft.final()
|
||||
assert_eq(int(final[spent_stat]), int(rolled[spent_stat]) + 1,
|
||||
"spending a point must move final away from rolled, or nothing below distinguishes them")
|
||||
|
||||
for i in range(Attributes.IDS.size()):
|
||||
var stat: String = Attributes.IDS[i]
|
||||
var card = s._ability_cards[i]
|
||||
var value_label: Label = card.get_node("Box/Value")
|
||||
var rolled_label: Label = card.get_node("Box/Rolled")
|
||||
assert_eq(value_label.text, str(int(final[stat])),
|
||||
"%s's Value must show final(), not rolled()" % stat)
|
||||
|
||||
var spent: int = int(s.draft.spend.get(stat, 0))
|
||||
var expected_rolled_text: String = "rolled %d%s" % [
|
||||
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
|
||||
assert_eq(rolled_label.text, expected_rolled_text,
|
||||
"%s's Rolled sub-label must show the roll and the spend, nothing else covers it" % stat)
|
||||
|
||||
|
||||
func test_reroll_changes_what_is_on_screen():
|
||||
var s := _screen()
|
||||
# Spend a point BEFORE the re-roll, by pressing the actual Plus button — a
|
||||
# fresh draft's pool already reads "points left: 3" with nothing spent, so
|
||||
# asserting that value after reroll alone proves nothing about whether
|
||||
# reroll() actually clears the spend.
|
||||
s._ability_cards[0].get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(s._points.text, "points left: 2", "the spend landed before the reroll")
|
||||
|
||||
var before := s.draft.character_seed
|
||||
s._reroll.pressed.emit()
|
||||
assert_ne(s.draft.character_seed, before)
|
||||
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")
|
||||
|
||||
|
||||
func test_pressing_a_race_card_sets_the_draft_s_race():
|
||||
# Nothing in the suite ever emits a card's `pressed` signal — every existing
|
||||
# test called _on_race_pressed directly. A mis-wired card (bound to the wrong
|
||||
# index, or not connected at all — see _wire()) would leave every other test
|
||||
# green. Press the elf card itself.
|
||||
var s := _screen()
|
||||
var elf_i: int = Races.IDS.find("elf")
|
||||
s._race_cards[elf_i].pressed.emit()
|
||||
assert_eq(s.draft.race_id, "elf",
|
||||
"the CARD's pressed signal must reach the draft through _wire()")
|
||||
assert_true(s._race_cards[elf_i].get_node("Tag").visible, "the chosen card shows its tag")
|
||||
|
||||
|
||||
func test_pressing_a_calling_card_sets_the_draft_s_calling():
|
||||
var s := _screen()
|
||||
var idx: int = s._allowed_callings().find("cutpurse")
|
||||
s._calling_cards[idx].pressed.emit()
|
||||
assert_eq(s.draft.calling_id, "cutpurse",
|
||||
"the CARD's pressed signal must reach the draft through _wire()")
|
||||
|
||||
|
||||
func test_pressing_a_pool_chip_toggles_the_skill():
|
||||
var s := _screen()
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
var skill: String = pool[0]
|
||||
s._pool_chips[0].pressed.emit()
|
||||
assert_true(s.draft.is_picked(skill),
|
||||
"the CHIP's pressed signal must reach toggle_skill through _wire()")
|
||||
s._pool_chips[0].pressed.emit()
|
||||
assert_false(s.draft.is_picked(skill), "pressing it again toggles it back off")
|
||||
|
||||
|
||||
func test_pressing_a_bonus_chip_sets_the_bonus_skill():
|
||||
var s := _screen() # human is the default race, and wants a bonus skill
|
||||
var idx := -1
|
||||
for i in range(Skills.IDS.size()):
|
||||
if s.draft.can_take_bonus(Skills.IDS[i]):
|
||||
idx = i
|
||||
break
|
||||
assert_ne(idx, -1, "the default draft (human) must have a takeable bonus skill")
|
||||
s._bonus_chips[idx].pressed.emit()
|
||||
assert_eq(s.draft.bonus_skill, Skills.IDS[idx],
|
||||
"the CHIP's pressed signal must reach set_bonus_skill through _wire()")
|
||||
|
||||
|
||||
func test_pressing_the_minus_button_returns_a_spent_point():
|
||||
# Nothing else in the suite ever presses Minus — every prior test only ever
|
||||
# spent points, never gave one back.
|
||||
var s := _screen()
|
||||
var stat: String = Attributes.IDS[0]
|
||||
var card = s._ability_cards[0]
|
||||
card.get_node("Box/Buttons/Plus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(stat, 0)), 1)
|
||||
|
||||
card.get_node("Box/Buttons/Minus").pressed.emit()
|
||||
assert_eq(int(s.draft.spend.get(stat, 0)), 0,
|
||||
"the MINUS button's pressed signal must reach the draft through _wire()")
|
||||
assert_eq(s._points.text, "points left: 3")
|
||||
|
||||
|
||||
func test_minus_is_disabled_at_the_rolled_floor_plus_at_the_spent_pool():
|
||||
var s := _screen()
|
||||
var card = s._ability_cards[0]
|
||||
var plus: Button = card.get_node("Box/Buttons/Plus")
|
||||
var minus: Button = card.get_node("Box/Buttons/Minus")
|
||||
|
||||
assert_true(minus.disabled, "nothing has been spent yet — minus must be inert at the rolled floor")
|
||||
plus.pressed.emit()
|
||||
assert_false(minus.disabled, "a spent point makes minus live again")
|
||||
|
||||
# Drain the whole pool (SPEND_POOL == 3) into this one stat. points_left() is
|
||||
# POOL-wide, not per-card, so every Plus on the screen must go inert together.
|
||||
# A BOUNDED loop, deliberately — `while points_left() > 0` would hang forever
|
||||
# if _wire() were ever broken (the press would never reach the draft), turning
|
||||
# a guard meant to catch that exact bug into a stuck test run instead of a red one.
|
||||
var pool_size: int = s.draft.points_left()
|
||||
for i in range(pool_size + 1):
|
||||
plus.pressed.emit()
|
||||
assert_eq(s.draft.points_left(), 0)
|
||||
assert_true(plus.disabled, "the pool is spent — this card's Plus must go inert")
|
||||
var other_plus: Button = s._ability_cards[1].get_node("Box/Buttons/Plus")
|
||||
assert_true(other_plus.disabled, "points_left() is pool-wide — every card's Plus disables together")
|
||||
|
||||
|
||||
func test_a_race_granted_skill_chip_is_inert_and_so_is_a_chip_once_picks_are_spent():
|
||||
# Two real binder behaviours in one chip's line
|
||||
# (chip.disabled = draft.is_granted(skill) or (not is_picked(skill) and not can_pick(skill)))
|
||||
# that nothing in the suite observed. Elf grants "perception"; Cutpurse's pool
|
||||
# has 6 skills (one of them "perception") and needs 4 picks — the only
|
||||
# race/calling pairing where both halves of that OR are exercised at once.
|
||||
var s := _screen()
|
||||
s._race_cards[Races.IDS.find("elf")].pressed.emit()
|
||||
var cutpurse_i: int = s._allowed_callings().find("cutpurse")
|
||||
s._calling_cards[cutpurse_i].pressed.emit()
|
||||
|
||||
var pool: Array = s.draft.skill_pool()
|
||||
var perception_i: int = pool.find("perception")
|
||||
assert_ne(perception_i, -1, "cutpurse's pool must include perception for this test to mean anything")
|
||||
assert_true(s._pool_chips[perception_i].disabled,
|
||||
"a race-granted skill must be inert — the player already has it for free")
|
||||
|
||||
# Spend every pick on the OTHER skills in the pool, leaving exactly one behind.
|
||||
var picked := 0
|
||||
var left_behind_i := -1
|
||||
for i in range(pool.size()):
|
||||
if i == perception_i:
|
||||
continue
|
||||
if picked < s.draft.picks_needed():
|
||||
s._pool_chips[i].pressed.emit()
|
||||
picked += 1
|
||||
else:
|
||||
left_behind_i = i
|
||||
assert_eq(picked, s.draft.picks_needed(), "the pool must actually be spent for this test to mean anything")
|
||||
assert_ne(left_behind_i, -1, "cutpurse's pool must be wider than its pick count for this test to mean anything")
|
||||
assert_true(s._pool_chips[left_behind_i].disabled,
|
||||
"once the picks are spent, the remaining UNPICKED chip must go inert too")
|
||||
assert_false(s._pool_chips[perception_i].button_pressed,
|
||||
"the granted chip was never toggled — it was inert from the start")
|
||||
|
||||
|
||||
func test_bonus_row_shows_only_for_the_race_that_wants_one():
|
||||
# _bonus_row.visible = draft.wants_bonus_skill() — nothing in the suite checks
|
||||
# this. Human wants a bonus skill; the other three races do not.
|
||||
var s := _screen() # default race is human
|
||||
assert_true(s._bonus_row.visible, "human wants a bonus skill choice")
|
||||
|
||||
for race_id in ["elf", "dwarf", "beastfolk"]:
|
||||
s._race_cards[Races.IDS.find(race_id)].pressed.emit()
|
||||
assert_false(s._bonus_row.visible, "%s does not want a bonus skill choice" % race_id)
|
||||
|
||||
s._race_cards[Races.IDS.find("human")].pressed.emit()
|
||||
assert_true(s._bonus_row.visible, "switching back to a bonus-wanting race must show the row again")
|
||||
|
||||
|
||||
func test_ability_card_surfaces_the_calling_s_primary_stat():
|
||||
# The RACE card's Tag is checked (test_pressing_a_race_card_sets_the_draft_s_race);
|
||||
# the ability card's equivalent — Tag.visible and theme_type_variation marking
|
||||
# which of the five stats is the calling's primary — is not.
|
||||
var s := _screen()
|
||||
var idx: int = s._allowed_callings().find("cutpurse")
|
||||
s._calling_cards[idx].pressed.emit()
|
||||
var primary: String = Callings.primary("cutpurse")
|
||||
assert_eq(primary, "dex", "cutpurse's primary must be dex for this test to mean anything")
|
||||
|
||||
for i in range(Attributes.IDS.size()):
|
||||
var stat: String = Attributes.IDS[i]
|
||||
var card: PanelContainer = s._ability_cards[i]
|
||||
var is_primary := (stat == primary)
|
||||
assert_eq(card.get_node("Overlay/Tag").visible, is_primary,
|
||||
"%s's Tag must show only when it is the calling's primary stat" % stat)
|
||||
var expected_variation: StringName = ThemeKeys.PRIMARY_CARD if is_primary else ThemeKeys.ABILITY_CARD
|
||||
assert_eq(card.theme_type_variation, expected_variation,
|
||||
"%s's card style must reflect whether it is the primary stat" % stat)
|
||||
|
||||
|
||||
func test_pressing_enter_on_an_illegal_draft_emits_nothing():
|
||||
# _on_enter_pressed's early-out (`if not draft.is_complete(): return`) is the
|
||||
# one branch in the file nothing else in the suite enters — every other Enter
|
||||
# test first drives the draft to a legal state. Button.pressed still fires even
|
||||
# on a disabled Button, so the early-out must be proven explicitly.
|
||||
var s := _screen()
|
||||
assert_true(s._enter.disabled, "an empty draft must start illegal, or this test proves nothing")
|
||||
watch_signals(s)
|
||||
s._enter.pressed.emit()
|
||||
assert_signal_not_emitted(s, "creation_confirmed",
|
||||
"an illegal draft must never reach creation_confirmed, even when Enter fires anyway")
|
||||
|
||||
|
||||
# ------------------------------------------- the screen a human actually has to read
|
||||
|
||||
## Luminance gap a Label must clear against the surface it is drawn on. Every styled
|
||||
## Label on the sheet measures >= 0.419 (the dimmest is SectionLabel's ink); an UNSTYLED
|
||||
## Label — Godot's built-in Label colour is WHITE — measures 0.112 on parchment. The
|
||||
## threshold sits in that gap, nearer the failure so it cannot be squeaked past.
|
||||
const MIN_CONTRAST := 0.30
|
||||
|
||||
|
||||
func _labels_under(n: Node, out: Array = []) -> Array:
|
||||
for c in n.get_children():
|
||||
if c is Label:
|
||||
out.append(c)
|
||||
_labels_under(c, out)
|
||||
return out
|
||||
|
||||
|
||||
func _laid_out() -> void:
|
||||
## Geometry assertions are meaningless until the container tree has actually sorted.
|
||||
## On the frame add_child() runs, a race card reports 102px wide and its autowrapped
|
||||
## blurb reports a 2508px minimum height (one unwrapped line) — numbers that would
|
||||
## make a size guard fail for the wrong reason, or pass for one.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func test_no_label_on_the_parchment_sheet_is_unreadable():
|
||||
# The race and calling names, and the race blurbs, SHIPPED INVISIBLE: 15 Labels were
|
||||
# left with no type-variation, so they inherited Godot's default Label colour — white
|
||||
# — and rendered white-on-parchment. The trait line beneath them was the only readable
|
||||
# string on the card. Nothing in the suite could see it, because every assertion was
|
||||
# about text CONTENT, and the content was correct. It was the colour that was wrong.
|
||||
#
|
||||
# This is TOTAL: no Label is skipped. A Label that paints its own background (the
|
||||
# CHOSEN / PRIMARY pills) is judged against THAT background rather than the sheet, so
|
||||
# it is checked, not exempted — a whitelist here would be the same hole traps.md #16
|
||||
# describes, where the guard's own `continue` covered the gap it was hunting.
|
||||
var s := _screen()
|
||||
var sheet: Control = s.get_node("Split/Sheet")
|
||||
var parchment := Palette.SHEET_TOP.get_luminance()
|
||||
|
||||
var checked := 0
|
||||
for lab in _labels_under(sheet):
|
||||
var col: Color = lab.get_theme_color(&"font_color")
|
||||
var bg := parchment
|
||||
var surface := "the parchment sheet"
|
||||
var sb: StyleBox = lab.get_theme_stylebox(&"normal")
|
||||
if sb is StyleBoxFlat and (sb as StyleBoxFlat).bg_color.a > 0.0:
|
||||
bg = (sb as StyleBoxFlat).bg_color.get_luminance()
|
||||
surface = "its own pill"
|
||||
checked += 1
|
||||
assert_gt(absf(col.get_luminance() - bg), MIN_CONTRAST,
|
||||
"'%s' (variation '%s') is unreadable on %s: text luminance %.3f vs surface %.3f" % [
|
||||
lab.name, str(lab.theme_type_variation), surface, col.get_luminance(), bg])
|
||||
|
||||
# 15 Labels carried the bug. If this sweep ever walks fewer than that it has stopped
|
||||
# reaching the cards, and a green result would mean nothing.
|
||||
assert_gt(checked, 15, "the sweep must actually reach the sheet's labels, not an empty subtree")
|
||||
|
||||
|
||||
func test_the_primary_badge_rides_the_card_edge_instead_of_covering_the_roll():
|
||||
# The ability card is a PanelContainer. A Container FORCE-FITS its children, so the
|
||||
# badge's authored anchors were dead letters: it stretched to the card's full content
|
||||
# width (200 of 224px) and centred itself vertically, straight across the rolled value
|
||||
# — measured, not guessed. Nesting it under a plain Control (NOT a Container) restores
|
||||
# absolute positioning. These three assertions are the three ways that regressed.
|
||||
var s := _screen()
|
||||
_complete_a_valid_draft(s)
|
||||
await _laid_out()
|
||||
var primary: String = Callings.primary("sellsword")
|
||||
var i: int = Attributes.IDS.find(primary)
|
||||
assert_gt(i, -1, "the sellsword's primary must be a real attribute, or this test proves nothing")
|
||||
|
||||
var card: PanelContainer = s._ability_cards[i]
|
||||
var tag: Label = card.get_node("Overlay/Tag")
|
||||
var value: Label = card.get_node("Box/Value")
|
||||
assert_true(tag.visible, "the primary card must show its badge, or the geometry below is untested")
|
||||
|
||||
var tag_r := tag.get_global_rect()
|
||||
var card_r := card.get_global_rect()
|
||||
var value_r := value.get_global_rect()
|
||||
|
||||
assert_lt(tag_r.position.y, card_r.position.y,
|
||||
"the badge must RIDE the card's top edge, not sit inside the card")
|
||||
assert_lt(tag_r.size.x, card_r.size.x * 0.6,
|
||||
"the badge is a flag, not a banner — it stretched to the card's full width (%.0f of %.0f)" % [
|
||||
tag_r.size.x, card_r.size.x])
|
||||
assert_false(tag_r.intersects(value_r),
|
||||
"the badge is covering the rolled value: badge %s vs value %s" % [tag_r, value_r])
|
||||
|
||||
|
||||
func test_no_card_overflows_its_own_border():
|
||||
# "+1 skill of choice" and "Claws · Keen scent" rendered BELOW the race card's bottom
|
||||
# border: the card's height is a fixed custom_minimum_size, and its Box is anchored
|
||||
# inside it rather than being a container child — so content CANNOT push the card
|
||||
# taller, it just spills out. Every card must be authored tall enough for its own text.
|
||||
var s := _screen()
|
||||
await _laid_out()
|
||||
var cards: Array = []
|
||||
cards.append_array(s._race_cards)
|
||||
cards.append_array(s._calling_cards)
|
||||
assert_gt(cards.size(), 4, "no cards reached the sweep")
|
||||
|
||||
# Measure against the CARD, never against the Box's own size. Godot clamps a Control's
|
||||
# size UP to its combined minimum, so box.size.y is forced equal to the very number it
|
||||
# would be compared against — `needed <= box.size.y` is a tautology that stays green
|
||||
# with the card at its original, overflowing 108px. It was written that way first and
|
||||
# it passed against the bug it names (traps.md: a green suite is evidence only if the
|
||||
# test can fail). The card's height is the thing the text actually spills out of.
|
||||
for card in cards:
|
||||
var box: VBoxContainer = card.get_node("Box")
|
||||
var needed := box.get_combined_minimum_size().y
|
||||
# The Box is anchored inside the card; offset_bottom is negative, so this is the
|
||||
# card's height less its top and bottom insets.
|
||||
var available: float = card.size.y - box.offset_top + box.offset_bottom
|
||||
assert_lte(needed, available,
|
||||
"'%s' overflows its card: content needs %.0fpx, the card leaves %.0fpx" % [
|
||||
box.get_node("Name").text, needed, available])
|
||||
|
||||
|
||||
func test_the_hint_label_advertises_the_floor_the_roll_actually_enforces():
|
||||
# The label used to say "rolled 3d6" and stop there, but the roll is maxi(8, 3d6)
|
||||
# (new_game.gd) — so a player who rolled low read a label that looked like it was
|
||||
# lying to him. The label now names the floor, and this pins it TO THE CODE.
|
||||
#
|
||||
# The floor is OBSERVED, never hardcoded: roll a deterministic spread of seeds and
|
||||
# take the minimum the player can actually be dealt. Retune maxi(8, ..) to 7 and the
|
||||
# observed floor becomes 7, the label still claims 8, and this goes red. Delete the
|
||||
# clamp and the floor falls to 3 — red. Reword the label past the claim — red.
|
||||
#
|
||||
# It costs the phrase "never below N": reword that and update this string, do not
|
||||
# delete the guard. The number must keep coming from the roll.
|
||||
var observed_floor := 99
|
||||
for seed_value in range(1, 201): # deterministic — no RNG in the test itself
|
||||
for stat in Attributes.IDS:
|
||||
observed_floor = mini(observed_floor, int(NewGame.roll_attributes(seed_value)[stat]))
|
||||
|
||||
# ~16% of 3d6 lands at or under 7, so across 1000 rolls the minimum is the CLAMP,
|
||||
# not the luck of the draw. Assert the clamp is actually binding rather than trusting
|
||||
# that: an unclamped 3d6 reaches 3, and a floor at or below 3 would make the pin vacuous.
|
||||
assert_true(observed_floor > 3,
|
||||
"the roll's minimum is %d — the clamp is not binding, so the label's floor claim is a lie" % observed_floor)
|
||||
|
||||
var hint: Label = _screen().get_node("Split/Sheet/Body/AbilitySection/Head/Hint")
|
||||
assert_string_contains(hint.text, "never below %d" % observed_floor)
|
||||
assert_string_contains(hint.text, "3d6")
|
||||
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
1
client/tests/unit/test_character_creation_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmliumgn42tv8
|
||||
@@ -130,3 +130,51 @@ func test_blurb_content_carries_no_mechanics():
|
||||
"nightsight", "claws", "keen_scent"]:
|
||||
assert_false(db.races[id].has(banned),
|
||||
"%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned])
|
||||
|
||||
|
||||
func test_every_race_and_calling_carries_a_fragment():
|
||||
# The DM origin panel (M4-b) composes race.fragment + calling.fragment. A
|
||||
# missing one degrades to the blurb at runtime rather than crashing — but it
|
||||
# must never SHIP missing, so the suite is where it fails.
|
||||
#
|
||||
# The composition is "{race.fragment} Now you carry a {Calling}'s work —
|
||||
# {calling.fragment}", so three shape rules make all 28 pairings legal
|
||||
# sentences: a race fragment opens a sentence (uppercase) and closes it (.);
|
||||
# a calling fragment continues one after the em dash (lowercase) and closes
|
||||
# it (.). Guard them here — a future author adding a calling gets a signal,
|
||||
# not a subtly ungrammatical DM.
|
||||
for id in Races.IDS:
|
||||
assert_true(db.race(id).has("fragment"), "race %s has no fragment" % id)
|
||||
var race_fragment := str(db.race(id).get("fragment", "")).strip_edges()
|
||||
assert_ne(race_fragment, "", "race %s has an empty fragment" % id)
|
||||
assert_eq(race_fragment.substr(0, 1), race_fragment.substr(0, 1).to_upper(),
|
||||
"race %s: fragment opens the composition, so it must start uppercase" % id)
|
||||
assert_true(race_fragment.ends_with("."),
|
||||
"race %s: fragment must end with '.' — a sentence precedes 'Now you carry…'" % id)
|
||||
for id in Callings.IDS:
|
||||
assert_true(db.calling(id).has("fragment"), "calling %s has no fragment" % id)
|
||||
var calling_fragment := str(db.calling(id).get("fragment", "")).strip_edges()
|
||||
assert_ne(calling_fragment, "", "calling %s has an empty fragment" % id)
|
||||
assert_eq(calling_fragment.substr(0, 1), calling_fragment.substr(0, 1).to_lower(),
|
||||
"calling %s: fragment follows an em dash, so it must start lowercase" % id)
|
||||
assert_true(calling_fragment.ends_with("."),
|
||||
"calling %s: fragment must end with '.' — it closes the composition" % id)
|
||||
|
||||
|
||||
func test_no_calling_is_named_with_a_vowel():
|
||||
# The fourth shape rule of that same composition, and the one that breaks SILENTLY.
|
||||
# CharacterCreation._origin_prose() hardcodes the article:
|
||||
# "%s Now you carry a %s's work — %s"
|
||||
# All seven callings are consonant-initial, so all 28 compositions read correctly
|
||||
# today and nothing anywhere would notice an eighth. Add "an Auger" and every
|
||||
# Auger in the game gets "Now you carry a auger's work" on the DM panel — no
|
||||
# error, no test, just a DM who cannot speak. Fail HERE, where the author is.
|
||||
var vowels := ["a", "e", "i", "o", "u"]
|
||||
for id in Callings.IDS:
|
||||
var calling_name := str(db.calling(id).get("name", "")).strip_edges()
|
||||
assert_ne(calling_name, "", "calling %s has no name" % id)
|
||||
assert_false(calling_name.substr(0, 1).to_lower() in vowels,
|
||||
("calling %s is named '%s'. The DM origin panel composes "
|
||||
+ "\"Now you carry a %s's work\" with the article HARDCODED as \"a\" "
|
||||
+ "(CharacterCreation._origin_prose) — a vowel-initial calling needs \"an\". "
|
||||
+ "Change the template first, then this guard.") % [id, calling_name, calling_name.to_lower()])
|
||||
|
||||
214
client/tests/unit/test_creation_copy.gd
Normal file
214
client/tests/unit/test_creation_copy.gd
Normal file
@@ -0,0 +1,214 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
|
||||
var world
|
||||
|
||||
|
||||
func before_each():
|
||||
world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
|
||||
|
||||
func _deserter() -> Dictionary:
|
||||
return ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
|
||||
|
||||
func _draft(race := "human", calling := "sellsword") -> CreationDraft:
|
||||
var d := CreationDraft.new()
|
||||
d.character_seed = 8675309
|
||||
d.set_race(race)
|
||||
d.set_calling(calling)
|
||||
d.name = "Aldric"
|
||||
return d
|
||||
|
||||
|
||||
func test_calling_detail_reads_the_table_rather_than_repeating_it():
|
||||
# The guard that fails if anyone types "d8" into a string literal. If someone
|
||||
# retunes the Cutpurse's hit die, this test keeps passing and the CARD FOLLOWS —
|
||||
# which is the whole point. A hardcoded line would go red here.
|
||||
#
|
||||
# The talent is asserted through the DERIVED phrase ("talent second wind"), not
|
||||
# the raw id ("second_wind") the card used to print — but it is still read from
|
||||
# Callings.talent() at assert time, so hardcoding a talent into the format string
|
||||
# still goes red (traps.md #7: anchor to the full derived phrase).
|
||||
for id in Callings.IDS:
|
||||
var line := CreationCopy.calling_detail(id)
|
||||
assert_string_contains(line, "d%d" % Callings.hit_die(id))
|
||||
assert_string_contains(line, CreationCopy.armor_word(id))
|
||||
assert_string_contains(line, "talent %s" % CreationCopy.talent_word(id))
|
||||
assert_string_contains(line, "picks %d skills" % Callings.skill_count(id))
|
||||
for s in Callings.saves(id):
|
||||
assert_string_contains(line, s.to_upper())
|
||||
|
||||
|
||||
func test_the_talent_is_humanised_and_no_card_line_shows_a_raw_id():
|
||||
# The player read "talent second_wind" off the Sellsword's card. Four of the seven
|
||||
# talents carry an underscore — the card printed the ID.
|
||||
assert_eq(CreationCopy.talent_word("sellsword"), "second wind")
|
||||
assert_eq(CreationCopy.talent_word("cutpurse"), "backstab", "an underscore-free talent is untouched")
|
||||
|
||||
var underscored := 0
|
||||
for id in Callings.IDS:
|
||||
if Callings.talent(id).contains("_"):
|
||||
underscored += 1
|
||||
assert_false(CreationCopy.calling_detail(id).contains("_"),
|
||||
"%s's card line shows a raw snake_case id: %s" % [id, CreationCopy.calling_detail(id)])
|
||||
assert_eq(underscored, 4,
|
||||
"four talents carry an underscore — if that ever hits zero this guard stops guarding")
|
||||
|
||||
|
||||
func test_casters_show_a_pool_and_martials_show_cooldowns():
|
||||
assert_string_contains(CreationCopy.calling_detail("hedge_mage"), "MP (MAG)")
|
||||
assert_string_contains(CreationCopy.calling_detail("bonesetter"), "MP (FTH)")
|
||||
assert_string_contains(CreationCopy.calling_detail("sellsword"), "cooldowns")
|
||||
assert_false(CreationCopy.calling_detail("sellsword").contains("MP"))
|
||||
|
||||
|
||||
func test_race_trait_lines_are_derived_from_the_race_table():
|
||||
assert_string_contains(CreationCopy.race_trait("human"), "every save")
|
||||
assert_string_contains(CreationCopy.race_trait("human"), "skill of choice")
|
||||
assert_string_contains(CreationCopy.race_trait("elf"), "Perception")
|
||||
assert_string_contains(CreationCopy.race_trait("elf"), "Nightsight")
|
||||
assert_string_contains(CreationCopy.race_trait("dwarf"), "Poison")
|
||||
assert_string_contains(CreationCopy.race_trait("dwarf"), "Nightsight")
|
||||
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Claws")
|
||||
assert_string_contains(CreationCopy.race_trait("beastfolk"), "Keen scent")
|
||||
|
||||
|
||||
func test_every_race_has_a_non_empty_trait_line():
|
||||
for id in Races.IDS:
|
||||
assert_ne(CreationCopy.race_trait(id).strip_edges(), "", "%s has no trait line" % id)
|
||||
|
||||
|
||||
func test_skill_labels_are_human_readable():
|
||||
assert_eq(CreationCopy.skill_label("sleight_of_hand"), "sleight of hand")
|
||||
assert_eq(CreationCopy.skill_label("faith_lore"), "faith lore")
|
||||
assert_eq(CreationCopy.skill_label("stealth"), "stealth")
|
||||
|
||||
|
||||
func test_nothing_in_the_copy_mentions_luck():
|
||||
# §7 — the player must never be able to CALCULATE that he is cursed.
|
||||
for id in Callings.IDS:
|
||||
assert_false(CreationCopy.calling_detail(id).to_lower().contains("luck"))
|
||||
assert_false(CreationCopy.calling_detail(id).to_lower().contains("lck"))
|
||||
for id in Races.IDS:
|
||||
assert_false(CreationCopy.race_trait(id).to_lower().contains("luck"))
|
||||
assert_false(CreationCopy.race_trait(id).to_lower().contains("lck"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- §13: the nag
|
||||
|
||||
func test_the_nag_is_authored_copy_not_the_validator_s_grammar():
|
||||
# §13. The exact strings a human must be able to judge, pinned. Anchored to the
|
||||
# authored phrase itself — asserting only "no underscore" would pass on any
|
||||
# string that happens to have none, including a blank one.
|
||||
var human := _draft("human", "sellsword")
|
||||
human.name = ""
|
||||
assert_eq(CreationCopy.error_line("player name is required", human), "name yourself")
|
||||
|
||||
# "sellsword picks 2 skills, got 0" -> the count is DERIVED from the rules table.
|
||||
assert_eq(Callings.skill_count("sellsword"), 2, "the sellsword picks 2, or the line below means nothing")
|
||||
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 0", human),
|
||||
"choose two more proficiencies")
|
||||
human.toggle_skill("athletics")
|
||||
assert_eq(CreationCopy.error_line("sellsword picks 2 skills, got 1", human),
|
||||
"choose one more proficiency", "one is singular, and the number is never hardcoded")
|
||||
|
||||
# The same error against a calling that picks FOUR must say four — the proof the
|
||||
# number comes from Callings.skill_count and not from a literal "two".
|
||||
var thief := _draft("dwarf", "cutpurse")
|
||||
assert_eq(Callings.skill_count("cutpurse"), 4, "the cutpurse picks 4, or the line below means nothing")
|
||||
assert_eq(CreationCopy.error_line("cutpurse picks 4 skills, got 0", thief),
|
||||
"choose four more proficiencies")
|
||||
|
||||
assert_eq(CreationCopy.error_line("human must choose a bonus skill", human),
|
||||
"one more proficiency, any of them — take it")
|
||||
|
||||
|
||||
func test_an_unmapped_error_still_speaks_in_voice():
|
||||
# §13: "write fallbacks as content, not as error handling." An error nobody
|
||||
# anticipated (broken content; a spend the screen cannot produce) must never put
|
||||
# the raw string on the label, and must never blank it either.
|
||||
var d := _draft()
|
||||
for raw in ["unresolved ref: item:ghost_blade", "seed is required and must be an integer",
|
||||
"spend exceeds the pool of 3 (got 5)", "cannot spend on 'lck' — not an attribute"]:
|
||||
var line := CreationCopy.error_line(raw, d)
|
||||
assert_eq(line, CreationCopy.UNSPOKEN, "an unmapped error degrades to the authored fallback: %s" % raw)
|
||||
assert_ne(line, raw)
|
||||
assert_ne(CreationCopy.UNSPOKEN.strip_edges(), "", "the fallback is CONTENT — it is never blank")
|
||||
assert_false(CreationCopy.UNSPOKEN.contains("_"))
|
||||
|
||||
|
||||
func _is_deliberately_unmapped(raw: String) -> bool:
|
||||
# The shapes error_line has NO authored line for, on purpose, because the
|
||||
# screen structurally cannot produce them (CreationDraft always emits an
|
||||
# int seed and a spend built only from increment/decrement, which can
|
||||
# never exceed the pool or name a non-attribute) — a malformed seed or
|
||||
# spend, or a broken content reference, is not a player mistake to voice
|
||||
# in character; test_an_unmapped_error_still_speaks_in_voice already pins
|
||||
# the fallback for exactly this set. Named by the validator's own prefixes
|
||||
# so this stays independent of error_line's internals.
|
||||
return (raw.begins_with("unresolved ref:")
|
||||
or raw.begins_with("seed is required")
|
||||
or raw.begins_with("spend")
|
||||
or raw.begins_with("cannot spend on")
|
||||
or raw == "skills must be an array")
|
||||
|
||||
|
||||
func test_no_error_the_validator_can_raise_reaches_the_player_raw():
|
||||
# Sweep EVERY error NewGame.validate actually produces for a draft the screen can
|
||||
# be driven into (plus the hostile shapes only a saga could hand it), and assert
|
||||
# the player never sees the id, the underscore, or the validator's grammar — AND,
|
||||
# for every shape error_line actually has an authored line for, that the line
|
||||
# fired rather than silently falling through to the generic UNSPOKEN fallback.
|
||||
#
|
||||
# CreationCopy.UNSPOKEN ("something in this does not hold — look again") is
|
||||
# itself non-blank, isn't the raw string, has no underscore, and has no "got " —
|
||||
# so the four checks below cannot tell "correctly mapped to its authored line"
|
||||
# from "silently fell through." Without the UNSPOKEN check, renaming a validator
|
||||
# string in new_game.gd so an error_line branch stops matching would leave every
|
||||
# error line reading the generic fallback, suite green.
|
||||
var d := _draft()
|
||||
var hostiles: Array = [
|
||||
{"name": "", "skills": [], "bonus_skill": ""}, # nameless, unpicked
|
||||
{"skills": ["athletics"]}, # too few
|
||||
{"skills": ["athletics", "athletics"]}, # duplicate
|
||||
{"skills": ["athletics", "sorcery"]}, # not in the pool
|
||||
{"race_id": "elf", "skills": ["athletics", "perception"], "bonus_skill": ""}, # granted already
|
||||
{"bonus_skill": ""}, # human, no bonus
|
||||
{"bonus_skill": "athletics", "skills": ["athletics", "endurance"]}, # bonus == a pick
|
||||
{"bonus_skill": "swimming"}, # no such skill
|
||||
{"race_id": "dwarf", "bonus_skill": "stealth"}, # dwarf gets none
|
||||
{"race_id": "orc"}, {"calling_id": "paladin"}, # off the tables
|
||||
{"seed": "8675309"}, {"spend": {"lck": 3}}, {"spend": {"str": 9}},
|
||||
]
|
||||
var base := {
|
||||
"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "seed": 8675309,
|
||||
"spend": {}, "skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
|
||||
var seen := 0
|
||||
var mapped_seen := 0
|
||||
for h in hostiles:
|
||||
var creation := base.duplicate(true)
|
||||
for k in h:
|
||||
creation[k] = h[k]
|
||||
var errors: Array = NewGame.validate(_deserter(), world, creation)
|
||||
assert_gt(errors.size(), 0, "hostile creation %s must actually be invalid, or it guards nothing" % str(h))
|
||||
for e in errors:
|
||||
seen += 1
|
||||
var raw := str(e)
|
||||
var line: String = CreationCopy.error_line(raw, d)
|
||||
assert_ne(line.strip_edges(), "", "'%s' put a BLANK line on the label" % raw)
|
||||
assert_ne(line, raw, "'%s' reached the player raw" % raw)
|
||||
assert_false(line.contains("_"), "'%s' put a snake_case id on the label: %s" % [raw, line])
|
||||
assert_false(line.contains("got "), "'%s' put the validator's grammar on the label: %s" % [raw, line])
|
||||
|
||||
if not _is_deliberately_unmapped(raw):
|
||||
mapped_seen += 1
|
||||
assert_ne(line, CreationCopy.UNSPOKEN,
|
||||
"'%s' has an authored error_line branch but silently fell through to the generic fallback" % raw)
|
||||
assert_gt(seen, 12, "the sweep must actually reach a spread of errors, not one or two")
|
||||
assert_gt(mapped_seen, 8, "the sweep must actually exercise error_line's mapped branches, not just the deliberately-unmapped ones")
|
||||
1
client/tests/unit/test_creation_copy.gd.uid
Normal file
1
client/tests/unit/test_creation_copy.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bvb87ime573x4
|
||||
241
client/tests/unit/test_creation_draft.gd
Normal file
241
client/tests/unit/test_creation_draft.gd
Normal file
@@ -0,0 +1,241 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
|
||||
var world
|
||||
|
||||
|
||||
func before_each():
|
||||
world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
|
||||
|
||||
func _deserter() -> Dictionary:
|
||||
return ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
|
||||
|
||||
func _draft() -> CreationDraft:
|
||||
# A fixed seed, so every assertion below is reproducible.
|
||||
var d := CreationDraft.new()
|
||||
d.character_seed = 8675309
|
||||
d.set_race("human")
|
||||
d.set_calling("sellsword")
|
||||
d.name = "Aldric"
|
||||
return d
|
||||
|
||||
|
||||
func test_a_fresh_draft_has_a_seed_and_no_choices():
|
||||
var d := CreationDraft.fresh()
|
||||
assert_ne(d.character_seed, 0, "randomize() gave us the one bit of non-determinism there is")
|
||||
assert_eq(d.spend, {})
|
||||
assert_eq(d.skills, [])
|
||||
assert_eq(d.bonus_skill, "")
|
||||
|
||||
|
||||
func test_rolled_is_exactly_what_new_game_will_roll():
|
||||
var d := _draft()
|
||||
assert_eq(d.rolled(), NewGame.roll_attributes(d.character_seed),
|
||||
"the screen and construct must reach the same five numbers")
|
||||
|
||||
|
||||
func test_final_is_rolled_plus_spend():
|
||||
var d := _draft()
|
||||
d.increment("str")
|
||||
d.increment("str")
|
||||
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]) + 2)
|
||||
assert_eq(int(d.final()["dex"]), int(d.rolled()["dex"]), "an unspent stat is untouched")
|
||||
|
||||
|
||||
func test_the_pool_is_three_and_the_fourth_point_does_not_exist():
|
||||
var d := _draft()
|
||||
assert_eq(d.points_left(), 3)
|
||||
d.increment("str"); d.increment("dex"); d.increment("con")
|
||||
assert_eq(d.points_left(), 0)
|
||||
assert_false(d.can_increment("fth"))
|
||||
d.increment("fth")
|
||||
assert_eq(int(d.final()["fth"]), int(d.rolled()["fth"]), "the fourth point bought nothing")
|
||||
assert_eq(d.spent_total(), 3)
|
||||
|
||||
|
||||
func test_minus_removes_only_what_you_spent_and_floors_at_the_roll():
|
||||
var d := _draft()
|
||||
d.increment("str")
|
||||
assert_true(d.can_decrement("str"))
|
||||
d.decrement("str")
|
||||
assert_eq(d.points_left(), 3)
|
||||
assert_false(d.can_decrement("str"), "you cannot go below the roll — additive only")
|
||||
d.decrement("str")
|
||||
assert_eq(int(d.final()["str"]), int(d.rolled()["str"]))
|
||||
|
||||
|
||||
func test_reroll_changes_the_numbers_and_clears_the_spend_and_nothing_else():
|
||||
var d := _draft()
|
||||
d.set_calling("cutpurse")
|
||||
d.toggle_skill("stealth")
|
||||
d.increment("dex")
|
||||
var before := d.character_seed
|
||||
|
||||
d.reroll()
|
||||
|
||||
assert_ne(d.character_seed, before, "a re-roll IS a new seed — nothing else")
|
||||
assert_eq(d.spend, {}, "points spent against a roll that no longer exists are meaningless")
|
||||
assert_eq(d.calling_id, "cutpurse", "the calling survives")
|
||||
assert_eq(d.skills, ["stealth"], "the picks survive")
|
||||
assert_eq(d.name, "Aldric", "the name survives")
|
||||
|
||||
|
||||
func test_changing_calling_clears_the_picks():
|
||||
var d := _draft()
|
||||
d.toggle_skill("athletics")
|
||||
assert_eq(d.skills, ["athletics"])
|
||||
d.set_calling("hedge_mage")
|
||||
assert_eq(d.skills, [], "the pool changed underneath them — stale picks are an invalid draft the player did not cause")
|
||||
|
||||
|
||||
func test_changing_race_drops_a_pick_the_new_race_now_grants():
|
||||
# The elf/cutpurse case. An Elf is GRANTED perception; a Cutpurse who had
|
||||
# picked it now holds a pick that "buys nothing" and construct rejects by name.
|
||||
var d := _draft()
|
||||
d.set_calling("cutpurse")
|
||||
d.toggle_skill("perception")
|
||||
assert_eq(d.skills, ["perception"])
|
||||
d.set_race("elf")
|
||||
assert_eq(d.skills, [], "the elf already has perception — the pick is dropped, not left to 422")
|
||||
|
||||
|
||||
func test_changing_race_off_human_clears_the_bonus_skill():
|
||||
var d := _draft()
|
||||
d.set_bonus_skill("stealth")
|
||||
assert_eq(d.bonus_skill, "stealth")
|
||||
d.set_race("dwarf")
|
||||
assert_eq(d.bonus_skill, "", "only a human gets a bonus skill")
|
||||
|
||||
|
||||
func test_a_granted_skill_cannot_be_picked():
|
||||
var d := _draft()
|
||||
d.set_race("elf")
|
||||
d.set_calling("cutpurse")
|
||||
assert_true(d.is_granted("perception"))
|
||||
assert_false(d.can_pick("perception"))
|
||||
d.toggle_skill("perception")
|
||||
assert_eq(d.skills, [], "the chip is inert, not merely styled to look inert")
|
||||
|
||||
|
||||
func test_picks_stop_at_the_calling_s_count():
|
||||
var d := _draft()
|
||||
d.set_calling("sellsword") # picks 2
|
||||
d.toggle_skill("athletics")
|
||||
d.toggle_skill("endurance")
|
||||
assert_eq(d.picks_left(), 0)
|
||||
d.toggle_skill("perception")
|
||||
assert_eq(d.skills, ["athletics", "endurance"], "the third pick does not exist")
|
||||
|
||||
|
||||
func test_toggling_an_already_picked_skill_removes_it():
|
||||
var d := _draft()
|
||||
d.toggle_skill("athletics")
|
||||
d.toggle_skill("athletics")
|
||||
assert_eq(d.skills, [])
|
||||
|
||||
|
||||
func test_the_bonus_skill_cannot_also_be_picked_from_the_pool():
|
||||
# The guard was one-directional: can_take_bonus() refused a skill already PICKED,
|
||||
# but can_pick() did not consider the bonus. Human + Sellsword, take athletics as
|
||||
# the bonus, then press athletics in the Sellsword's pool — the pick landed and
|
||||
# the draft went invalid ("bonus skill athletics is already proficient"), which is
|
||||
# precisely the invalid draft "he did not cause and cannot see" that set_calling's
|
||||
# comment forbids.
|
||||
var d := _draft() # human + sellsword; athletics is in the pool
|
||||
d.set_bonus_skill("athletics")
|
||||
assert_eq(d.bonus_skill, "athletics")
|
||||
assert_true("athletics" in d.skill_pool(), "athletics must be in the sellsword's pool or this proves nothing")
|
||||
|
||||
assert_false(d.can_pick("athletics"), "the chip must be INERT, not merely styled to look inert")
|
||||
d.toggle_skill("athletics")
|
||||
assert_eq(d.skills, [], "the pick must not land while athletics is the bonus skill")
|
||||
|
||||
# ...and the draft the press would have produced is exactly the one construct rejects.
|
||||
d.skills = ["athletics", "endurance"] # what the bug let the player build
|
||||
assert_true("bonus skill athletics is already proficient" in d.errors(_deserter(), world),
|
||||
"this IS the invalid draft the guard exists to prevent")
|
||||
|
||||
|
||||
func test_the_other_direction_still_holds_a_picked_skill_cannot_become_the_bonus():
|
||||
# The half of the guard that already worked. Kept beside its mirror so the two
|
||||
# can never drift apart again.
|
||||
var d := _draft()
|
||||
d.toggle_skill("athletics")
|
||||
assert_eq(d.skills, ["athletics"])
|
||||
assert_false(d.can_take_bonus("athletics"))
|
||||
d.set_bonus_skill("athletics")
|
||||
assert_eq(d.bonus_skill, "", "a picked skill must not also be taken as the bonus")
|
||||
|
||||
|
||||
func test_handing_back_the_bonus_skill_makes_the_pool_chip_live_again():
|
||||
# The inert chip is recoverable — the bonus row is right there. Without this the
|
||||
# fix above would be a dead end the player could walk into and not walk out of.
|
||||
var d := _draft()
|
||||
d.set_bonus_skill("athletics")
|
||||
assert_false(d.can_pick("athletics"))
|
||||
d.set_bonus_skill("athletics") # pressing the chosen bonus chip clears it
|
||||
assert_eq(d.bonus_skill, "")
|
||||
assert_true(d.can_pick("athletics"), "handing the bonus back must make the pool chip live")
|
||||
d.toggle_skill("athletics")
|
||||
assert_eq(d.skills, ["athletics"])
|
||||
|
||||
|
||||
func test_to_creation_carries_exactly_the_seven_contract_keys():
|
||||
# §2, stated as a test rather than as a comment. The screen emits a seed and four
|
||||
# choices; it never hands construct a NUMBER. test_pressing_enter_emits_exactly_
|
||||
# the_draft_s_creation_dict compares the emitted dict to to_creation() — both
|
||||
# sides move together, so adding an "attributes" key there breaks nothing. This
|
||||
# pins the contract itself: seven keys, no more, no fewer.
|
||||
var d := _draft()
|
||||
var keys: Array = d.to_creation().keys()
|
||||
keys.sort()
|
||||
assert_eq(keys, ["bonus_skill", "calling_id", "name", "race_id", "seed", "skills", "spend"],
|
||||
"the creation contract is exactly seven keys — an eighth (an 'attributes' block, say) is the §2 breach")
|
||||
|
||||
|
||||
func test_to_creation_round_trips_through_construct():
|
||||
var d := _draft()
|
||||
d.toggle_skill("athletics")
|
||||
d.toggle_skill("endurance")
|
||||
d.set_bonus_skill("perception")
|
||||
d.increment("str")
|
||||
|
||||
var res := NewGame.construct(_deserter(), world, d.to_creation())
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
assert_eq(res["state"].sheet.attributes, d.final(),
|
||||
"TRAP 1: what the screen SHOWED is what the player GOT — asserted across the pipeline")
|
||||
|
||||
|
||||
func test_errors_are_construct_s_errors_not_a_second_copy_of_the_rules():
|
||||
var d := _draft() # no skills picked yet
|
||||
assert_gt(d.errors(_deserter(), world).size(), 0)
|
||||
assert_false(d.is_complete(_deserter(), world))
|
||||
|
||||
d.toggle_skill("athletics")
|
||||
d.toggle_skill("endurance")
|
||||
d.set_bonus_skill("perception")
|
||||
assert_eq(d.errors(_deserter(), world), [], "a legal draft has nothing wrong with it")
|
||||
assert_true(d.is_complete(_deserter(), world))
|
||||
|
||||
|
||||
func test_an_unnamed_draft_is_not_complete():
|
||||
var d := _draft()
|
||||
d.toggle_skill("athletics")
|
||||
d.toggle_skill("endurance")
|
||||
d.set_bonus_skill("perception")
|
||||
d.name = " "
|
||||
assert_false(d.is_complete(_deserter(), world), "a nameless character cannot enter the world")
|
||||
|
||||
|
||||
func test_the_draft_never_exposes_luck():
|
||||
# §7 — no accessor, no field, no leak. The screen cannot show what it cannot reach.
|
||||
var d := _draft()
|
||||
assert_false("lck" in d.to_creation().get("spend", {}))
|
||||
assert_false(d.to_creation().has("luck"))
|
||||
assert_false(d.final().has("lck"))
|
||||
assert_false(d.rolled().has("lck"))
|
||||
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
1
client/tests/unit/test_creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqllxamt2tcgu
|
||||
71
client/tests/unit/test_main.gd
Normal file
71
client/tests/unit/test_main.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const SCENE := "res://scenes/Main.tscn"
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
|
||||
func _flow() -> Node:
|
||||
# Inject fakes BEFORE _ready (add_child): a real world + a network-free service,
|
||||
# so the shell's initial narrate never touches HTTP.
|
||||
var node = load(SCENE).instantiate()
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
node.world = world
|
||||
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
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 _creation() -> Dictionary:
|
||||
return {
|
||||
"name": "Dagnet", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
|
||||
|
||||
func test_boots_into_the_title():
|
||||
var flow := _flow()
|
||||
assert_true(flow._current is TitleScreen, "Main opens on the title")
|
||||
|
||||
|
||||
func test_new_game_shows_creation():
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
assert_true(flow._current is CharacterCreation, "new game routes to creation")
|
||||
|
||||
|
||||
func test_confirming_creation_reaches_the_shell_as_the_created_character():
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
flow._current.emit_signal("creation_confirmed", _creation())
|
||||
assert_true(flow._current is MainWindowShell, "creation routes to the shell")
|
||||
assert_eq(flow._current._log.player.name, "Dagnet",
|
||||
"the CREATED character flowed through — not a seed, not a swap alone")
|
||||
|
||||
|
||||
func _assert_stays_on_creation(bad: Dictionary, why: String) -> void:
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
# Drive the REAL signal with an invalid dict. The CTA gates this out via the UI,
|
||||
# but §2 forbids Main trusting the caller and §13 forbids surfacing an error.
|
||||
flow._current.emit_signal("creation_confirmed", bad)
|
||||
assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why)
|
||||
assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why)
|
||||
|
||||
|
||||
func test_construct_failure_empty_name_stays_on_creation():
|
||||
var bad := _creation()
|
||||
bad["name"] = "" # empty name fails NewGame.validate
|
||||
_assert_stays_on_creation(bad, "empty name")
|
||||
|
||||
|
||||
func test_construct_failure_bad_calling_stays_on_creation():
|
||||
var bad := _creation()
|
||||
bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate
|
||||
_assert_stays_on_creation(bad, "unknown calling")
|
||||
1
client/tests/unit/test_main.gd.uid
Normal file
1
client/tests/unit/test_main.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://17abdh3almry
|
||||
@@ -3,6 +3,8 @@ 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")
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _shell() -> MainWindowShell:
|
||||
@@ -47,3 +49,36 @@ func test_shell_seed_player_has_a_real_race_and_calling():
|
||||
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
|
||||
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
|
||||
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
|
||||
|
||||
|
||||
func _built_character():
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
var creation := {
|
||||
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
return NewGame.construct(origin, world, creation)
|
||||
|
||||
|
||||
func _shell_with_character(res) -> MainWindowShell:
|
||||
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())
|
||||
node.injected_log = res["log"]
|
||||
node.injected_state = res["state"]
|
||||
add_child_autofree(node)
|
||||
return node
|
||||
|
||||
|
||||
func test_shell_shows_the_injected_character_not_the_seed():
|
||||
var res = _built_character()
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
var node := _shell_with_character(res)
|
||||
assert_same(node._log, res["log"], "the injected canon log drives narration")
|
||||
var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()]
|
||||
assert_eq(node._hp.text, expected, "HP label derives from the real sheet")
|
||||
assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals")
|
||||
|
||||
@@ -300,3 +300,86 @@ func test_non_companion_override_goes_to_state_not_log():
|
||||
for m in res["log"].to_dict()["party"]:
|
||||
assert_ne(m["id"], "oda_fenn")
|
||||
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
|
||||
|
||||
|
||||
func test_roll_attributes_is_pure_and_seeded():
|
||||
# Same seed, same five. The screen shows these; construct builds from them.
|
||||
var a := NewGame.roll_attributes(8675309)
|
||||
var b := NewGame.roll_attributes(8675309)
|
||||
assert_eq(a, b)
|
||||
assert_eq(a.keys(), Attributes.IDS, "the five rows, in the contract's order")
|
||||
assert_false(a.has("lck"), "LCK is not an attribute and never leaves construct (§7)")
|
||||
|
||||
|
||||
func test_roll_attributes_holds_the_floor_of_eight():
|
||||
# Sweep enough seeds that a straight 3d6 would certainly have rolled below 8
|
||||
# somewhere. (GUT has no assert_gte — assert_true on the comparison.)
|
||||
var lowest := 999
|
||||
for s in range(400):
|
||||
for stat in Attributes.IDS:
|
||||
lowest = mini(lowest, int(NewGame.roll_attributes(s)[stat]))
|
||||
assert_true(lowest >= 8, "the floor broke: something rolled %d" % lowest)
|
||||
assert_eq(lowest, 8, "the floor should actually BITE across 2000 rolls, not sit unused")
|
||||
|
||||
|
||||
func test_construct_builds_exactly_what_roll_attributes_showed():
|
||||
# TRAP 1 (traps.md): this is the load-bearing claim of the creation SCREEN —
|
||||
# "the numbers you saw are the numbers you got". Asserting that two fresh
|
||||
# rolls agree would pass even if construct kept its own second implementation
|
||||
# of the die. This asserts across the PIPELINE: what roll_attributes returns,
|
||||
# plus the spend, IS what construct puts on the sheet.
|
||||
var spend := {"str": 2, "con": 1}
|
||||
var res := _build(_creation({"seed": 4242, "spend": spend}))
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
|
||||
var shown := NewGame.roll_attributes(4242)
|
||||
var expected := {}
|
||||
for stat in Attributes.IDS:
|
||||
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
|
||||
|
||||
assert_eq(res["state"].sheet.attributes, expected)
|
||||
|
||||
|
||||
func test_construct_ignores_an_attribute_block_handed_to_it():
|
||||
# §2 — "code owns state" — stated as a TEST, not as a comment. Every other test
|
||||
# here feeds construct a well-behaved creation dict, so nothing in the suite ever
|
||||
# proves construct REFUSES a number: a construct that trusted creation["attributes"]
|
||||
# would pass all of them. Hand it a hostile one — an 18 in every stat — and assert
|
||||
# the sheet is still, exactly, roll_attributes(seed) + spend.
|
||||
var spend := {"str": 2, "con": 1}
|
||||
var hostile := _creation({
|
||||
"seed": 4242,
|
||||
"spend": spend,
|
||||
"attributes": {"str": 18, "dex": 18, "con": 18, "fth": 18, "mag": 18},
|
||||
})
|
||||
var res := _build(hostile)
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
|
||||
var shown := NewGame.roll_attributes(4242)
|
||||
var expected := {}
|
||||
for stat in Attributes.IDS:
|
||||
expected[stat] = int(shown[stat]) + int(spend.get(stat, 0))
|
||||
assert_eq(res["state"].sheet.attributes, expected,
|
||||
"construct re-rolls from the SEED — a stat block handed to it is not state, it is noise (§2)")
|
||||
|
||||
# And the hostile block is not merely ignored on the sheet — it never becomes state
|
||||
# anywhere else either. `res["log"].to_dict().has("attributes")` (the old check here)
|
||||
# was already false before this test's action ran: LogPlayer.to_dict() has no such
|
||||
# TOP-LEVEL key on any code path, hostile block or not, so it could never fail — delete
|
||||
# construct's whole §2 guard and this stayed green. Assert the player row's exact key
|
||||
# SET instead (sorted — insertion order in LogPlayer.to_dict() isn't the contract):
|
||||
# it feeds the AI (§2/§7), so a stray "attributes", "str", or anything else reaching it
|
||||
# is a real leak, and this is what actually fails if one does.
|
||||
var player_keys: Array = res["log"].to_dict()["player"].keys()
|
||||
player_keys.sort()
|
||||
assert_eq(player_keys, ["calling_id", "luck_descriptor", "name", "race_id"],
|
||||
"the canon-log player row carries a key beyond name/race_id/calling_id/luck_descriptor — numeric state reached the AI-facing log (§2/§7)")
|
||||
|
||||
|
||||
func test_validate_is_public_and_agrees_with_construct():
|
||||
var bad := _creation({"calling_id": "paladin"})
|
||||
var errors := NewGame.validate(_deserter(), world, bad)
|
||||
assert_gt(errors.size(), 0)
|
||||
var res := _build(bad)
|
||||
assert_false(res["ok"])
|
||||
assert_eq(res["errors"], errors, "the screen must be told exactly what construct would reject")
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _built():
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
var creation := {
|
||||
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
return NewGame.construct(origin, world, creation)
|
||||
|
||||
|
||||
func test_for_character_derives_vitals_and_location_from_the_real_character():
|
||||
var res = _built()
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
var state = res["state"]
|
||||
var log = res["log"]
|
||||
var s := ShellState.for_character(state, log)
|
||||
assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet")
|
||||
assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet")
|
||||
assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet")
|
||||
assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet")
|
||||
assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)")
|
||||
assert_eq(s.location_label, log.location.name, "location from the canon log")
|
||||
assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet")
|
||||
|
||||
|
||||
func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory():
|
||||
# turn_order/consumables/round_label have no real source yet (M5/M7 own them);
|
||||
# for_character must reuse seed()'s placeholders, not invent its own.
|
||||
var res = _built()
|
||||
var s := ShellState.for_character(res["state"], res["log"])
|
||||
var seed := ShellState.seed()
|
||||
assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder")
|
||||
assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder")
|
||||
assert_eq(s.round_label, seed.round_label, "round label is still the placeholder")
|
||||
|
||||
|
||||
func test_turn_entry_holds_fields():
|
||||
var e := TurnEntry.new("DW", 12, &"ally")
|
||||
|
||||
@@ -29,22 +29,166 @@ func test_default_font_is_set():
|
||||
assert_true(t.default_font is FontFile)
|
||||
|
||||
|
||||
func _guarded_variations() -> Array:
|
||||
# EVERY variation OR BASE TYPE the builder touches — the stylebox variations
|
||||
# (ThemeKeys.ALL), the six font roles (ThemeKeys.FONT_ROLES), AND the base
|
||||
# types the builder styles directly with no variation on top
|
||||
# (ThemeKeys.BASE_TYPES — today just RichTextLabel). ALL and FONT_ROLES
|
||||
# deliberately exclude each other's half by their own docstrings; missing
|
||||
# BASE_TYPES here left RichTextLabel's two fonts, its font size and its
|
||||
# font colour — the ONLY things rendering DM prose on the creation screen —
|
||||
# unguarded against a stale .tres. traps.md #12, the third miss: iterating
|
||||
# ALL alone, then ALL+FONT_ROLES, both still left a gap "covers everything"
|
||||
# did not actually cover.
|
||||
var out: Array = ThemeKeys.ALL.keys()
|
||||
out.append_array(ThemeKeys.FONT_ROLES.keys())
|
||||
out.append_array(ThemeKeys.BASE_TYPES.keys())
|
||||
return out
|
||||
|
||||
|
||||
func test_the_drift_guard_covers_every_variation_the_builder_touches():
|
||||
# The guard below is only as good as the set it walks. Pin that set to the
|
||||
# builder's own output: every variation the fresh Theme carries a stylebox, a
|
||||
# font, a font size or a font colour for MUST be in _guarded_variations(), or
|
||||
# it can drift unwatched. A new variation added to the builder and forgotten in
|
||||
# ThemeKeys goes red here rather than sitting unguarded forever.
|
||||
var fresh: Theme = Builder.build_theme()
|
||||
var guarded := _guarded_variations()
|
||||
for variation in fresh.get_type_list():
|
||||
var touched := (not fresh.get_stylebox_list(variation).is_empty()
|
||||
or not fresh.get_font_list(variation).is_empty()
|
||||
or not fresh.get_font_size_list(variation).is_empty()
|
||||
or not fresh.get_color_list(variation).is_empty())
|
||||
if not touched:
|
||||
continue
|
||||
if fresh.get_type_variation_base(variation) == &"":
|
||||
# "" means one of two things, and they must be told apart, not both
|
||||
# waved through: (a) a KNOWN base type the builder styles directly
|
||||
# with no set_type_variation (RichTextLabel today — registered in
|
||||
# ThemeKeys.BASE_TYPES), or (b) a type nobody ever registered at
|
||||
# all — styled with set_stylebox/set_font/set_color but never
|
||||
# given a set_type_variation, which reports the same "" and used
|
||||
# to slip through this `continue` forever. The old guard could not
|
||||
# tell these apart because it treated "" as "definitely (a)" and
|
||||
# skipped it unconditionally — the exact whitelist that let
|
||||
# RichTextLabel through in the first place (traps.md #12, third
|
||||
# miss). Assert (a); anything else IS (b) and must be named.
|
||||
assert_true(variation in ThemeKeys.BASE_TYPES,
|
||||
("the builder styles %s directly (no set_type_variation) and it is not in " +
|
||||
"ThemeKeys.BASE_TYPES — add it there, or if it should be a real variation, " +
|
||||
"give it a set_type_variation(...) base so ALL/FONT_ROLES can guard it") % variation)
|
||||
continue
|
||||
assert_true(variation in guarded,
|
||||
"the builder styles the variation %s but no drift guard walks it — add it to ThemeKeys.ALL or ThemeKeys.FONT_ROLES" % variation)
|
||||
|
||||
|
||||
func test_committed_tres_matches_builder():
|
||||
# Catches "palette/builder changed but nobody regenerated game_theme.tres."
|
||||
# Preloading the builder script and calling its static build_theme() does
|
||||
# NOT run _init() (that only fires on .new()), so this is side-effect-free.
|
||||
#
|
||||
# Drives its checks off ThemeKeys.ALL *and* ThemeKeys.FONT_ROLES so every
|
||||
# variation is guarded, not just whichever three someone remembered to hardcode
|
||||
# here — and not just the stylebox half. For each variation: every stylebox
|
||||
# state, every font, every font size and every font colour the builder actually
|
||||
# set is compared. Nothing the builder never touches is asserted on.
|
||||
var fresh: Theme = Builder.build_theme()
|
||||
var committed: Theme = load(THEME_PATH)
|
||||
|
||||
var checks := [
|
||||
[ThemeKeys.PRIMARY_CTA, &"normal"],
|
||||
[ThemeKeys.PARCHMENT_CARD, &"panel"],
|
||||
[ThemeKeys.DARK_PANEL, &"panel"],
|
||||
]
|
||||
for pair in checks:
|
||||
var variation: StringName = pair[0]
|
||||
var state: StringName = pair[1]
|
||||
var fresh_box: StyleBoxFlat = fresh.get_stylebox(state, variation)
|
||||
var committed_box: StyleBoxFlat = committed.get_stylebox(state, variation)
|
||||
assert_eq(fresh_box.bg_color, committed_box.bg_color,
|
||||
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
# Theme-LEVEL defaults (build_game_theme.gd:69-70) live on the Theme object
|
||||
# itself, not under any type or variation, so no per-variation loop below
|
||||
# ever reaches them — they need their own comparison or a changed
|
||||
# default_font_size (18 -> anything) ships silently. Font by resource_path,
|
||||
# not the Resource: load() caches, so two references to the same .ttf are
|
||||
# `==` even when the builder swapped fonts, unless the .tres happens to
|
||||
# still point at the same file.
|
||||
assert_eq(committed.default_font.resource_path, fresh.default_font.resource_path,
|
||||
"stale game_theme.tres — theme.default_font drifted from the builder; re-run build_game_theme.gd")
|
||||
assert_eq(committed.default_font_size, fresh.default_font_size,
|
||||
"stale game_theme.tres — theme.default_font_size drifted from the builder; re-run build_game_theme.gd")
|
||||
|
||||
for variation in _guarded_variations():
|
||||
for state in fresh.get_stylebox_list(variation):
|
||||
assert_true(committed.has_stylebox(state, variation),
|
||||
"stale game_theme.tres — %s/%s stylebox is missing entirely; re-run build_game_theme.gd" % [variation, state])
|
||||
var fresh_box: StyleBox = fresh.get_stylebox(state, variation)
|
||||
var committed_box: StyleBox = committed.get_stylebox(state, variation)
|
||||
assert_eq(committed_box.get_class(), fresh_box.get_class(),
|
||||
"stale game_theme.tres — %s/%s stylebox type drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
|
||||
if fresh_box is StyleBoxFlat:
|
||||
var f: StyleBoxFlat = fresh_box
|
||||
var c: StyleBoxFlat = committed_box
|
||||
assert_eq(c.bg_color, f.bg_color,
|
||||
"stale game_theme.tres — %s/%s bg_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.border_color, f.border_color,
|
||||
"stale game_theme.tres — %s/%s border_color drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.border_width_left, f.border_width_left,
|
||||
"stale game_theme.tres — %s/%s border_width_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.border_width_top, f.border_width_top,
|
||||
"stale game_theme.tres — %s/%s border_width_top drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.border_width_right, f.border_width_right,
|
||||
"stale game_theme.tres — %s/%s border_width_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.border_width_bottom, f.border_width_bottom,
|
||||
"stale game_theme.tres — %s/%s border_width_bottom drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.corner_radius_top_left, f.corner_radius_top_left,
|
||||
"stale game_theme.tres — %s/%s corner_radius_top_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.corner_radius_top_right, f.corner_radius_top_right,
|
||||
"stale game_theme.tres — %s/%s corner_radius_top_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.corner_radius_bottom_right, f.corner_radius_bottom_right,
|
||||
"stale game_theme.tres — %s/%s corner_radius_bottom_right drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
assert_eq(c.corner_radius_bottom_left, f.corner_radius_bottom_left,
|
||||
"stale game_theme.tres — %s/%s corner_radius_bottom_left drifted from the builder; re-run build_game_theme.gd" % [variation, state])
|
||||
|
||||
# The FONT half — the whole reason this guard could not fail against a font
|
||||
# role. Compare the font's resource_path, not the Resource: `load()` caches,
|
||||
# so two references to the same .ttf are the same instance and `==` would be
|
||||
# true even when the builder swapped SERIF for MONO... only if the .tres
|
||||
# happened to point at the same file anyway. The path is the thing that drifts.
|
||||
for font_name in fresh.get_font_list(variation):
|
||||
assert_true(committed.has_font(font_name, variation),
|
||||
"stale game_theme.tres — %s/%s font is missing entirely; re-run build_game_theme.gd" % [variation, font_name])
|
||||
var fresh_font: Font = fresh.get_font(font_name, variation)
|
||||
var committed_font: Font = committed.get_font(font_name, variation)
|
||||
assert_eq(committed_font.resource_path, fresh_font.resource_path,
|
||||
"stale game_theme.tres — %s/%s font drifted from the builder; re-run build_game_theme.gd" % [variation, font_name])
|
||||
|
||||
for size_name in fresh.get_font_size_list(variation):
|
||||
assert_true(committed.has_font_size(size_name, variation),
|
||||
"stale game_theme.tres — %s/%s font size is missing entirely; re-run build_game_theme.gd" % [variation, size_name])
|
||||
assert_eq(committed.get_font_size(size_name, variation), fresh.get_font_size(size_name, variation),
|
||||
"stale game_theme.tres — %s/%s font size drifted from the builder; re-run build_game_theme.gd" % [variation, size_name])
|
||||
|
||||
for color_name in fresh.get_color_list(variation):
|
||||
assert_true(committed.has_color(color_name, variation),
|
||||
"stale game_theme.tres — %s/%s colour is missing entirely; re-run build_game_theme.gd" % [variation, color_name])
|
||||
var fresh_color: Color = fresh.get_color(color_name, variation)
|
||||
var committed_color: Color = committed.get_color(color_name, variation)
|
||||
assert_eq(committed_color, fresh_color,
|
||||
"stale game_theme.tres — %s/%s drifted from the builder; re-run build_game_theme.gd" % [variation, color_name])
|
||||
|
||||
|
||||
func test_creation_variations_exist_with_the_states_the_screen_needs():
|
||||
var t: Theme = load(THEME_PATH)
|
||||
|
||||
# A race/calling card is a Button that must show a CHOSEN state.
|
||||
assert_true(t.has_stylebox(&"normal", ThemeKeys.SELECT_CARD))
|
||||
assert_true(t.has_stylebox(&"hover", ThemeKeys.SELECT_CARD))
|
||||
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SELECT_CARD), "pressed IS the chosen ring")
|
||||
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SELECT_CARD), "an origin may disallow a calling")
|
||||
|
||||
# A skill chip is a toggle with three states: unpicked, picked, and granted-so-inert.
|
||||
assert_true(t.has_stylebox(&"normal", ThemeKeys.SKILL_CHIP))
|
||||
assert_true(t.has_stylebox(&"pressed", ThemeKeys.SKILL_CHIP))
|
||||
assert_true(t.has_stylebox(&"disabled", ThemeKeys.SKILL_CHIP))
|
||||
|
||||
# The tags and the primary ring.
|
||||
assert_true(t.has_stylebox(&"normal", ThemeKeys.CHOSEN_TAG))
|
||||
assert_true(t.has_stylebox(&"normal", ThemeKeys.PRIMARY_TAG))
|
||||
assert_true(t.has_stylebox(&"panel", ThemeKeys.PRIMARY_CARD))
|
||||
|
||||
|
||||
func test_section_label_is_a_font_role_on_parchment():
|
||||
var t: Theme = load(THEME_PATH)
|
||||
assert_true(t.has_font(&"font", ThemeKeys.SECTION_LABEL))
|
||||
assert_eq(t.get_color(&"font_color", ThemeKeys.SECTION_LABEL), Palette.INK_LABEL_MUTED)
|
||||
|
||||
@@ -38,8 +38,7 @@ func test_up_from_first_wraps_to_last():
|
||||
|
||||
func test_new_game_and_quit_emit_their_action():
|
||||
var t := _title()
|
||||
# Isolate the signal seam so the test does not actually change scene / quit.
|
||||
t.menu_activated.disconnect(t._on_menu_activated)
|
||||
# Title is emit-only now: no internal handler to isolate. Just watch the emit.
|
||||
watch_signals(t)
|
||||
t._activate(_index_of(t, &"new_game"))
|
||||
assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"])
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled." }
|
||||
{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled.", "fragment": "something answered when you called it, and it has been keeping accounts ever since." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough." }
|
||||
{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough.", "fragment": "you are the one they wake at the third hour, and when it goes the other way you wash the body yourself and answer the door again the next night." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it." }
|
||||
{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it.", "fragment": "you pick your man and take what he will not miss until morning, and the towns where you picked wrong still have your description at the gate." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it." }
|
||||
{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it.", "fragment": "you taught yourself the rest where nobody would come looking, and the scars are from the parts the book got wrong." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it." }
|
||||
{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it.", "fragment": "they send for you when the talking is finished, and they make a point of being somewhere else when you arrive." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }
|
||||
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out.", "fragment": "you sell the swing and let the man paying keep the reasons." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait." }
|
||||
{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait.", "fragment": "you learned to sit still in the wet dark until something else made the mistake." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak." }
|
||||
{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak.", "fragment": "Most towns will take your coin and still want you outside the walls by dark. You stopped taking that personally a long time ago." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." }
|
||||
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it.", "fragment": "You came up in stone and cold water, among folk who bury their dead deep and do not speak of them again." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed." }
|
||||
{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed.", "fragment": "You come from a people the Margreave tolerates from a distance, and you have never been in a room where that was not clear." }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }
|
||||
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth.", "fragment": "You were born in one of the Margreave's thousand small towns, and it let you go without asking where." }
|
||||
|
||||
@@ -36,18 +36,25 @@ M0–M2 built the **engine** (the contract, the client, the server loop, bounded
|
||||
| **M1 — Prove the server loop** ✅ | A real model sits behind `/dm/narrate` and the pipeline is reusable. |
|
||||
| **M2 — Prove aliveness** ✅ | **Does bounded AI dialogue feel alive?** — answered; bounded NPC dialogue + DM loop live-proven. |
|
||||
| **M3 — Visual foundation & shell** ✅ | The `Theme` + the exploration HUD; every screen inherits the look and lives in the shell. |
|
||||
| **M4 — Character creation** ◀ *here* | The player enters the world; the proven stat/Luck model gets its UI. |
|
||||
| **M4 — Character creation** ✅ | The player enters the world; the proven stat/Luck model gets its UI. |
|
||||
| **M5 — Tactical combat (gridless)** | Real stakes: the Combat HUD turn manager. |
|
||||
| **M6 — Dialogue screen** | The proven NPC free-text loop inside the mock's conversation UI. |
|
||||
| **M7 — Inventory & character sheet** | State the player can see and manage. |
|
||||
| **M8 — World systems** | World-map travel, a shop economy, the quest log. |
|
||||
| **M9 — Framing, AI-flavor & persistence** | Title/pause, save/load, and the Improviser/Banter/Luck-drift roles + fallback sweep. |
|
||||
|
||||
### Landed out of band
|
||||
**▶ Next (before M5):** *Provider flexibility — Ollama Cloud + a backend switch* (in **Landed / planned out of band**, below). A laptop-dev enabler: it lets the proxy serve the same Ollama-hosted models over an API key from any machine, so development continues away from the homelab. Not a game-screen milestone — a cross-cutting infra item taken now because the alternative is a laptop with no AI.
|
||||
|
||||
Not every item is a milestone. These shipped on their own because a later milestone
|
||||
would have been more expensive to build on top of the old shape.
|
||||
### Landed / planned out of band
|
||||
|
||||
Not every item is a milestone. Some shipped on their own because a later milestone
|
||||
would have been more expensive to build on top of the old shape; some are planned
|
||||
cross-cutting work that does not belong to a single screen.
|
||||
|
||||
- ▶ **Provider flexibility — Ollama Cloud + a backend switch** *(next; not yet specced — a future session builds it).* Development spans two machines: a desktop with the homelab Ollama, and a laptop with no homelab reach. Today the proxy's model-call pipeline (M1) only knows a **local** Ollama, so the laptop has no AI at all. This adds **Ollama Cloud** as a backend so the same Ollama-hosted models are reachable over an **API key** from anywhere, and a **switch** to flip between `local-ollama` (desktop, free/offline) and `ollama-cloud` (laptop) — and the door stays open for prod's Replicate (§4) as a third backend.
|
||||
- **§4 is load-bearing — the key and the provider choice live server-side, in the proxy.** The client never holds an API key and never learns which backend serves a role (§4). Concretely: extend the proxy's Ollama httpx client (M1) to target an **Ollama-compatible cloud endpoint** with an **`Authorization: Bearer <key>`** header, the key read from the proxy's environment / `.env`, never a client binary. The "flip a switch" is a **proxy config/env** value selecting the backend (endpoint + key + per-role model ids) — this is the M1 role→model routing config growing a *backend* axis, a deploy/config change, not a client patch, exactly as §4 intends.
|
||||
- **The client's Settings-button angle (reconcile, don't silently adopt):** the "config file surfaced by the title **Settings** button" is a real feature, but under §4 it splits in two. **Legit client setting:** the **proxy base URL / profile** (§4 lets the client know *one base URL*) — a persisted client config choosing "laptop proxy" vs "desktop proxy," editable via the M9 Settings screen. **NOT a client setting:** the AI **provider and API key** — those stay in the proxy; the Settings screen must never hold the key or pick the backend, or the §4 boundary leaks into a shippable binary. So the honest "flip a switch" is *two* switches — a proxy-side provider/key config (this item, the immediate laptop-dev enabler) and a client-side base-URL/profile that **folds into M9's settings + persistence**. For laptop work right now, only the proxy switch is needed.
|
||||
- *§2: text (the provider serves text; the client stays provider-agnostic) · depends on the M1 pipeline · goal: AI on any machine, switchable — and a proxy that is already multi-backend before Replicate (§4) arrives.*
|
||||
- ✅ **Currency — copper with real denominations** ([spec](superpowers/specs/2026-07-12-currency-copper-design.md)). `1g = 100s = 10,000c`, **stored entirely in copper** as one `GameState.purse_copper` int, formatted only at the display edge (`◈ 3s 47c`). `coin.json` → `copper`/`silver`/`gold`. Money is **not** an inventory item: `give_item`/`accept_item` carry no quantity and `gifts_given` is a global one-shot, so a lone `copper` id would have been givable once, for one copper, per campaign — the denominations are **move tokens** instead (`give_item(gold)` = +10,000c), routed to the purse and never into `inventory`, so money cannot surface in an inventory grid by construction. Taken before M7 while `content/world/items/` held two files. **Prices remain M8's content BOM — nothing is priced yet.** *§2: state · goal: money that means something.*
|
||||
- ✅ **A move applies at most once per reply** — found by the review of the above. `TagExtractor` returns *every* tag occurrence and `MoveValidator` is a membership test, so a model repeating a tag N times picked an amount **in unary** (§2 breach: the AI choosing a number). Now deduped by (name, id) across a reply, and `adjust_disposition`'s `MAX_DELTA` caps the reply's **net** swing rather than each tag — it was per-tag, so three `+15`s moved standing by 45, the exact "wholesale swing" the constant forbids. *§2: state · goal: the bounded vocabulary stays bounded.*
|
||||
|
||||
@@ -71,14 +78,14 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
|
||||
### M3 — Visual foundation & shell ✅
|
||||
- ✅ **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
|
||||
- ✅ **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
|
||||
- ○ **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||
- ✅ **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). Built and committed (`a77bf03`, `f071392`); this entry was stale — the roadmap had not caught up and M4-c would have been misled into thinking it still had to build one. **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||
|
||||
### M4 — Character creation
|
||||
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.***
|
||||
- ✅ **Creation model (M4-a)** — the rules a character is made of, no UI ([spec](superpowers/specs/2026-07-12-creation-model-design.md)). Race/calling/skill **mechanics** are static tables in code (`client/scripts/rules/`); their **blurbs** are hand-written content (`content/world/{callings,races}/`, loaded by `ContentDB`, parity-tested), so the content BOM does not block the engine. `CharacterSheet` **stores only what was rolled or chosen** (attributes, race, calling, skills, current hp/mp) and **derives** the rest (`max_hp`, `ac`, `save`, `skill_bonus`, `spell_dc`) — so no derived number can drift from its inputs, and M5's level curve is a function change rather than a stored-field migration. **`NewGame.construct` carries a seed, not a stat block:** it rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed yields an identical character, hidden Luck included (§10) — pinned by a golden-vector test. The roll is **3d6 with a hard floor of 8** (straight 3d6 puts ~9% on a primary the +3 pool cannot rescue; a character bad at the one thing he is *for* is the "fine" §7 forbids). **LCK is unspendable, not merely hidden.** Open seams marked honestly: `ac()` is `10 + DEX` until armor exists (M7), `max_mp()` is a `TUNABLE` placeholder (M5 owns the curve), L1 talents are id stubs (M5). **Live-proven end-to-end** (2026-07-12): a beastfolk cutpurse built by the pipeline → canon log → schema validation → `Player: Vexcca, a beastfolk cutpurse` in the digest (no numeric Luck, no stats, no snake_case id) → real narration from qwen3.5. 250 client tests, 76 api (live layer included). *§2: state · goal: the character exists in code.*
|
||||
- ✅ **Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree.
|
||||
- ○ **Character creation UI (M4-b)** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
||||
- ○ **Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
|
||||
- ✅ **Character creation UI (M4-b)** — the mock's screen over M4-a's model: four race cards and seven nameplate calling cards (blurb + mechanics shown only for the *chosen* calling), the **+3 additive spend pool** with `−` flooring at the rolled base, proficiency chips gated by the chosen calling's pool (plus the Human bonus-skill row), five ability cards — **never six, and the word "luck" appears nowhere on the screen** (§7) — and the DM origin panel composing race + calling fragments. Five mock/model reconciles settled in the spec's §4 (skill picking added, seven callings not five, blurb-on-chosen-only, `+`/`−` both present, the portrait `‹ ›` arrows cut for lacking art). **Architecture:** a pure, node-free `CreationDraft` (`RefCounted`) holds every rule — changing calling clears stale skill picks, changing race drops a now-granted pick, re-roll clears the spend and only the spend, `errors()` delegates to `NewGame.validate` rather than keeping a second copy of the rules; `CreationCopy` is a pure static formatter that derives every display string (hit die, armour word, saves, skill count) from the `Races`/`Callings`/`Skills` tables, so retuning a calling retunes its card without touching a string literal; the `.tscn` owns the layout per ADR 0001 and `character_creation.gd` only binds. **The §2 property holds without the screen having to be trustworthy:** the screen shows its five numbers by calling the same newly-public `NewGame.roll_attributes(seed)` that `construct` calls internally — the screen never hands a number to anything, only a seed and four choices; `construct` rolls the identical attributes again, itself, off the same seed. Re-roll is unlimited (the floor of 8 already prevents a dead primary), and every re-roll silently re-rolls the hidden LCK draw riding the same RNG stream — the screen says nothing about this, ever. Emits `creation_confirmed(creation: Dictionary)`; does not call `construct` itself, so a saga can synthesize the same Dictionary later and skip the scene entirely. **Test count: 250 → 302.** **The honest seams:** three rounds of adversarial review found ten guard-shaped assertions across this milestone that could not fail against the bug they named (substring collisions, a BBCode wrapper masking an empty ContentDB, a test asserting the script's own loop bound, a trivial origin fixture, assertions already true before the action under test ran, a theme-drift guard checking three hardcoded variations instead of all of them, a `Label` sweep that missed `RichTextLabel`, and tests that called handlers directly instead of pressing nodes) — all rewritten against the actual artifact and reverted-and-reconfirmed-red per `docs/traps.md`'s standard; the new species are folded into that file. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
||||
- ✅ **Title → creation → shell flow (M4-c)** — the *new game* path is wired end-to-end ([spec](superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md)). A script-first **`Main`** coordinator node (`scenes/Main.tscn`, now the `main_scene`) owns the Title → Creation → Shell sequence in one file, swapping screens via **instantiate → inject → add_child** — the injection seam `change_scene_to_file` cannot provide, and the reason it, not a linear chain, holds the flow. The **Title became emit-only** (it announces `menu_activated`; the coordinator routes it — it no longer knows what comes next), and the **shell stopped faking a character**: new `injected_log`/`injected_state` seams + a `ShellState.for_character(state, log)` factory mean the created character's **HP/MP/purse, location, and opening narration are real** (`seed()`/`_build_seed_log()` stay as the F6/standalone fallback, exactly like `service == null`). The flow carries only a plain `creation` Dictionary; `NewGame.construct` re-rolls the character from its seed itself (§2). **⛨ saga honored structurally:** `_show_creation()` and `_start_campaign(creation)` are separate methods, so the fourth entry path ("begin the next campaign of an existing saga") is an *added `menu_activated` arm* that synthesizes a Dictionary and calls `_start_campaign` directly — no existing edge changes. **§13:** a `construct` that returns `{ok:false}` (unreachable via the CTA gate, but the coordinator distrusts its caller anyway) logs and stays on creation — never a crash, never a half-built shell. Built subagent-per-task with a per-task + final whole-branch review; every new guard reverted-and-reconfirmed-red per `docs/traps.md`. **Test count: 302 → 327.** The coordinator was named `Main` (not `GameFlow`) so the boot scene is self-evident; `TitleScreen` is unchanged and shown *by* `Main`. *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
|
||||
|
||||
### M5 — Tactical combat (gridless)
|
||||
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***
|
||||
|
||||
2173
docs/superpowers/plans/2026-07-13-character-creation-ui.md
Normal file
2173
docs/superpowers/plans/2026-07-13-character-creation-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
# M4-c — Title → Creation → Shell Flow 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:** Wire the title screen's *new game* path through character creation and into the exploration shell, with the created character flowing all the way through — HP/MP/purse/location/opening-narration all real.
|
||||
|
||||
**Architecture:** A new script-first `GameFlow` root node becomes the `main_scene` and owns the Title → Creation → Shell sequence in one file, swapping screens via `instantiate()` → inject → `add_child()` (the injection seam that `change_scene_to_file` cannot provide). The title becomes emit-only; the shell gains `injected_log`/`injected_state` seams and stops self-seeding on the real path (its `seed()`/`_build_seed_log()` stay as the F6/standalone fallback).
|
||||
|
||||
**Tech Stack:** Godot 4.7, GDScript, GUT 9.7 for tests.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-07-15-m4c-title-creation-shell-flow-design.md`](../specs/2026-07-15-m4c-title-creation-shell-flow-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **§2 (code owns state):** the flow carries only plain data; `NewGame.construct` re-rolls the character from the creation seed itself. No task hands a computed stat between screens.
|
||||
- **⛨ saga:** do NOT weld `new game → creation → shell` into one unit. `_show_creation()` and `_start_campaign(creation)` stay separate methods so a fourth entry path is an added `menu_activated` arm, not surgery on existing edges.
|
||||
- **§13:** a failure never reaches the player as an error. A `construct` that returns `{ok:false}` logs and aborts the transition; it never crashes or shows a half-built shell.
|
||||
- **No autoloads / no new global state.** Use the existing injection-seam pattern (set a property between `instantiate()` and `add_child()`).
|
||||
- **ADR 0001 (editor-first UI):** applies to *Control layout* scenes. `GameFlow` is a pure coordinator with no layout, so it is script-first by design — this is not a violation.
|
||||
- **traps.md discipline:** every new guard must be reverted-and-watched-red before acceptance. Drive screens by emitting their real signals, never by calling handlers directly.
|
||||
- **Test runner:** whole suite `./run_tests.sh` (from `client/`, ~4s). Single file: `./run_tests.sh -gselect=test_NAME.gd`. Single method: add `-gunit_test_name=test_method`.
|
||||
- All paths below are relative to `client/` unless noted.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New:**
|
||||
- `scripts/ui/flow/game_flow.gd` — the coordinator: owns `world`/`service`/`origin`, holds the Title→Creation→Shell sequence, one `_show` swap primitive.
|
||||
- `scenes/flow/GameFlow.tscn` — one `Node` with `game_flow.gd` attached. The new `main_scene`.
|
||||
- `tests/unit/test_game_flow.gd` — flow tests (new game reaches shell as the created character; construct-failure stays put).
|
||||
|
||||
**Modified:**
|
||||
- `scripts/ui/shell/shell_state.gd` — add `for_character(state, log)` factory.
|
||||
- `scripts/ui/shell/main_window_shell.gd` — add `injected_log`/`injected_state` seams; seed calls become fallbacks.
|
||||
- `scripts/ui/title/title_screen.gd` — emit-only (drop `const SHELL`, `_on_menu_activated`, the self-connect).
|
||||
- `project.godot` — `run/main_scene` → `GameFlow.tscn`.
|
||||
- `tests/unit/test_shell_state.gd` — `for_character` test.
|
||||
- `tests/unit/test_main_window_shell.gd` — injected-path test.
|
||||
- `tests/unit/test_title_screen.gd` — drop the `_on_menu_activated` disconnect (it no longer exists).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ShellState.for_character` factory
|
||||
|
||||
Derive real HUD vitals + location from a constructed character, keeping the turn-order/consumables/round placeholders honest (combat=M5, inventory=M7 own those).
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/ui/shell/shell_state.gd`
|
||||
- Test: `tests/unit/test_shell_state.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GameState` (`.sheet.hp`, `.sheet.max_hp()`, `.sheet.mp`, `.sheet.max_mp()`, `.purse_copper`), `CanonLog` (`.location.name`), `NewGame.construct` (test only).
|
||||
- Produces: `static func ShellState.for_character(state: GameState, log: CanonLog) -> ShellState`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/unit/test_shell_state.gd`:
|
||||
|
||||
```gdscript
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _built():
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
var creation := {
|
||||
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
return NewGame.construct(origin, world, creation)
|
||||
|
||||
|
||||
func test_for_character_derives_vitals_and_location_from_the_real_character():
|
||||
var res = _built()
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
var state = res["state"]
|
||||
var log = res["log"]
|
||||
var s := ShellState.for_character(state, log)
|
||||
assert_eq(s.vitals["hp"], state.sheet.hp, "hp from the sheet")
|
||||
assert_eq(s.vitals["hp_max"], state.sheet.max_hp(), "hp_max from the sheet")
|
||||
assert_eq(s.vitals["mp"], state.sheet.mp, "mp from the sheet")
|
||||
assert_eq(s.vitals["mp_max"], state.sheet.max_mp(), "mp_max from the sheet")
|
||||
assert_eq(s.vitals["purse_copper"], state.purse_copper, "purse from origin grants (47c)")
|
||||
assert_eq(s.location_label, log.location.name, "location from the canon log")
|
||||
assert_ne(s.vitals["hp_max"], 60, "NOT the seed's hardcoded 60 — this is a real L1 sheet")
|
||||
|
||||
|
||||
func test_for_character_keeps_the_seed_placeholders_for_combat_and_inventory():
|
||||
# turn_order/consumables/round_label have no real source yet (M5/M7 own them);
|
||||
# for_character must reuse seed()'s placeholders, not invent its own.
|
||||
var res = _built()
|
||||
var s := ShellState.for_character(res["state"], res["log"])
|
||||
var seed := ShellState.seed()
|
||||
assert_eq(s.turn_order.size(), seed.turn_order.size(), "turn rail is still the placeholder")
|
||||
assert_eq(s.consumables, seed.consumables, "consumables are still the placeholder")
|
||||
assert_eq(s.round_label, seed.round_label, "round label is still the placeholder")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_shell_state.gd`
|
||||
Expected: FAIL — `Invalid call. Nonexistent function 'for_character' in base 'RefCounted (ShellState)'`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add to `scripts/ui/shell/shell_state.gd` (after `seed()`):
|
||||
|
||||
```gdscript
|
||||
static func for_character(state: GameState, log: CanonLog) -> ShellState:
|
||||
## The real HUD state for a constructed character. Vitals + location come from
|
||||
## the character (§2 — code owns them); turn_order/consumables/round_label are
|
||||
## still seed() placeholders because combat (M5) and inventory (M7) own those.
|
||||
var s := seed() # start from the placeholders, then overwrite what is real
|
||||
s.vitals = {
|
||||
"hp": state.sheet.hp,
|
||||
"hp_max": state.sheet.max_hp(),
|
||||
"mp": state.sheet.mp,
|
||||
"mp_max": state.sheet.max_mp(),
|
||||
"purse_copper": state.purse_copper,
|
||||
}
|
||||
s.location_label = log.location.name
|
||||
return s
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_shell_state.gd`
|
||||
Expected: PASS (existing seed tests + 2 new tests).
|
||||
|
||||
- [ ] **Step 5: Re-break to prove the guard (traps.md)**
|
||||
|
||||
Temporarily change `"hp_max": state.sheet.max_hp()` to `"hp_max": 60`. Run the test.
|
||||
Expected: FAIL on `test_for_character_derives_vitals_and_location_from_the_real_character` (both the `==` and the `assert_ne 60`). Revert the change; re-run; PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd
|
||||
git commit -m "feat(m4c): ShellState.for_character derives vitals+location from the real character"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Shell consumes an injected character
|
||||
|
||||
Add the `injected_log`/`injected_state` seams; make the seed calls fallbacks. The seed path stays fully working for F6/standalone.
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/ui/shell/main_window_shell.gd:21-23` (seams) and `:43-44` (fallback wiring)
|
||||
- Test: `tests/unit/test_main_window_shell.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ShellState.for_character(state, log)` (Task 1).
|
||||
- Produces: shell public seams `injected_log: CanonLog`, `injected_state: GameState`, set before `add_child` (mirrors the existing `service` seam).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/unit/test_main_window_shell.gd`:
|
||||
|
||||
```gdscript
|
||||
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _built_character():
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
var origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
var creation := {
|
||||
"name": "Aldric", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
return NewGame.construct(origin, world, creation)
|
||||
|
||||
|
||||
func _shell_with_character(res) -> MainWindowShell:
|
||||
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())
|
||||
node.injected_log = res["log"]
|
||||
node.injected_state = res["state"]
|
||||
add_child_autofree(node)
|
||||
return node
|
||||
|
||||
|
||||
func test_shell_shows_the_injected_character_not_the_seed():
|
||||
var res = _built_character()
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
var node := _shell_with_character(res)
|
||||
assert_same(node._log, res["log"], "the injected canon log drives narration")
|
||||
var expected := "HP %d / %d" % [res["state"].sheet.hp, res["state"].sheet.max_hp()]
|
||||
assert_eq(node._hp.text, expected, "HP label derives from the real sheet")
|
||||
assert_ne(node._hp.text, "HP 42 / 60", "NOT the seed's Vexcca vitals")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_main_window_shell.gd`
|
||||
Expected: FAIL — `Invalid set index 'injected_log'` (the property does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
In `scripts/ui/shell/main_window_shell.gd`, add the seams next to `service` (currently line 21):
|
||||
|
||||
```gdscript
|
||||
var service: DmService = null # injection seam; real HttpTransport built if null
|
||||
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
|
||||
var injected_state: GameState = null # injection seam; ShellState.seed() if null
|
||||
```
|
||||
|
||||
Then in `_ready()` replace the two seed lines (currently lines 43-44):
|
||||
|
||||
```gdscript
|
||||
_state = ShellState.seed()
|
||||
_log = _build_seed_log()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```gdscript
|
||||
# The injected character wins; the seed calls are the F6/standalone fallback —
|
||||
# same shape as `service == null` building a real transport below.
|
||||
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
|
||||
_log = injected_log if injected_log != null else _build_seed_log()
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_main_window_shell.gd`
|
||||
Expected: PASS (existing seed/fallback tests still green — they inject only `service`, so both seams stay null and the seed path runs — plus the new injected test).
|
||||
|
||||
- [ ] **Step 5: Re-break to prove the guard (traps.md)**
|
||||
|
||||
Temporarily force the seed path: change the `_state`/`_log` lines to always use `ShellState.seed()` / `_build_seed_log()`. Run the test.
|
||||
Expected: FAIL on `test_shell_shows_the_injected_character_not_the_seed` (`_log` mismatch + `HP 42 / 60`). Revert; re-run; PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/shell/main_window_shell.gd client/tests/unit/test_main_window_shell.gd
|
||||
git commit -m "feat(m4c): shell consumes an injected character (seed stays the F6 fallback)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Title becomes emit-only
|
||||
|
||||
Strip the title's knowledge of what comes next. It already emits `menu_activated(id)`; the coordinator will route it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/ui/title/title_screen.gd` (remove `const SHELL`, `_on_menu_activated`, the self-connect)
|
||||
- Test: `tests/unit/test_title_screen.gd:39-47`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: `TitleScreen` emits `menu_activated(id: StringName)` and does not self-route (no scene changes, no quit from within the screen).
|
||||
|
||||
- [ ] **Step 1: Update the test first (it currently disconnects a method we are deleting)**
|
||||
|
||||
In `tests/unit/test_title_screen.gd`, replace `test_new_game_and_quit_emit_their_action` (lines 39-47) with:
|
||||
|
||||
```gdscript
|
||||
func test_new_game_and_quit_emit_their_action():
|
||||
var t := _title()
|
||||
# Title is emit-only now: no internal handler to isolate. Just watch the emit.
|
||||
watch_signals(t)
|
||||
t._activate(_index_of(t, &"new_game"))
|
||||
assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"])
|
||||
t._activate(_index_of(t, &"quit"))
|
||||
assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_title_screen.gd`
|
||||
Expected: PASS still (the removed `disconnect` line was the only thing that would break — but `_on_menu_activated` still exists right now, so this test passes both before and after the edit). To get a real red first, temporarily rename `_activate`'s emit target — SKIP; instead this task's red/green is driven by Step 4 after the source change. Proceed to Step 3.
|
||||
|
||||
- [ ] **Step 3: Make the title emit-only**
|
||||
|
||||
In `scripts/ui/title/title_screen.gd`:
|
||||
|
||||
1. Delete `const SHELL := "res://scenes/shell/MainWindowShell.tscn"` (line 12).
|
||||
2. In `_ready()`, delete the line `menu_activated.connect(_on_menu_activated)` (line 47).
|
||||
3. Delete the entire `_on_menu_activated` function (lines 131-138):
|
||||
|
||||
```gdscript
|
||||
func _on_menu_activated(id: StringName) -> void:
|
||||
match id:
|
||||
&"new_game":
|
||||
get_tree().change_scene_to_file(SHELL)
|
||||
&"quit":
|
||||
get_tree().quit()
|
||||
_:
|
||||
pass # inert placeholder — the screen lands in a later milestone
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full suite**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_title_screen.gd`
|
||||
Expected: PASS. `test_new_game_and_quit_emit_their_action` still asserts the emit; nothing self-routes.
|
||||
|
||||
- [ ] **Step 5: Verify the title no longer references downstream scenes**
|
||||
|
||||
Run: `grep -n "SHELL\|change_scene\|_on_menu_activated" client/scripts/ui/title/title_screen.gd`
|
||||
Expected: no matches. (This is the proof the screen is emit-only; it cannot be a runtime assert.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/title/title_screen.gd client/tests/unit/test_title_screen.gd
|
||||
git commit -m "feat(m4c): title screen is emit-only (routing moves to GameFlow)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `GameFlow` coordinator
|
||||
|
||||
The single home for the Title → Creation → Shell sequence. Injection seams on `world`/`service`/`origin` keep it headless-testable.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ui/flow/game_flow.gd`
|
||||
- Create: `scenes/flow/GameFlow.tscn`
|
||||
- Test: `tests/unit/test_game_flow.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TitleScreen.menu_activated(id)`, `CharacterCreation` seams (`world`, `origin`) + signal `creation_confirmed(creation)`, `NewGame.construct(origin, world, creation) -> {ok, errors, log, state}`, `MainWindowShell` seams (`service`, `injected_log`, `injected_state`).
|
||||
- Produces: `GameFlow` node with public seams `world: ContentDB`, `service: DmService`, `origin: Dictionary`, and `_current: Node` (the active screen, read by tests).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/unit/test_game_flow.gd`:
|
||||
|
||||
```gdscript
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const SCENE := "res://scenes/flow/GameFlow.tscn"
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||
|
||||
|
||||
func _flow() -> Node:
|
||||
# Inject fakes BEFORE _ready (add_child): a real world + a network-free service,
|
||||
# so the shell's initial narrate never touches HTTP.
|
||||
var node = load(SCENE).instantiate()
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
node.world = world
|
||||
node.origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
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 _creation() -> Dictionary:
|
||||
return {
|
||||
"name": "Dagnet", "race_id": "human", "calling_id": "sellsword",
|
||||
"seed": 8675309, "spend": {},
|
||||
"skills": ["athletics", "endurance"], "bonus_skill": "perception",
|
||||
}
|
||||
|
||||
|
||||
func test_boots_into_the_title():
|
||||
var flow := _flow()
|
||||
assert_true(flow._current is TitleScreen, "GameFlow opens on the title")
|
||||
|
||||
|
||||
func test_new_game_shows_creation():
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
assert_true(flow._current is CharacterCreation, "new game routes to creation")
|
||||
|
||||
|
||||
func test_confirming_creation_reaches_the_shell_as_the_created_character():
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
flow._current.emit_signal("creation_confirmed", _creation())
|
||||
assert_true(flow._current is MainWindowShell, "creation routes to the shell")
|
||||
assert_eq(flow._current._log.player.name, "Dagnet",
|
||||
"the CREATED character flowed through — not a seed, not a swap alone")
|
||||
|
||||
|
||||
func _assert_stays_on_creation(bad: Dictionary, why: String) -> void:
|
||||
var flow := _flow()
|
||||
flow._current.emit_signal("menu_activated", &"new_game")
|
||||
# Drive the REAL signal with an invalid dict. The CTA gates this out via the UI,
|
||||
# but §2 forbids GameFlow trusting the caller and §13 forbids surfacing an error.
|
||||
flow._current.emit_signal("creation_confirmed", bad)
|
||||
assert_false(flow._current is MainWindowShell, "a failed construct never reaches the shell (§13): %s" % why)
|
||||
assert_true(flow._current is CharacterCreation, "the player stays on the valid creation screen: %s" % why)
|
||||
|
||||
|
||||
func test_construct_failure_empty_name_stays_on_creation():
|
||||
var bad := _creation()
|
||||
bad["name"] = "" # empty name fails NewGame.validate
|
||||
_assert_stays_on_creation(bad, "empty name")
|
||||
|
||||
|
||||
func test_construct_failure_bad_calling_stays_on_creation():
|
||||
var bad := _creation()
|
||||
bad["calling_id"] = "bard" # NPC-only / not a real calling — fails NewGame.validate
|
||||
_assert_stays_on_creation(bad, "unknown calling")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_game_flow.gd`
|
||||
Expected: FAIL — the scene `res://scenes/flow/GameFlow.tscn` does not exist yet (load returns null).
|
||||
|
||||
- [ ] **Step 3: Write the coordinator script**
|
||||
|
||||
Create `scripts/ui/flow/game_flow.gd`:
|
||||
|
||||
```gdscript
|
||||
class_name GameFlow
|
||||
extends Node
|
||||
## The single home for the game's screen sequence: Title → Creation → Shell.
|
||||
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
|
||||
## editor-first rule does not apply (that rule governs layout scenes). It owns one
|
||||
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
|
||||
## the injection seam change_scene_to_file cannot provide.
|
||||
##
|
||||
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
|
||||
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
|
||||
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
|
||||
|
||||
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||
|
||||
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
|
||||
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
|
||||
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
|
||||
|
||||
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
|
||||
# do for their own deps. _ready builds the real ones when nothing was supplied.
|
||||
var world: ContentDB = null
|
||||
var service: DmService = null
|
||||
var origin: Dictionary = {}
|
||||
|
||||
var _current: Node = null
|
||||
var _http: HTTPRequest
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if world == null:
|
||||
world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
if origin.is_empty():
|
||||
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
if service == null:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||
_show_title()
|
||||
|
||||
|
||||
func _show(next: PackedScene, inject: Callable) -> void:
|
||||
## The one swap primitive. inject runs after instantiate() (which does NOT run
|
||||
## _ready) and before add_child (which does) — the injection window. The outgoing
|
||||
## screen is queue_free'd (deferred): a signal handler must never free the node
|
||||
## still executing up its own call stack.
|
||||
var old := _current
|
||||
var screen := next.instantiate()
|
||||
inject.call(screen)
|
||||
_current = screen
|
||||
add_child(screen)
|
||||
if old != null:
|
||||
old.queue_free()
|
||||
|
||||
|
||||
func _show_title() -> void:
|
||||
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
|
||||
|
||||
|
||||
func _show_creation() -> void:
|
||||
_show(CREATION, func(s):
|
||||
s.world = world
|
||||
s.origin = origin
|
||||
s.creation_confirmed.connect(_start_campaign))
|
||||
|
||||
|
||||
func _show_shell(log: CanonLog, state: GameState) -> void:
|
||||
_show(SHELL, func(s):
|
||||
s.service = service
|
||||
s.injected_log = log
|
||||
s.injected_state = state)
|
||||
|
||||
|
||||
func _on_menu(id: StringName) -> void:
|
||||
match id:
|
||||
&"new_game":
|
||||
_show_creation()
|
||||
&"quit":
|
||||
get_tree().quit()
|
||||
_:
|
||||
pass # continue / load land in later milestones (M9); inert for now
|
||||
|
||||
|
||||
func _start_campaign(creation: Dictionary) -> void:
|
||||
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
|
||||
## a saga can synthesize one and call this directly (skipping creation).
|
||||
var result := NewGame.construct(origin, world, creation)
|
||||
if not result.get("ok", false):
|
||||
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
|
||||
# the caller and §13 forbids surfacing an error. Log and stay put.
|
||||
push_error("GameFlow: construct rejected a creation: %s" % str(result.get("errors", [])))
|
||||
return
|
||||
_show_shell(result["log"], result["state"])
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the scene**
|
||||
|
||||
Create `scenes/flow/GameFlow.tscn` — a single `Node` with the script attached. Author it in the editor (New Scene → Node → attach `res://scripts/ui/flow/game_flow.gd` → save as `res://scenes/flow/GameFlow.tscn`), or write the file directly:
|
||||
|
||||
```
|
||||
[gd_scene load_steps=2 format=3 uid="uid://coc_gameflow"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/ui/flow/game_flow.gd" id="1"]
|
||||
|
||||
[node name="GameFlow" type="Node"]
|
||||
script = ExtResource("1")
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `./run_tests.sh -gselect=test_game_flow.gd`
|
||||
Expected: PASS — boots into title; new_game → creation; creation_confirmed → shell with `player.name == "Dagnet"`; both construct-failure parameterizations stay on creation.
|
||||
|
||||
- [ ] **Step 6: Re-break to prove the guards (traps.md)**
|
||||
|
||||
1. In `_start_campaign`, temporarily delete the `if not result.get("ok"...)` early return. Run `test_game_flow.gd` → `test_construct_failure_empty_name_stays_on_creation` and `test_construct_failure_bad_calling_stays_on_creation` must FAIL (a rejected construct reaches the shell). Revert.
|
||||
2. In `test_confirming_creation...`, temporarily change the created name to `"Dagnet"` → assert against `"Aldric"`. Run → FAIL (proves the name assertion reads the real flowed character, not a constant). Revert.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/flow/game_flow.gd client/scenes/flow/GameFlow.tscn client/tests/unit/test_game_flow.gd
|
||||
git commit -m "feat(m4c): GameFlow coordinator wires title -> creation -> shell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Make `GameFlow` the entry point + smoke the whole loop
|
||||
|
||||
Flip `main_scene` and confirm the real, non-injected boot path start-to-shell (ADR 0001: the final visual gate is a person).
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.godot` (`run/main_scene`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything above.
|
||||
- Produces: the shipped boot path — launching the client opens the title; *new game* leads through creation into a shell showing the created character.
|
||||
|
||||
- [ ] **Step 1: Flip the main scene**
|
||||
|
||||
In `project.godot`, change:
|
||||
|
||||
```
|
||||
run/main_scene="res://scenes/title/TitleScreen.tscn"
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```
|
||||
run/main_scene="res://scenes/flow/GameFlow.tscn"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the full suite**
|
||||
|
||||
Run: `./run_tests.sh`
|
||||
Expected: PASS. All prior tests + the new Task 1/2/4 tests. Note the new total (was 319; +~7 new tests).
|
||||
|
||||
- [ ] **Step 3: Manual smoke (human/visual gate — ADR 0001)**
|
||||
|
||||
Launch the client (F5 in the editor, or `godot --path client`). Verify the *real* path with no injected fakes:
|
||||
1. The **title** appears (embers, gold rule).
|
||||
2. Select **New Game** → the **character creation** screen appears (four race cards, seven calling cards, five ability cards, no "luck").
|
||||
3. Build a legal character, give a name, press **Enter the World**.
|
||||
4. The **shell** appears; the DM narration book opens on *this* character's scene (grounded in the deserter origin — Greywater, the desertion), and the command bar HP/MP/purse reflect the built character (not Vexcca's 42/60, ◈3s 47c).
|
||||
|
||||
Record the result. If narration shows the degraded fallback (API down), that is acceptable — the character/vitals must still be the created one.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/project.godot
|
||||
git commit -m "feat(m4c): GameFlow is the main scene — title->creation->shell is the boot path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §1 problem (title skips creation; shell fakes) → Tasks 3, 2, 5. ✓
|
||||
- §3.1 GameFlow (owns world/service/origin, `_show`, sequence table, saga seam) → Task 4. ✓
|
||||
- §3.2 title emit-only → Task 3. ✓
|
||||
- §3.3 creation unchanged → no task (correct — it already emits). ✓
|
||||
- §3.4 shell seams + `ShellState.for_character` → Tasks 1, 2. ✓
|
||||
- §4 data flow → exercised by Task 4's shell-reached-as-created-character test. ✓
|
||||
- §5 error handling → Task 4's `test_construct_failure_{empty_name,bad_calling}_stays_on_creation` + re-break. ✓
|
||||
- §6 testing (real signals, name-proves-flow, injected vitals, for_character unit, construct-failure) → Tasks 1,2,4. ✓
|
||||
- §3.1 main_scene flip → Task 5. ✓
|
||||
- ⛨ saga seam (separate `_show_creation`/`_start_campaign`) → Task 4 script. ✓
|
||||
|
||||
**Placeholder scan:** none — every step has concrete code/commands.
|
||||
|
||||
**Type consistency:** `for_character(state, log)` defined Task 1, consumed Task 2 (via shell `_ready`) and Task 4 (via shell). `injected_log`/`injected_state` defined Task 2, set in Task 4's `_show_shell`. `_current`, `_start_campaign(creation)`, `_show_creation()` consistent across Task 4 script + test. `NewGame.construct` return keys (`ok`/`errors`/`log`/`state`) match the real signature. ✓
|
||||
@@ -0,0 +1,340 @@
|
||||
# M4-b — the character creation screen
|
||||
|
||||
**Date:** 2026-07-13
|
||||
**Status:** approved, ready for planning
|
||||
**Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §13 (authored
|
||||
fallback is content), §15 (button-driven), §16 (editor-first UI), §18 (git)
|
||||
**Consumes:** [M4-a — the creation model](2026-07-12-creation-model-design.md). Everything
|
||||
here is a screen over rules that already exist and are already tested.
|
||||
**Mock:** `mockups/Character Creation.dc.html` — the UI bible, and where it and the model
|
||||
disagree, §4 below records the decision.
|
||||
**Roadmap:** M4, split **M4-a (the model, done) → M4-b (this: the screen) → M4-c (the
|
||||
title→creation→shell flow)**.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
The mock's creation screen, over M4-a's proven model. The player picks a race and a
|
||||
calling, sees five rolled attributes (LCK never), spends a pool of three points, chooses
|
||||
proficiencies, types a name, and presses ENTER THE WORLD.
|
||||
|
||||
**It makes no AI calls.** Not one. The DM's real entrance is the shell's opening narration
|
||||
one screen later (M4-c). This screen is authored text and local rules end to end, and runs
|
||||
with no proxy and no model reachable.
|
||||
|
||||
**It does not construct the character.** It emits a creation `Dictionary` and M4-c calls
|
||||
`NewGame.construct`. The scene is a producer of that Dictionary, never the only possible
|
||||
producer — the saga guardrail M4-a wrote down.
|
||||
|
||||
---
|
||||
|
||||
## 2. What is actually there today (verified at `b4b72e4`)
|
||||
|
||||
- `NewGame.construct(origin, world, creation)` is built, seeded and tested. It takes a
|
||||
**seed, not a stat block**, rolls the five attributes itself in a fixed order, then rolls
|
||||
LCK off the same RNG, applies `spend`, race grants, calling picks, and returns
|
||||
`{ok, errors, log, state}`.
|
||||
- `_validate` is **private**. `_roll_attribute` is **private** and called in a loop inside
|
||||
`construct`.
|
||||
- `Races` (4), `Callings` (7), `Skills` (9), `Attributes` (5) are static tables in
|
||||
`client/scripts/rules/`. `CharacterSheet` stores inputs and derives the rest.
|
||||
- `ContentDB` loads `content/world/races/` and `callings/`. Each JSON carries
|
||||
`{id, name, blurb}` and **no mechanics** — the M4-a split.
|
||||
- The theme has a `Chip` variation (`ThemeKeys.CHIP`) and a `PrimaryCTA`. Both were built
|
||||
for a single visual state.
|
||||
- `TitleScreen` exists and `MainWindowShell` exists. Nothing connects them to creation —
|
||||
that is M4-c.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three units, one of which is a scene.
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `client/scenes/creation/CharacterCreation.tscn` | **new.** The screen. Editor-authored tree, theme on the root (ADR 0001). |
|
||||
| `client/scripts/ui/creation/character_creation.gd` | **new.** The binder. `@onready` refs, click → draft, draft → nodes. **No rules.** |
|
||||
| `client/scripts/ui/creation/creation_draft.gd` | **new.** `CreationDraft` — pure, node-free. Every rule the screen enforces. |
|
||||
| `client/scripts/ui/creation/creation_copy.gd` | **new.** `CreationCopy` — pure, static. Formats *mechanics* into display strings by reading the rules tables. |
|
||||
| `client/scripts/newgame/new_game.gd` | `roll_attributes()` and `validate()` become **public**; `construct` calls `roll_attributes` instead of its own loop. |
|
||||
| `content/world/{races,callings}/*.json` | Each gains a `fragment` (§6). |
|
||||
| `client/scripts/theme/build_game_theme.gd` + `theme_keys.gd` | Chip `pressed`/`disabled`, CTA `disabled` (§7). |
|
||||
|
||||
**Why a `CreationDraft` and not logic in the scene script.** ADR 0001 already decided this:
|
||||
the script binds, it does not reason. A draft that is a plain `RefCounted` makes every rule
|
||||
("can a Human re-pick a skill his race already granted?") answerable headlessly, with no
|
||||
scene tree, which is the difference between a rule that is tested and a rule that is
|
||||
merely written. `ShellState` is the same pattern; this follows it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Where the mock and the model disagree — and what wins
|
||||
|
||||
The mock predates the seven callings and the skill system. It is a **UI bible, not a rules
|
||||
bible.** Five reconciles, all decided:
|
||||
|
||||
| # | The mock says | We do | Why |
|
||||
|---|---|---|---|
|
||||
| 1 | No skill picking at all | **A skills section** — chips, N of a pool | `construct` *rejects* a creation dict without exactly `Callings.skill_count(id)` legal picks. Without the UI the pools do nothing, every Cutpurse is identical, and we ship a validation rule no player exercises. |
|
||||
| 2 | Five callings | **Seven**, nameplate cards | The seven are named and settled (races/classes spec §4). |
|
||||
| 3 | Blurb on every calling card | **Blurb on the *chosen* calling only** | Seven blurbs shouting at once is a menu you skim, not a choice. See §5. |
|
||||
| 4 | `+` only on the spend | **`+` and `−`** | "Additive only" constrains where the number lands, not whether a mis-click is recoverable. A `−` that floors at the rolled base produces a dict `construct` already accepts. The mock did not think about mis-clicks; this is a bug in it, not a position it took. |
|
||||
| 5 | `‹ ›` arrows on the portrait | **Cut** | They cycle model variants. We have neither art nor a portrait field on `CharacterSheet`. Two buttons that do nothing are worse than none. The dashed art slot stays (ADR 0001 — art slots are placeholders). |
|
||||
|
||||
The **"THE DM SIZES YOU UP" panel is authored, not an AI call** (§6). The **re-roll is
|
||||
unlimited**, as mocked (§8).
|
||||
|
||||
---
|
||||
|
||||
## 5. Layout — variant C
|
||||
|
||||
1920×1080. The mock's split holds: a 660px dark portrait bay on the left, a 1260px
|
||||
parchment sheet on the right.
|
||||
|
||||
**Left bay:** kicker (`WHO WILL YOU BE?`), the dashed character-model art slot, the
|
||||
nameplate (`{name or "Nameless"}` · `{Race} · {Calling}`), and the DM origin card.
|
||||
|
||||
**Right sheet, top to bottom:**
|
||||
|
||||
- Header — *Create Your Character* / `THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU`
|
||||
- **RACE** — four cards (name, blurb, derived trait line). Selected card gets the crimson
|
||||
border + `CHOSEN` tag.
|
||||
- **CALLING** — **seven nameplate cards in one row: name + primary-stat chip, no blurb.**
|
||||
Beneath the row, a detail panel for the *chosen* calling carrying **its blurb and its
|
||||
mechanics together**:
|
||||
`d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab`
|
||||
- **PROFICIENCIES** — chips. The chosen calling's pool, `N of N chosen`, plus (Human only) a
|
||||
second row over all nine skills for the bonus pick.
|
||||
- **ABILITY SCORES** — five cards (STR DEX CON FTH MAG — **never LCK**), each with the final
|
||||
value, `rolled X +Y`, and `−`/`+`. `points left: N`, the `⟳ re-roll` button, and the dry
|
||||
line *rolled 3d6 — that roll is your floor, spend up only*. The calling's primary stat
|
||||
gets the `PRIMARY` ring.
|
||||
- **Footer** — the name field and ENTER THE WORLD.
|
||||
|
||||
**Why C.** The CALLING block becomes the visual mirror of the PROFICIENCIES block below it:
|
||||
*pick, and the consequence appears.* One blurb, for the thing you chose, in a panel that
|
||||
exists because you chose it. It also buys ~70px and kills the cramped-italic problem that
|
||||
seven 157px cards create. Worst case for vertical space is **Human + Cutpurse** (the bonus
|
||||
row appears *and* the pool is the widest in the game, 4 of 6) — it fits inside 1080 with
|
||||
room.
|
||||
|
||||
**LCK appears nowhere on this screen.** Not as a number, not as a descriptor, not as a
|
||||
hidden tooltip. §7.
|
||||
|
||||
---
|
||||
|
||||
## 6. The DM origin panel is authored
|
||||
|
||||
The panel composes, exactly as the mock does:
|
||||
|
||||
> `{race.fragment}` Now you carry a `{calling.name}`'s work — `{calling.fragment}`
|
||||
|
||||
Each race and calling JSON gains a **`fragment`**:
|
||||
|
||||
```json
|
||||
{ "id": "cutpurse", "name": "Cutpurse",
|
||||
"blurb": "Purses, locks, confidences — you have taken all three. …",
|
||||
"fragment": "quick fingers, quicker exits, and a knife for the rest." }
|
||||
```
|
||||
|
||||
Eleven fragments — four races, seven callings. **Real prose, not stubs**, authored through
|
||||
the **world-building skill** so they carry the world's voice. A fragment a shade too warm
|
||||
poisons the first screen the player ever sees.
|
||||
|
||||
**Why not a live Narrator call.** The player clicks race and calling cards a dozen times
|
||||
while fiddling; that is a dozen calls with a spinner between them, against §14's "cache
|
||||
aggressively, pre-generate." Worse, it asks the AI to narrate a character *nothing has
|
||||
happened to yet* — there is no scene, no event, nothing to narrate. §13 is explicit that
|
||||
authored text is **content**, not error handling; the DM's voice does not require a model
|
||||
behind it to be the DM's voice. The DM's real entrance is the shell's opening narration,
|
||||
one screen later, at the first moment where speaking means something.
|
||||
|
||||
**Degradation:** a missing `fragment` falls back to the `blurb`. The parity test (§9)
|
||||
prevents that from ever shipping, but the screen does not crash on it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mechanics are derived, never written twice
|
||||
|
||||
`CreationCopy` is a pure static formatter that reads `Races` / `Callings` / `Skills` and
|
||||
returns display strings:
|
||||
|
||||
- **Race trait line** — `+1 to every save · +1 skill of choice` (human),
|
||||
`Poison resist · nightsight` (dwarf), …
|
||||
- **Calling detail line** — `d8 · light armour · cooldowns · saves DEX + MAG · picks 4 skills · talent backstab`
|
||||
|
||||
**Prose lives in content; numbers live in code; the card reads both.** A hit die written
|
||||
into a JSON is a hit die that will eventually disagree with the table. If someone retunes
|
||||
the Cutpurse's d8, the card follows — because the card never knew the number.
|
||||
|
||||
**Theme additions.** A skill chip has three states — unpicked, picked, and *granted by your
|
||||
race so it cannot be picked* — which is a `Button` in `toggle_mode` needing
|
||||
`normal`/`pressed`/`disabled` styleboxes. The existing `Chip` is a display badge with no
|
||||
`disabled` and no selected look, and the race/calling cards need a "chosen" crimson-ring
|
||||
state that nothing in the theme has. (`PrimaryCTA` **already** carries `disabled` +
|
||||
`font_disabled_color` — the CTA needs nothing new.) The new variations are added to
|
||||
**`build_game_theme.gd`** + `theme_keys.gd` and the `.tres` **regenerated** — never
|
||||
hand-edited, no hex in a scene or script (the M3-a rule).
|
||||
|
||||
---
|
||||
|
||||
## 8. The draft, the seed, and the data flow
|
||||
|
||||
### `CreationDraft`
|
||||
|
||||
Stores exactly what a creation Dictionary is made of, and nothing derived:
|
||||
|
||||
```gdscript
|
||||
var name: String
|
||||
var race_id: String
|
||||
var calling_id: String
|
||||
var seed: int
|
||||
var spend: Dictionary # {stat: int} — additive only
|
||||
var skills: Array # the player's picks
|
||||
var bonus_skill: String # human only
|
||||
```
|
||||
|
||||
**The interaction rules** — where a creation screen actually gets buggy, so they are stated
|
||||
rather than discovered:
|
||||
|
||||
- **Changing calling clears the skill picks.** The pool changed underneath them; keeping
|
||||
stale picks leaves the player holding an invalid draft he did not cause and cannot see.
|
||||
- **Changing race drops a now-granted pick.** An Elf is granted `perception`; a Cutpurse who
|
||||
had picked it now holds a pick that "buys nothing" and `construct` rejects by name.
|
||||
`set_race` drops it, and clears `bonus_skill` if the new race is not Human.
|
||||
- **Re-roll clears the spend, and only the spend.** Race, calling, skills and name survive.
|
||||
Points spent against a roll that no longer exists are meaningless.
|
||||
- **`−` floors at the rolled base.** It removes only what you added.
|
||||
- **`errors()` delegates to `NewGame.validate`.** The screen owns no second copy of the
|
||||
rules. It asks, and shows the answer.
|
||||
|
||||
### The seed — and why the screen can show numbers without owning them
|
||||
|
||||
```
|
||||
randomize() ──> seed ──┬──> draft.rolled() ────────> the five numbers ON SCREEN
|
||||
│ (NewGame.roll_attributes)
|
||||
│
|
||||
└──> creation Dictionary ──> NewGame.construct
|
||||
│
|
||||
└──> rolls them AGAIN, itself
|
||||
──> CharacterSheet
|
||||
```
|
||||
|
||||
The screen and `construct` reach the same five numbers by calling **the same pure function
|
||||
on the same seed** — but the screen never *hands* a number to anything. It hands over a seed
|
||||
and four choices. **§2 holds without the screen having to be trustworthy**, which is the only
|
||||
kind of holding that counts.
|
||||
|
||||
`randomize()` is the one place non-determinism enters the entire pipeline, and what it
|
||||
produces is the seed — which is thereafter the character's determinism anchor.
|
||||
|
||||
**This forces two small M4-a changes:**
|
||||
|
||||
- **`roll_attributes(seed: int) -> Dictionary`** — public, pure. `construct` **calls it**
|
||||
rather than keeping its own loop, so there is exactly one implementation of the roll.
|
||||
- **`validate(origin, world, creation) -> Array`** — today's `_validate`, made public, so
|
||||
the screen can gate the button on the real rules.
|
||||
|
||||
**Neither takes a default parameter.** See §9, trap 2.
|
||||
|
||||
### Re-roll is unlimited
|
||||
|
||||
As mocked. The floor of 8 already prevents the dead-primary "fine" §7 forbids, so limiting
|
||||
re-rolls buys no protection — it only adds friction and calls it grit.
|
||||
|
||||
And it earns something. The seed drives the five visible attributes **and the hidden LCK
|
||||
roll**, which comes off the same RNG immediately after MAG. A player who re-rolls twenty
|
||||
times chasing a 16 STR is, on every press, blindly re-rolling his Luck. He cannot see it, he
|
||||
cannot count it, and he will never know what he traded away. That is the superstition §7 is
|
||||
built to produce, and the seeding decision M4-a already made hands it to us for free.
|
||||
|
||||
**The screen says nothing about this, ever.** The `⟳ re-roll` button and the dry subtitle are
|
||||
all the player is told.
|
||||
|
||||
### Exit
|
||||
|
||||
The screen emits **`creation_confirmed(creation: Dictionary)`**. It does **not** call
|
||||
`construct` — the flow is M4-c's job, and a saga later synthesizes the same Dictionary and
|
||||
skips the scene entirely.
|
||||
|
||||
`origin` and `world` are **injected** the way the shell injects `DmService`: settable between
|
||||
`instantiate()` and `add_child()`, with `_ready` loading the deserter origin and a real
|
||||
`ContentDB` when nothing was supplied. Tests stay hermetic, and the origin's
|
||||
`allowed_callings` gates which calling cards appear rather than the screen hardcoding seven.
|
||||
|
||||
**ENTER THE WORLD is disabled while `errors()` is non-empty**, with the first error printed
|
||||
dry beneath it — *choose two more proficiencies*, *name your character*. The player cannot
|
||||
press his way into a rejection.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
All headless (GUT, `client/run_tests.sh`) except the visual gate. `traps.md` records two bugs
|
||||
M4-a shipped **past a green suite**; both have a direct analogue here, so the guard is named,
|
||||
not hoped for.
|
||||
|
||||
### Trap 1 — a regression test that cannot fail is not a regression test
|
||||
|
||||
The load-bearing claim of this entire screen is **the numbers you saw are the numbers you
|
||||
got.** The lazy test builds a `CreationDraft`, builds a `CharacterSheet`, and asserts both
|
||||
roll the same — which passes even if the screen and `construct` hold two separate roll
|
||||
implementations that merely agree today.
|
||||
|
||||
**The real test asserts across the pipeline:**
|
||||
|
||||
```gdscript
|
||||
var draft := CreationDraft.new() # seed S, calling, spend
|
||||
var result := NewGame.construct(origin, world, draft.to_creation())
|
||||
assert_eq(result.state.sheet.attributes, draft.final())
|
||||
```
|
||||
|
||||
Re-implement the roll in either place and it goes red. Per `traps.md`: before accepting the
|
||||
work, **re-break it and watch the test fail.**
|
||||
|
||||
### Trap 2 — GDScript defaults turn an arity change into a silent miscompile
|
||||
|
||||
`validate` and `roll_attributes` become public. **Neither gets a default parameter on any
|
||||
argument.** A wrong-arity call to a defaulted function compiles clean and binds the wrong
|
||||
values — exactly how M4-a shipped a player block of four empty strings into the live shell,
|
||||
past a grep that came back clean. Required args only.
|
||||
|
||||
### The rest
|
||||
|
||||
- **`CreationDraft`** (no scene tree): re-roll clears the spend and only the spend; changing
|
||||
calling clears the picks; changing race drops a now-granted pick and clears a non-Human's
|
||||
bonus skill; `−` floors at the rolled base; the fourth point cannot be spent; a granted
|
||||
skill cannot be picked; `to_creation()` round-trips into `construct` cleanly.
|
||||
- **`CreationCopy`**: for **all seven** callings the detail line *contains*
|
||||
`"d%d" % Callings.hit_die(id)` and that calling's actual saves — the guard that fails if
|
||||
anyone types `d8` into a string literal instead of reading the table. Same for the four
|
||||
race trait lines.
|
||||
- **`NewGame`**: same seed → same five attributes; the floor of 8 holds at the bottom of the
|
||||
die; `validate` returns the same errors `construct` would reject with.
|
||||
- **The scene**: mounts with an injected origin + `ContentDB`; nodes are non-zero; the CTA is
|
||||
disabled on a fresh draft and enabled on a complete one; `creation_confirmed` emits exactly
|
||||
`draft.to_creation()`.
|
||||
- **Content parity** (extends the existing test): every id in `Races.IDS` / `Callings.IDS` has
|
||||
a content file with a **non-empty `fragment`**.
|
||||
- **§7 guard**: no LCK value, descriptor, or accessor is reachable from the creation scene.
|
||||
|
||||
### The visual gate
|
||||
|
||||
Headless GUT proves the nodes mount and the bindings run. **It cannot prove the screen looks
|
||||
like the mock.** ADR 0001 is explicit that this gate is a **human F6 eyeball**, and this is
|
||||
the screen where that matters most.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- **Title → creation → shell wiring** — M4-c, next.
|
||||
- **Portrait art and the `‹ ›` arrows** — no art, no portrait field.
|
||||
- **Origin *selection*** — the deserter is the only origin. The screen is *handed* one; it
|
||||
does not choose.
|
||||
- **Persisting the creation seed past creation** — M9 saves the sheet, and the sheet *is* the
|
||||
character.
|
||||
- **Any AI call.** The DM's entrance is the shell, one screen later.
|
||||
- **The MP curve and the level curve** — M5, deferred by the races/classes spec.
|
||||
@@ -0,0 +1,281 @@
|
||||
# M4-c — Title → creation → shell flow
|
||||
|
||||
**Milestone:** M4-c (roadmap §M4). **Date:** 2026-07-15.
|
||||
**§2 side:** n/a (flow orchestration — code owns it, no AI).
|
||||
**Depends on:** the Title screen (M3), the creation screen (M4-b), the creation
|
||||
model `NewGame.construct` (M4-a), the shell (M3-a).
|
||||
**Goal it advances:** a playable start-to-shell loop — the player builds a
|
||||
character and lands in the exploration shell as *that* character.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The flow from the title screen into the game is fictional in two places:
|
||||
|
||||
1. **Title skips creation.** `title_screen.gd`'s `new_game` item does
|
||||
`get_tree().change_scene_to_file(SHELL)` — it jumps straight to the shell and
|
||||
never touches the creation screen.
|
||||
2. **The shell fakes a character.** `main_window_shell.gd._build_seed_log()`
|
||||
hardcodes "Vexcca, a human sellsword" and `ShellState.seed()` hardcodes her
|
||||
vitals. The shell has never consumed a real, created character.
|
||||
|
||||
Meanwhile the creation screen already emits `creation_confirmed(creation)` and
|
||||
`NewGame.construct(origin, world, creation)` already returns
|
||||
`{ok, log: CanonLog, state: GameState}` — the two objects the shell needs. Nothing
|
||||
connects the emit to the construct to the shell.
|
||||
|
||||
M4-c wires those three edges: **Title → Creation → Shell**, with the created
|
||||
character flowing all the way through.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **⛨ saga (roadmap §M3/§M4):** do NOT hardcode a linear
|
||||
`new game → creation → world` flow as one welded unit. The saga's fourth entry
|
||||
path — "begin the next campaign of an existing saga" — must be addable later
|
||||
without rewriting existing edges.
|
||||
- **§2:** the screen never hands game state upward. `construct` re-rolls the
|
||||
character from the creation seed itself; the flow only carries plain data.
|
||||
- **§13:** a failure never surfaces to the player as an error.
|
||||
- **No new global state.** The codebase uses an injection-seam pattern
|
||||
(`instantiate()` → set property → `add_child()`), not autoloads. There are zero
|
||||
autoloads today; this milestone adds none.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach (chosen)
|
||||
|
||||
**A flow coordinator node.** A new script-first root `Node`, `GameFlow`, becomes
|
||||
the `main_scene`. It owns one `ContentDB` and the current screen (its only child),
|
||||
and it holds the entire Title → Creation → Shell sequence in one file.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Autoload handoff singleton** — keep `change_scene_to_file` everywhere, carry
|
||||
the character in a global. Smallest screen diff, but smears the flow across three
|
||||
handlers + global mutable state, fighting the no-hardcoded-flow constraint and
|
||||
cutting against the injection-seam pattern.
|
||||
- **Screens instantiate their own successor** — hardcodes the flow *into* the
|
||||
screens and couples them; worst for the saga constraint.
|
||||
|
||||
The coordinator is the only option where the sequence is a single editable object
|
||||
(saga-friendly) and it reuses the existing injection seam (no global state).
|
||||
|
||||
---
|
||||
|
||||
## 3. Components
|
||||
|
||||
### 3.1 `GameFlow` (new)
|
||||
|
||||
- **Files:** `client/scripts/ui/flow/game_flow.gd`,
|
||||
`client/scenes/flow/GameFlow.tscn` (a trivial one-node scene).
|
||||
- **Type:** script-first `Node`. It has no Control layout, so ADR 0001's
|
||||
editor-first rule does not apply — that rule governs *Control layout* scenes;
|
||||
this is a pure coordinator. (Noted explicitly so a reviewer does not read the
|
||||
code-built structure as an ADR 0001 violation.)
|
||||
- **Owns:**
|
||||
- `world: ContentDB` — loaded once in `_ready` (`ContentDB.default_content_root()`).
|
||||
- `service: DmService` — built once and injected into the shell, so the shell's
|
||||
own `service == null` fallback is not needed on the real path (it stays for
|
||||
F6/standalone).
|
||||
- `origin: Dictionary` — the deserter origin (the single origin that exists).
|
||||
- the current child screen.
|
||||
- **`main_scene`:** `project.godot` `run/main_scene` changes from
|
||||
`res://scenes/title/TitleScreen.tscn` to `res://scenes/flow/GameFlow.tscn`.
|
||||
|
||||
**The one swap primitive:**
|
||||
|
||||
```gdscript
|
||||
func _show(next: PackedScene, inject: Callable) -> void:
|
||||
# free current child, instantiate next, run the injection seam, then add.
|
||||
for c in get_children():
|
||||
c.queue_free()
|
||||
var screen := next.instantiate()
|
||||
inject.call(screen) # set injected properties BEFORE add_child (_ready)
|
||||
add_child(screen)
|
||||
```
|
||||
|
||||
`inject` is the seam: it runs after `instantiate()` (which does NOT run `_ready`)
|
||||
and before `add_child()` (which does) — the same window the tests use. This is
|
||||
precisely what `change_scene_to_file` cannot provide.
|
||||
|
||||
**The sequence (the whole table lives here):**
|
||||
|
||||
| Step | Trigger | GameFlow method |
|
||||
|---|---|---|
|
||||
| boot | `_ready()` | `_show_title()` |
|
||||
| title | `menu_activated(&"new_game")` | `_show_creation()` |
|
||||
| title | `menu_activated(&"quit")` | `get_tree().quit()` |
|
||||
| creation | `creation_confirmed(creation)` | `_start_campaign(creation)` |
|
||||
|
||||
- `_show_title()` → `_show(TITLE, func(s): s.menu_activated.connect(_on_menu))`.
|
||||
- `_show_creation()` → `_show(CREATION, func(s): s.world = world; s.origin = origin;
|
||||
s.creation_confirmed.connect(_start_campaign))`.
|
||||
- `_start_campaign(creation: Dictionary)` → call `NewGame.construct`; on success
|
||||
`_show_shell(result.log, result.state)`; on failure see §5.
|
||||
- `_show_shell(log, state)` → `_show(SHELL, func(s): s.service = service;
|
||||
s.injected_log = log; s.injected_state = state)`.
|
||||
|
||||
**Saga seam:** `_show_creation()` and `_start_campaign(creation)` are deliberately
|
||||
separate. `_start_campaign` takes a plain Dictionary and does not assume the
|
||||
creation came from the screen. The saga's fourth path becomes a new
|
||||
`menu_activated` arm that synthesizes a `creation` Dict and calls
|
||||
`_start_campaign` directly, skipping the creation screen — no existing edge
|
||||
changes.
|
||||
|
||||
### 3.2 `title_screen.gd` (modified)
|
||||
|
||||
Becomes **emit-only**. It already emits `menu_activated(id)`. Remove:
|
||||
|
||||
- `const SHELL`,
|
||||
- the `new_game` and `quit` bodies of `_on_menu_activated` (and the now-dead
|
||||
`_on_menu_activated` connection if it holds nothing else).
|
||||
|
||||
The screen no longer knows what comes after it. Its atmosphere/selection/input
|
||||
behavior is untouched.
|
||||
|
||||
### 3.3 `character_creation.gd` (unchanged)
|
||||
|
||||
No change. It already accepts injected `world`/`origin` and already emits
|
||||
`creation_confirmed(draft.to_creation())`. M4-b left the signal dangling for
|
||||
exactly this milestone to connect.
|
||||
|
||||
### 3.4 `main_window_shell.gd` + `ShellState` (modified)
|
||||
|
||||
Two new injection seams on the shell, mirroring `service`:
|
||||
|
||||
```gdscript
|
||||
var injected_log: CanonLog = null
|
||||
var injected_state: GameState = null
|
||||
```
|
||||
|
||||
In `_ready()`, the seed calls become fallbacks (same shape as `service == null`):
|
||||
|
||||
```gdscript
|
||||
_state = ShellState.for_character(injected_state, injected_log) if injected_state \
|
||||
else ShellState.seed()
|
||||
_log = injected_log if injected_log else _build_seed_log()
|
||||
```
|
||||
|
||||
New factory **`ShellState.for_character(state: GameState, log: CanonLog)`**:
|
||||
|
||||
- `vitals.hp / hp_max` ← `state.sheet.hp / state.sheet.max_hp()`
|
||||
- `vitals.mp / mp_max` ← `state.sheet.mp / state.sheet.max_mp()`
|
||||
- `vitals.purse_copper` ← `state.purse_copper`
|
||||
- `location_label` ← `log.location.name`
|
||||
- `turn_order`, `consumables`, `round_label` ← **the same placeholders `seed()`
|
||||
uses** (combat = M5, inventory = M7 own these; not M4-c's to invent).
|
||||
|
||||
`_build_seed_log()` and `ShellState.seed()` **stay** — they are the F6/standalone
|
||||
fallback, unchanged. On the real path the injected objects win and the created
|
||||
character's vitals, location, and opening narration (via the injected log) are all
|
||||
real.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
project.godot main_scene = GameFlow.tscn
|
||||
│
|
||||
GameFlow._ready → loads ContentDB (world), origin (deserter), builds DmService
|
||||
│ _show_title
|
||||
TitleScreen ──menu_activated(&"new_game")──▶ GameFlow._show_creation
|
||||
│ inject world + origin
|
||||
CharacterCreation ──creation_confirmed(creation)──▶ GameFlow._start_campaign
|
||||
│ NewGame.construct(origin, world, creation) → {ok, log, state}
|
||||
│ inject log + state + service
|
||||
MainWindowShell (vitals/location/narration all from the real character)
|
||||
```
|
||||
|
||||
`creation` is a plain Dictionary carrying `{name, race_id, calling_id, seed,
|
||||
spend, skills, bonus_skill}`. `construct` re-rolls the attributes and Luck from
|
||||
`seed` itself (§2/§10) — the flow never carries a computed stat.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling (§13)
|
||||
|
||||
`NewGame.construct` returns `{ok: false, errors}` on invalid input. Through the UI
|
||||
this is **unreachable** — `character_creation._bind_cta` disables the CTA on any
|
||||
`NewGame.validate` error, so `creation_confirmed` only fires on a legal draft. But
|
||||
§2 requires `construct` to distrust its caller, and so does `GameFlow`:
|
||||
|
||||
- On `result.ok == false`, `_start_campaign` **logs the errors and returns** —
|
||||
it does not swap to the shell. The player remains on the valid creation screen.
|
||||
- No crash, no half-built shell reaches the player.
|
||||
|
||||
This path exists to be handled, not hit; a test drives it deliberately (§6).
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
All new guards get the revert-and-watch-it-go-red treatment per
|
||||
[`docs/traps.md`](../../traps.md). Drive screens by emitting their **real
|
||||
signals**, never by calling handlers (traps.md: tests that called handlers
|
||||
instead of pressing nodes).
|
||||
|
||||
1. **GameFlow flow — new game reaches the shell as the created character.**
|
||||
Instantiate GameFlow with an injected fake `world`/`origin`/`service`; let it
|
||||
show the title; `emit_signal("menu_activated", &"new_game")` → assert the child
|
||||
is a `CharacterCreation`. Build a valid `creation` Dict for a known character
|
||||
(e.g. name "Dagnet"), `emit_signal("creation_confirmed", creation)` → assert
|
||||
the child is a `MainWindowShell` **and its canon log player name == "Dagnet"**.
|
||||
The name assertion proves the character flowed through, not merely that a scene
|
||||
swapped. *Re-break:* feed a Dict whose name differs from the asserted one and
|
||||
watch it go red.
|
||||
|
||||
2. **Shell injected path — vitals derive from the real sheet.** Inject a
|
||||
`log`+`state` for a character with a known HP (distinct from the seed's 42/60);
|
||||
`add_child`; assert the `HP` label reads the injected HP. *Re-break:* force the
|
||||
`seed()` branch and watch it fail.
|
||||
|
||||
3. **`ShellState.for_character` unit.** Vitals + location derive from the passed
|
||||
`state`/`log`; `turn_order`/`consumables`/`round_label` equal `seed()`'s
|
||||
placeholders. *Re-break:* change one derived field's source and watch the
|
||||
assertion fail.
|
||||
|
||||
4. **Construct-failure stays on creation (§5).** Drive `_start_campaign` with a
|
||||
`creation` Dict that fails `validate`; assert no `MainWindowShell` is added and
|
||||
the flow is unchanged. *Re-break:* make `_start_campaign` transition
|
||||
unconditionally and watch this go red.
|
||||
|
||||
5. **Title is emit-only.** Assert `title_screen.gd` no longer references a shell
|
||||
scene path and emits `menu_activated` (the existing title test may already
|
||||
cover the emit; extend it to assert the routing bodies are gone).
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope boundary
|
||||
|
||||
**In:** the three flow edges; the character flowing through them; the shell's
|
||||
vitals + location + narration deriving from the real character; the emit-only
|
||||
title; error handling for the unreachable construct failure.
|
||||
|
||||
**Out (owned elsewhere):**
|
||||
- Origin selection UI — deserter is the single origin; the seam accepts one origin
|
||||
now and a choice later.
|
||||
- Save/load, continue, load — M9.
|
||||
- Quit-confirmation dialog.
|
||||
- Any HUD field beyond vitals + location (turn order = M5, consumables = M7).
|
||||
- The saga's fourth entry path itself — this milestone only keeps it *addable*.
|
||||
|
||||
---
|
||||
|
||||
## 8. Files
|
||||
|
||||
**New:**
|
||||
- `client/scripts/ui/flow/game_flow.gd`
|
||||
- `client/scenes/flow/GameFlow.tscn`
|
||||
- `client/tests/unit/test_game_flow.gd`
|
||||
|
||||
**Modified:**
|
||||
- `client/project.godot` — `run/main_scene` → `GameFlow.tscn`
|
||||
- `client/scripts/ui/title/title_screen.gd` — emit-only
|
||||
- `client/scripts/ui/shell/main_window_shell.gd` — `injected_log`/`injected_state`
|
||||
seams + fallback wiring
|
||||
- `client/scripts/ui/shell/shell_state.gd` — `for_character(state, log)` factory
|
||||
- `client/tests/unit/test_main_window_shell.gd` — injected-path test
|
||||
- `client/tests/unit/test_shell_state.gd` — `for_character` test
|
||||
- `client/tests/unit/test_title_screen.gd` — emit-only assertions
|
||||
266
docs/traps.md
266
docs/traps.md
@@ -119,6 +119,251 @@ nothing changed that you did not intend.
|
||||
|
||||
---
|
||||
|
||||
## 7. An assertion can pass because one string happens to sit inside another
|
||||
|
||||
**M4-b.** `assert_string_contains(line, Callings.armor(id))` checked that a calling's
|
||||
detail line mentioned its armour — using the **raw** table value (`"light"`, `"none"`),
|
||||
not the word the card actually prints (`"light armour"`, `"no armour"`). For
|
||||
light/medium/heavy this passed **by accident**: `"light"` is a substring of `"light
|
||||
armour"`. It broke only on the Hedge-Mage, the one calling with `armor: "none"`, because
|
||||
`"none"` is not a substring of `"no armour"`. Delete the Hedge-Mage and the bug goes
|
||||
dormant and silent — nothing else in the roster would ever expose it.
|
||||
|
||||
The same species, same milestone: `assert_string_contains(line, str(Callings.skill_count(id)))`
|
||||
could not fail for the Reaver — its skill count is 2, and `"2"` is already sitting inside
|
||||
`"d12"` (its hit die). Hardcoding the wrong skill count in the card would still pass, satisfied
|
||||
by a digit in an unrelated number.
|
||||
|
||||
**The guard:** anchor the assertion to the **full derived phrase** the code actually
|
||||
produces (`CreationCopy.armor_word(id)`, `"picks %d skills" % Callings.skill_count(id)`),
|
||||
never to a bare fragment that might coincidentally be present for the wrong reason. If an
|
||||
assertion would still pass with the code deleted and a different, unrelated number
|
||||
substituted, it is checking overlap, not correctness.
|
||||
|
||||
---
|
||||
|
||||
## 8. A wrapper can hide the void it was supposed to catch
|
||||
|
||||
**M4-b.** A §7 sweep asserted every race×calling combination's DM-panel prose was
|
||||
non-empty — the guard meant to catch a blank `ContentDB` leaking onto the one screen that
|
||||
must never show a raw error. But the binder wraps prose in BBCode (`"[i]%s[/i]"`) and the
|
||||
origin panel always emits its own literal connective (`" Now you carry a %s's work — "`)
|
||||
even when every authored fragment and blurb is blank. `"[i][/i]".strip_edges().is_empty()`
|
||||
reads `false` regardless of what content actually rendered. The 28-combination sweep was
|
||||
proving the *wrapper* is non-empty, not that the guard against a blank `ContentDB` held.
|
||||
|
||||
**The guard:** when a value is always wrapped in a fixed template before display, assert
|
||||
that the render **contains the payload's own string** (and that the payload's own string
|
||||
is itself non-blank) — never just that the wrapped result is non-empty. A non-empty
|
||||
assertion downstream of a non-empty literal proves nothing.
|
||||
|
||||
---
|
||||
|
||||
## 9. A test that asserts the script's own loop bound, not the artifact
|
||||
|
||||
**M4-b.** `test_five_ability_cards_never_six` asserted `_ability_cards.size() == 5` — but
|
||||
that array is filled by the binder's own `for i in range(5)`, so it is exactly 5 **by
|
||||
construction**, independent of what the scene actually contains. An `Ab5` node could be
|
||||
added to the `.tscn` and this test stayed green while a sixth ability card — the exact
|
||||
regression §7 exists to prevent — rendered on screen.
|
||||
|
||||
**The guard:** interrogate the artifact, not the code that reads it.
|
||||
`get_node_or_null("Ab5")` must be null, or `get_child_count()` on the authored container
|
||||
must equal the expected count. A test whose only source of truth is a literal in the same
|
||||
file it is meant to be guarding is not a test of that file.
|
||||
|
||||
---
|
||||
|
||||
## 10. A guard whose only fixture makes it trivially true
|
||||
|
||||
**M4-b.** `test_only_the_origin_s_allowed_callings_are_shown` checked that shown calling
|
||||
cards equal the origin's `allowed_callings`. The only origin in the game (the deserter)
|
||||
allows all seven callings, and cards default to visible — so the assertion was `7 == 7`
|
||||
no matter what the hiding logic did. **Deleting the entire hide branch left the test
|
||||
green.**
|
||||
|
||||
**The guard:** a gating rule needs a fixture that actually gates. Inject a restrictive
|
||||
origin (the injection seam for `origin`/`ContentDB` already existed for exactly this) that
|
||||
allows a strict subset, and assert the excluded cards are absent. A test with only the
|
||||
permissive case in play is not testing the restrictive path at all.
|
||||
|
||||
---
|
||||
|
||||
## 11. An assertion that was already true before the action under test ran
|
||||
|
||||
**M4-b.** A re-roll test asserted `"points left: 3"` **after** calling re-roll — but a
|
||||
freshly-constructed draft already reads 3 points left. Removing `spend = {}` from
|
||||
`reroll()` entirely left the test green, because the assertion never depended on the
|
||||
reset actually happening.
|
||||
|
||||
The same species bit a displayed-scores test harder: it asserted the ability cards show
|
||||
`draft.final()` values, but spent zero points before checking — so `final() == rolled()`
|
||||
and a binder bug that read `rolled` instead of `final` (arguably the single most obvious
|
||||
possible bug in a point-buy panel) passed clean.
|
||||
|
||||
**The guard:** drive the state away from its default before asserting the reset or the
|
||||
derivation. Spend a point, *then* re-roll and check the pool refilled. Spend a point,
|
||||
*then* check the card shows the spent value, not the rolled one. If the assertion would
|
||||
already hold on a brand-new object with no action taken, the test has not exercised
|
||||
anything.
|
||||
|
||||
---
|
||||
|
||||
## 12. A "covers everything" claim that covers three things
|
||||
|
||||
**M4-b.** `test_committed_tres_matches_builder` is the drift guard between the theme
|
||||
builder and the committed `.tres` artifact — the thing that stops "changed the palette,
|
||||
forgot to regenerate" from shipping silently. The plan described it as iterating
|
||||
`ThemeKeys.ALL`. It did not: it checked a hardcoded list of exactly three
|
||||
variation/state pairs, so every variation added since — including all five this
|
||||
milestone added for the creation screen — could drift from its generator with the test
|
||||
still green.
|
||||
|
||||
**It took three fixes, and that is the actual lesson.**
|
||||
|
||||
- **Fix 1** replaced the hardcoded three with `ThemeKeys.ALL`. Still could not fail: `ALL`,
|
||||
*by its own docstring*, holds stylebox variations only — it deliberately excludes the six
|
||||
**font roles**. Change a palette colour used only by a font role, skip the regen, ship a
|
||||
stale theme, green.
|
||||
- **Fix 2** added `FONT_ROLES` and iterated both. Still could not fail: the builder also
|
||||
styles some **base types** directly (`RichTextLabel`'s font, italics font, size and
|
||||
colour), and those are in neither set. `RichTextLabel` is what renders every line of DM
|
||||
prose on the creation screen.
|
||||
- **Fix 2 also added a meta-guard** — a test that walks the builder's own output and fails
|
||||
if it styles anything that no set covers. That guard was written *specifically* to make a
|
||||
third miss impossible. It contained this:
|
||||
|
||||
```gdscript
|
||||
if fresh.get_type_variation_base(variation) == &"":
|
||||
continue # a BASE type, not a variation
|
||||
```
|
||||
|
||||
**The meta-guard's one exemption was exactly the gap it existed to catch.**
|
||||
|
||||
**The guard:** when a guard is described as covering "every X," check that it enumerates
|
||||
`X.ALL` (or equivalent) rather than a literal list someone wrote down once — and then check
|
||||
what `X.ALL` actually *contains*, because a set's name is not its contents. Make the failure
|
||||
message name the specific variation and property that drifted; a guard that only says "theme
|
||||
mismatch" does not tell the next person which of forty checks failed.
|
||||
|
||||
**And the harder lesson: a guard-of-a-guard with an exemption in it is not a guard.** If you
|
||||
write a meta-test to prove a set is complete, every `continue` and every `if … : return` in
|
||||
it is a hole you are cutting on purpose. Assert on the exempted case instead of skipping it,
|
||||
or you have built the very thing you were trying to prevent, one level up, where nobody will
|
||||
look for it.
|
||||
|
||||
---
|
||||
|
||||
## 13. A node-type sweep that misses the types that matter most
|
||||
|
||||
**M4-b.** The §7 Luck-invisibility guard swept `find_children("*", "Label", ...)` across
|
||||
the creation screen looking for the word "luck." `RichTextLabel` does **not** extend
|
||||
`Label` in Godot's class hierarchy — and the two `RichTextLabel` nodes on the screen were
|
||||
the only nodes rendering authored prose (the DM origin panel and the calling detail
|
||||
panel), i.e. the single likeliest place for a stray "luck" to leak onto the screen. The
|
||||
sweep also missed every `Button` (whose text is a property, not a child Label) and
|
||||
`LineEdit.placeholder_text`.
|
||||
|
||||
**The guard:** a sweep for "does this string appear anywhere on screen" must walk
|
||||
`Control` and check every text-bearing property that type can hold (`text`, `.text` on
|
||||
buttons, `placeholder_text`, `bbcode_text`), not one Label subclass. Naming the node type
|
||||
you filtered on is not the same as covering the node types that carry the content you care
|
||||
about.
|
||||
|
||||
---
|
||||
|
||||
## 14. A test that calls the handler instead of pressing the button
|
||||
|
||||
**M4-b.** Every interaction test for the creation screen invoked `_on_race_pressed(i)` /
|
||||
`_on_calling_pressed(i)` / etc. directly. **Commenting out `_wire()` entirely — the method
|
||||
that connects every button's `pressed` signal to its handler — left the whole suite
|
||||
green**, because no test ever went through the signal. A mis-bound `bind(i)` (wrong card
|
||||
wired to the wrong index) would have been equally invisible.
|
||||
|
||||
**The guard:** drive the interaction through the actual node —
|
||||
`button.pressed.emit()` (or, for a real click, `button.pressing`/`gui_input`) — not the
|
||||
handler function. This covers the wiring *and* the index binding for free, and it is the
|
||||
only way a test can tell you a button that looks correct in the editor is inert at
|
||||
runtime.
|
||||
|
||||
---
|
||||
|
||||
## 15. GUT can skip a test file with a warning, not a failure
|
||||
|
||||
**M4-b.** A new `class_name`-declaring script (`CreationDraft`, `CreationCopy`) needs
|
||||
Godot's `.godot/` import cache rebuilt before GUT can resolve the global class name from a
|
||||
sibling test file. Until that happens, the test file fails to parse and GUT **silently
|
||||
skips it with a `WARNING`**, not a failure — and the run still prints `All tests passed!`,
|
||||
at the **old** test count. A TDD RED phase against a brand-new global class can therefore
|
||||
be **fake**: you believe you watched the new test fail, but it never ran at all.
|
||||
|
||||
**The guard:** check the test **count**, every time, not the green banner text. If a
|
||||
change was supposed to add N tests and the total didn't move, the suite lied by omission.
|
||||
`rm -rf .godot && ./run_tests.sh` forces the cache rebuild if a new file seems to be
|
||||
missing.
|
||||
|
||||
---
|
||||
|
||||
## 16. A test can hang instead of fail
|
||||
|
||||
**M4-b.** The natural way to write "drain the point-buy pool" in a test is
|
||||
`while draft.points_left() > 0: plus.pressed.emit()`. Run that against a broken `_wire()`
|
||||
(trap 14) and `points_left()` never changes — the loop **never terminates**, and the test
|
||||
runner hangs instead of reporting a failure.
|
||||
|
||||
**The guard:** bounded `for` loops only, with an explicit iteration cap well above the
|
||||
expected count, and an assertion after the loop that the expected end-state was actually
|
||||
reached. A test that can hang is worse than a test that can silently pass — it costs a
|
||||
human a wall-clock timeout to even learn something is wrong.
|
||||
|
||||
---
|
||||
|
||||
## 17. Asserting against a value the engine derives from the thing under test
|
||||
|
||||
**M4-b, the F6 pass.** The race cards were authored 108px tall, and their text — the
|
||||
trait line — visibly rendered *below the card's bottom border* in the running game. The
|
||||
guard written for it read:
|
||||
|
||||
```gdscript
|
||||
var needed := box.get_combined_minimum_size().y # 114
|
||||
assert_lte(needed, box.size.y) # box.size.y is ALSO 114
|
||||
```
|
||||
|
||||
It passed. Not against the fix — **against the bug.** The card was put back to its
|
||||
overflowing 108px and the suite stayed green, 29/29.
|
||||
|
||||
The reason is a Godot rule that is easy to forget: **`Control.size` is clamped UP to
|
||||
`get_combined_minimum_size()`.** The `Box` is anchored inside the card, so a 108px card
|
||||
leaves it 82px of room — but the Box does not *become* 82px tall. It reports 114, its own
|
||||
minimum, and quietly overflows the card's border. So the assertion compared 114 against
|
||||
114: `needed <= box.size.y` is not a size check at all, it is `x <= x`. It could never
|
||||
have failed, for any card, at any height.
|
||||
|
||||
**The guard:** assert against the container that actually *clips* — the card — not
|
||||
against the child, whose size the engine has already reconciled with the very number you
|
||||
are testing:
|
||||
|
||||
```gdscript
|
||||
var available: float = card.size.y - box.offset_top + box.offset_bottom # 82
|
||||
assert_lte(needed, available) # 114 <= 82 -> RED
|
||||
```
|
||||
|
||||
The general shape: **if the engine derives B from A, then `assert(A <= B)` is a
|
||||
tautology, not a test.** Layout, min-size, and any auto-fitted value are all A-and-B.
|
||||
Reach for a number the engine computed from a *different* source — the parent, the
|
||||
authored constant, the viewport — or the assertion is checking that arithmetic works.
|
||||
|
||||
Related: this is trap 11's family (an assertion already true before the action ran), but
|
||||
it is worse — this one is true *by construction*, so no amount of driving state can
|
||||
redeem it.
|
||||
|
||||
**Corollary, same fix:** geometry assertions are meaningless before the container tree has
|
||||
sorted. On the frame `add_child()` runs, a race card reports **102px** wide and its
|
||||
autowrapped blurb reports a **2508px** minimum height. `await get_tree().process_frame`
|
||||
twice, or the guard fails for a reason that has nothing to do with the bug.
|
||||
|
||||
---
|
||||
|
||||
## The checklist this all reduces to
|
||||
|
||||
- Can the new test **fail**? Re-break the code and watch it. If it can't fail, it isn't a test.
|
||||
@@ -127,3 +372,24 @@ nothing changed that you did not intend.
|
||||
- Anything the **model can emit**? Ask what happens if it emits it *twice*.
|
||||
- Any fact written down **twice**? Guard every copy — including docs and tooling.
|
||||
- Touched **content**? Check the generated tree and the build.
|
||||
- Does an assertion string appear **only as a fragment** of another string? Anchor to the
|
||||
full derived phrase.
|
||||
- Is the value under test **always wrapped** in a fixed template first? Assert on the
|
||||
payload, not the wrapper.
|
||||
- Could the assertion be reading a bound the **test's own setup** guarantees, rather than
|
||||
the artifact under test?
|
||||
- Does the fixture make the guard's condition **trivially true** regardless of the logic
|
||||
it claims to check?
|
||||
- Would the assertion **already hold before the action under test runs**? Drive state away
|
||||
from its default first.
|
||||
- Does "covers everything" actually **iterate the full set**, or a hardcoded sample of it?
|
||||
- Does a node-type sweep account for **every type** that can carry the content, not just
|
||||
the common one?
|
||||
- Does the test **press the node** (emit the real signal), or call the handler directly?
|
||||
- Did the test **count** move by the expected amount — not just the green banner?
|
||||
- Can the test **hang** on a broken precondition? Bound every loop.
|
||||
- Is the assertion's right-hand side **derived by the engine from its left-hand side**
|
||||
(`Control.size` vs `get_combined_minimum_size()`)? Then it is `x <= x`. Measure against
|
||||
the parent that clips, not the child that reports.
|
||||
- Is it a **geometry** assertion? Let the container tree sort first, or it measures an
|
||||
unlaid-out node.
|
||||
|
||||
Reference in New Issue
Block a user