diff --git a/.gitignore b/.gitignore index 22b6020..57fd54c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/) # ───────────────────────────────────────────── diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres index c6c3265..c4ad4de 100644 --- a/client/assets/theme/game_theme.tres +++ b/client/assets/theme/game_theme.tres @@ -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") diff --git a/client/docs/README.md b/client/docs/README.md index feb472f..6e258ed 100644 --- a/client/docs/README.md +++ b/client/docs/README.md @@ -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 diff --git a/client/project.godot b/client/project.godot index 0ea126e..29567d0 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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" diff --git a/client/scenes/creation/CharacterCreation.tscn b/client/scenes/creation/CharacterCreation.tscn new file mode 100644 index 0000000..3dc65bb --- /dev/null +++ b/client/scenes/creation/CharacterCreation.tscn @@ -0,0 +1,1249 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/ui/creation/character_creation.gd" id="1"] +[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/ParchmentPanel.tscn" id="4"] + +[node name="CharacterCreation" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Split" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/separation = 0 + +[node name="Bay" type="Control" parent="Split"] +custom_minimum_size = Vector2(660, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Bay" instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Col" type="VBoxContainer" parent="Split/Bay"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 34.0 +offset_top = 30.0 +offset_right = -34.0 +offset_bottom = -34.0 +theme_override_constants/separation = 16 + +[node name="Kicker" type="Label" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "WHO WILL YOU BE?" + +[node name="ModelSlot" type="PanelContainer" parent="Split/Bay/Col"] +custom_minimum_size = Vector2(0, 540) +layout_mode = 2 +size_flags_vertical = 3 +theme_type_variation = &"ItemTileEmpty" + +[node name="Label" type="Label" parent="Split/Bay/Col/ModelSlot"] +layout_mode = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"Mono" +text = "CHARACTER +MODEL" + +[node name="Nameplate" type="VBoxContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="Name" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 34 +text = "Nameless" + +[node name="Sub" type="Label" parent="Split/Bay/Col/Nameplate"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"TitleKicker" +text = "Human · Sellsword" + +[node name="OriginCard" type="PanelContainer" parent="Split/Bay/Col"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Bay/Col/OriginCard"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Kicker" type="Label" parent="Split/Bay/Col/OriginCard/Box"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE DM SIZES YOU UP" + +[node name="Text" type="RichTextLabel" parent="Split/Bay/Col/OriginCard/Box"] +custom_minimum_size = Vector2(0, 96) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]The DM has not yet decided what you are.[/i]" + +[node name="Sheet" type="Control" parent="Split"] +custom_minimum_size = Vector2(1260, 1080) +layout_mode = 2 + +[node name="Backdrop" parent="Split/Sheet" instance=ExtResource("4")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Body" type="VBoxContainer" parent="Split/Sheet"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 44.0 +offset_top = 30.0 +offset_right = -44.0 +offset_bottom = -30.0 +theme_override_constants/separation = 16 + +[node name="Header" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 + +[node name="Title" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"Heading" +text = "Create Your Character" + +[node name="Kicker" type="Label" parent="Split/Sheet/Body/Header"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "THE WORLD HAS REAL PROBLEMS AND DOES NOT CARE ABOUT YOU" + +[node name="RaceSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "RACE" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/RaceSection"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Race0" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +theme_type_variation = &"CardTitle" +text = "Human" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"CardBody" +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race1" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race1"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +mouse_filter = 2 +theme_type_variation = &"CardTitle" +text = "Elf" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"CardBody" +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race1"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race2" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race2"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +mouse_filter = 2 +theme_type_variation = &"CardTitle" +text = "Dwarf" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"CardBody" +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race2"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Race3" type="Button" parent="Split/Sheet/Body/RaceSection/Cards"] +custom_minimum_size = Vector2(0, 180) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/RaceSection/Cards/Race3"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 15.0 +offset_top = 13.0 +offset_right = -15.0 +offset_bottom = -13.0 +mouse_filter = 2 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +mouse_filter = 2 +theme_type_variation = &"CardTitle" +text = "Beastfolk" + +[node name="Blurb" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +size_flags_vertical = 3 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"CardBody" +text = "…" + +[node name="Trait" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3/Box"] +layout_mode = 2 +mouse_filter = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/RaceSection/Cards/Race3"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -78.0 +offset_top = -11.0 +offset_right = -12.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="CallingSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "CALLING" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Calling0" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Sellsword" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling0"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling1" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling1"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Reaver" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling1"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling2" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling2"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Cutpurse" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling2"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling3" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling3"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Trapper" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling3"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling4" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling4"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Hedge-Mage" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "MAG" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling4"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling5" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling5"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Bonesetter" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling5"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Calling6" type="Button" parent="Split/Sheet/Body/CallingSection/Cards"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +size_flags_horizontal = 3 +toggle_mode = true +theme_type_variation = &"SelectCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Cards/Calling6"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Name" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"CardTitle" +theme_override_font_sizes/font_size = 17 +text = "Bloodsworn" + +[node name="Chip" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6/Box"] +layout_mode = 2 +mouse_filter = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Tag" type="Label" parent="Split/Sheet/Body/CallingSection/Cards/Calling6"] +visible = false +layout_mode = 1 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -74.0 +offset_top = -11.0 +offset_right = -10.0 +offset_bottom = 9.0 +grow_horizontal = 0 +mouse_filter = 2 +theme_type_variation = &"ChosenTag" +text = "CHOSEN" + +[node name="Detail" type="PanelContainer" parent="Split/Sheet/Body/CallingSection"] +layout_mode = 2 +theme_type_variation = &"ParchmentCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/CallingSection/Detail"] +layout_mode = 2 +theme_override_constants/separation = 7 + +[node name="Blurb" type="RichTextLabel" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +custom_minimum_size = Vector2(0, 48) +layout_mode = 2 +bbcode_enabled = true +fit_content = true +text = "[i]…[/i]" + +[node name="Mechanics" type="Label" parent="Split/Sheet/Body/CallingSection/Detail/Box"] +layout_mode = 2 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "…" + +[node name="SkillSection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "PROFICIENCIES" + +[node name="PoolRow" type="HBoxContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Count" type="Label" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "0 of 2 chosen" + +[node name="Pool0" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "stealth" + +[node name="Pool1" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sleight of hand" + +[node name="Pool2" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "acrobatics" + +[node name="Pool3" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "athletics" + +[node name="Pool4" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "intimidation" + +[node name="Pool5" type="Button" parent="Split/Sheet/Body/SkillSection/PoolRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "perception" + +[node name="BonusRow" type="HFlowContainer" parent="Split/Sheet/Body/SkillSection"] +layout_mode = 2 +theme_override_constants/h_separation = 10 +theme_override_constants/v_separation = 8 + +[node name="Label" type="Label" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "HUMAN — ONE MORE, ANY SKILL" + +[node name="Bonus0" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "athletics" + +[node name="Bonus1" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "intimidation" + +[node name="Bonus2" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "stealth" + +[node name="Bonus3" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sleight of hand" + +[node name="Bonus4" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "acrobatics" + +[node name="Bonus5" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "endurance" + +[node name="Bonus6" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "perception" + +[node name="Bonus7" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "faith lore" + +[node name="Bonus8" type="Button" parent="Split/Sheet/Body/SkillSection/BonusRow"] +layout_mode = 2 +toggle_mode = true +theme_type_variation = &"SkillChip" +text = "sorcery" + +[node name="AbilitySection" type="VBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +theme_override_constants/separation = 9 + +[node name="Head" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 14 + +[node name="Label" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "ABILITY SCORES" + +[node name="Points" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "points left: 3" + +[node name="Reroll" type="Button" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "⟳ re-roll" + +[node name="Hint" type="Label" parent="Split/Sheet/Body/AbilitySection/Head"] +layout_mode = 2 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 15 +text = "rolled 3d6, never below 8 — that roll is your floor, spend up only" + +[node name="Cards" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="Ab0" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"AbilityCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "STR" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab0/Overlay"] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 +grow_horizontal = 2 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab1" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"AbilityCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "DEX" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab1/Overlay"] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 +grow_horizontal = 2 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab2" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"AbilityCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "CON" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab2/Overlay"] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 +grow_horizontal = 2 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab3" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"AbilityCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "FTH" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab3/Overlay"] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 +grow_horizontal = 2 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Ab4" type="PanelContainer" parent="Split/Sheet/Body/AbilitySection/Cards"] +custom_minimum_size = Vector2(0, 132) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"AbilityCard" + +[node name="Box" type="VBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 5 + +[node name="Key" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "MAG" + +[node name="Value" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"Accent" +theme_override_font_sizes/font_size = 36 +text = "10" + +[node name="Rolled" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +horizontal_alignment = 1 +theme_type_variation = &"SectionLabel" +text = "rolled 10" + +[node name="Buttons" type="HBoxContainer" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 6 + +[node name="Minus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "−" + +[node name="Plus" type="Button" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Box/Buttons"] +custom_minimum_size = Vector2(36, 28) +layout_mode = 2 +theme_type_variation = &"ParchmentButton" +text = "+" + +[node name="Overlay" type="Control" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4"] +layout_mode = 2 +mouse_filter = 2 + +[node name="Tag" type="Label" parent="Split/Sheet/Body/AbilitySection/Cards/Ab4/Overlay"] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -42.0 +offset_top = -26.0 +offset_right = 42.0 +offset_bottom = -6.0 +grow_horizontal = 2 +mouse_filter = 2 +horizontal_alignment = 1 +vertical_alignment = 1 +theme_type_variation = &"PrimaryTag" +text = "PRIMARY" + +[node name="Footer" type="HBoxContainer" parent="Split/Sheet/Body"] +layout_mode = 2 +size_flags_vertical = 10 +theme_override_constants/separation = 20 + +[node name="NameBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 9 + +[node name="Label" type="Label" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +theme_type_variation = &"SectionLabel" +text = "NAME" + +[node name="NameEdit" type="LineEdit" parent="Split/Sheet/Body/Footer/NameBox"] +layout_mode = 2 +placeholder_text = "What do they call you?" + +[node name="CtaBox" type="VBoxContainer" parent="Split/Sheet/Body/Footer"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="Enter" type="Button" parent="Split/Sheet/Body/Footer/CtaBox"] +custom_minimum_size = Vector2(280, 56) +layout_mode = 2 +disabled = true +theme_type_variation = &"PrimaryCTA" +text = "ENTER THE WORLD" + +[node name="Error" type="Label" parent="Split/Sheet/Body/Footer/CtaBox"] +layout_mode = 2 +horizontal_alignment = 1 +autowrap_mode = 3 +theme_type_variation = &"SectionLabel" +text = "no second chances out there" diff --git a/client/scripts/canon_log/canon_log.gd b/client/scripts/canon_log/canon_log.gd index 01ca212..661de7a 100644 --- a/client/scripts/canon_log/canon_log.gd +++ b/client/scripts/canon_log/canon_log.gd @@ -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 diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index bc6f765..a55a7bd 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -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: diff --git a/client/scripts/state/currency.gd b/client/scripts/state/currency.gd index 4d19d0b..18834c4 100644 --- a/client/scripts/state/currency.gd +++ b/client/scripts/state/currency.gd @@ -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) diff --git a/client/scripts/theme/build_game_theme.gd b/client/scripts/theme/build_game_theme.gd index 3154db1..94a4eeb 100644 --- a/client/scripts/theme/build_game_theme.gd +++ b/client/scripts/theme/build_game_theme.gd @@ -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") diff --git a/client/scripts/theme/palette.gd b/client/scripts/theme/palette.gd index 9151289..7475397 100644 --- a/client/scripts/theme/palette.gd +++ b/client/scripts/theme/palette.gd @@ -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") diff --git a/client/scripts/theme/theme_keys.gd b/client/scripts/theme/theme_keys.gd index 6f8578f..0728c86 100644 --- a/client/scripts/theme/theme_keys.gd +++ b/client/scripts/theme/theme_keys.gd @@ -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", } diff --git a/client/scripts/ui/creation/character_creation.gd b/client/scripts/ui/creation/character_creation.gd new file mode 100644 index 0000000..4739302 --- /dev/null +++ b/client/scripts/ui/creation/character_creation.gd @@ -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 diff --git a/client/scripts/ui/creation/character_creation.gd.uid b/client/scripts/ui/creation/character_creation.gd.uid new file mode 100644 index 0000000..4c2ff05 --- /dev/null +++ b/client/scripts/ui/creation/character_creation.gd.uid @@ -0,0 +1 @@ +uid://3cxsq82gjlfk diff --git a/client/scripts/ui/creation/creation_copy.gd b/client/scripts/ui/creation/creation_copy.gd new file mode 100644 index 0000000..6dc4a4a --- /dev/null +++ b/client/scripts/ui/creation/creation_copy.gd @@ -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)) diff --git a/client/scripts/ui/creation/creation_copy.gd.uid b/client/scripts/ui/creation/creation_copy.gd.uid new file mode 100644 index 0000000..75e02e7 --- /dev/null +++ b/client/scripts/ui/creation/creation_copy.gd.uid @@ -0,0 +1 @@ +uid://rm6bkgstenf1 diff --git a/client/scripts/ui/creation/creation_draft.gd b/client/scripts/ui/creation/creation_draft.gd new file mode 100644 index 0000000..b0c32c8 --- /dev/null +++ b/client/scripts/ui/creation/creation_draft.gd @@ -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() diff --git a/client/scripts/ui/creation/creation_draft.gd.uid b/client/scripts/ui/creation/creation_draft.gd.uid new file mode 100644 index 0000000..4129624 --- /dev/null +++ b/client/scripts/ui/creation/creation_draft.gd.uid @@ -0,0 +1 @@ +uid://dkxuw5c4r6tld diff --git a/client/tests/unit/test_character_creation_screen.gd b/client/tests/unit/test_character_creation_screen.gd new file mode 100644 index 0000000..c18266a --- /dev/null +++ b/client/tests/unit/test_character_creation_screen.gd @@ -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") diff --git a/client/tests/unit/test_character_creation_screen.gd.uid b/client/tests/unit/test_character_creation_screen.gd.uid new file mode 100644 index 0000000..f63d415 --- /dev/null +++ b/client/tests/unit/test_character_creation_screen.gd.uid @@ -0,0 +1 @@ +uid://dmliumgn42tv8 diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index f321786..5325a88 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -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()]) diff --git a/client/tests/unit/test_creation_copy.gd b/client/tests/unit/test_creation_copy.gd new file mode 100644 index 0000000..d9c8662 --- /dev/null +++ b/client/tests/unit/test_creation_copy.gd @@ -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") diff --git a/client/tests/unit/test_creation_copy.gd.uid b/client/tests/unit/test_creation_copy.gd.uid new file mode 100644 index 0000000..280de2d --- /dev/null +++ b/client/tests/unit/test_creation_copy.gd.uid @@ -0,0 +1 @@ +uid://bvb87ime573x4 diff --git a/client/tests/unit/test_creation_draft.gd b/client/tests/unit/test_creation_draft.gd new file mode 100644 index 0000000..9c79151 --- /dev/null +++ b/client/tests/unit/test_creation_draft.gd @@ -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")) diff --git a/client/tests/unit/test_creation_draft.gd.uid b/client/tests/unit/test_creation_draft.gd.uid new file mode 100644 index 0000000..d154ce7 --- /dev/null +++ b/client/tests/unit/test_creation_draft.gd.uid @@ -0,0 +1 @@ +uid://dqllxamt2tcgu diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index c0d0f98..ef3dd9c 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -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") diff --git a/client/tests/unit/test_theme_resource.gd b/client/tests/unit/test_theme_resource.gd index 212c651..a9af29f 100644 --- a/client/tests/unit/test_theme_resource.gd +++ b/client/tests/unit/test_theme_resource.gd @@ -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) diff --git a/content/world/callings/bloodsworn.json b/content/world/callings/bloodsworn.json index ff30160..093a493 100644 --- a/content/world/callings/bloodsworn.json +++ b/content/world/callings/bloodsworn.json @@ -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." } diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json index 0ad877f..c714a1b 100644 --- a/content/world/callings/bonesetter.json +++ b/content/world/callings/bonesetter.json @@ -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." } diff --git a/content/world/callings/cutpurse.json b/content/world/callings/cutpurse.json index 697a3f6..23013d3 100644 --- a/content/world/callings/cutpurse.json +++ b/content/world/callings/cutpurse.json @@ -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." } diff --git a/content/world/callings/hedge_mage.json b/content/world/callings/hedge_mage.json index 26ff24b..ad3fde1 100644 --- a/content/world/callings/hedge_mage.json +++ b/content/world/callings/hedge_mage.json @@ -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." } diff --git a/content/world/callings/reaver.json b/content/world/callings/reaver.json index 24b3fc2..6a27575 100644 --- a/content/world/callings/reaver.json +++ b/content/world/callings/reaver.json @@ -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." } diff --git a/content/world/callings/sellsword.json b/content/world/callings/sellsword.json index 2aff2e5..ed41905 100644 --- a/content/world/callings/sellsword.json +++ b/content/world/callings/sellsword.json @@ -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." } diff --git a/content/world/callings/trapper.json b/content/world/callings/trapper.json index 3031d0a..9cc8690 100644 --- a/content/world/callings/trapper.json +++ b/content/world/callings/trapper.json @@ -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." } diff --git a/content/world/races/beastfolk.json b/content/world/races/beastfolk.json index 4dde59a..5f1d6b3 100644 --- a/content/world/races/beastfolk.json +++ b/content/world/races/beastfolk.json @@ -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." } diff --git a/content/world/races/dwarf.json b/content/world/races/dwarf.json index 1a5bc4b..7de1a3e 100644 --- a/content/world/races/dwarf.json +++ b/content/world/races/dwarf.json @@ -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." } diff --git a/content/world/races/elf.json b/content/world/races/elf.json index 6755cb9..719f958 100644 --- a/content/world/races/elf.json +++ b/content/world/races/elf.json @@ -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." } diff --git a/content/world/races/human.json b/content/world/races/human.json index 373054e..d483ad0 100644 --- a/content/world/races/human.json +++ b/content/world/races/human.json @@ -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." } diff --git a/docs/roadmap.md b/docs/roadmap.md index dd9df3a..626e5f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/docs/traps.md b/docs/traps.md index 9948052..6c1f6e5 100644 --- a/docs/traps.md +++ b/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.