Compare commits
22 Commits
7a74a8de94
...
a106be673f
| Author | SHA1 | Date | |
|---|---|---|---|
| a106be673f | |||
| 1ae62fe0db | |||
| 8f3aa75fa9 | |||
| 63cbc8c16e | |||
| 7471d2e37f | |||
| 6291f21a00 | |||
| 34bf064904 | |||
| 31759ef848 | |||
| 4494856a44 | |||
| c5f127e0f1 | |||
| 6589d7c4ed | |||
| 2227d703f6 | |||
| fe8e24cf39 | |||
| 0bbf94289a | |||
| 1621a38b29 | |||
| 866158cc0d | |||
| 1c4d19492d | |||
| 537b0e1d18 | |||
| 057b83679b | |||
| c6b8a651aa | |||
| e4db022c4b | |||
| 17ef142689 |
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/)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@@ -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,28 @@ 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."
|
||||
config/version="0.01-alpha"
|
||||
run/main_scene="res://scenes/title/TitleScreen.tscn"
|
||||
config/features=PackedStringArray("4.7")
|
||||
|
||||
[display]
|
||||
|
||||
; Design/logical canvas stays 1920x1080 (charter §16, mockups authored here).
|
||||
; The DESIGN CANVAS stays 1920x1080 — the mockups (§16) are authored there, and every
|
||||
; .tscn's offsets are in those coordinates. It is a logical canvas, not a demand on the
|
||||
; monitor: canvas_items + keep scales it uniformly into whatever window it is given, so
|
||||
; the UI never reflows, it only gets smaller. Nothing below changes a layout number.
|
||||
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
|
||||
; The WINDOW is what the laptop has to fit, and it is a different question. 1600x900 was
|
||||
; larger than a 1600x900 laptop's *usable* area once the WM took its panel and titlebar,
|
||||
; so the window manager clamped it to 1589x752 and the run came up pillarboxed. 1280x720
|
||||
; is 16:9 and fits any laptop this ships to; the window is resizable (Godot's default),
|
||||
; and canvas_items rescales the canvas live on every resize.
|
||||
window/size/window_width_override=1280
|
||||
window/size/window_height_override=720
|
||||
window/size/resizable=true
|
||||
window/stretch/mode="canvas_items"
|
||||
; keep = letterbox rather than distort or reflow. This IS Godot 4's default, so the editor
|
||||
; prunes the line whenever it rewrites this file — that is cosmetic, not a behaviour change.
|
||||
window/stretch/aspect="keep"
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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." }
|
||||
|
||||
@@ -71,13 +71,13 @@ 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.*
|
||||
- ✅ **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)** — 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.*
|
||||
|
||||
### M5 — Tactical combat (gridless)
|
||||
|
||||
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