feat(creation): the character creation screen

Variant C of the mock: seven nameplate calling cards in one row, with the chosen
calling's blurb AND mechanics in a detail panel below — the one card you
committed to is the only one that talks.

Editor-first (ADR 0001): the tree is in the .tscn, the script binds. No rules
here — CreationDraft holds them, CreationCopy derives the display strings, the
prose comes from ContentDB. Emits creation_confirmed(Dictionary); M4-c
constructs. Five ability cards. Never six (§7).
This commit is contained in:
2026-07-13 15:03:24 -05:00
parent fe8e24cf39
commit 2227d703f6
5 changed files with 1619 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
extends "res://addons/gut/test.gd"
const SCENE := "res://scenes/creation/CharacterCreation.tscn"
const ContentDB = preload("res://scripts/content/content_db.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 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")
assert_gt(s.size.x, 0.0, "viewport-fit sized the root (the M3-a collapse guard)")
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.
var s := _screen()
assert_eq(s._ability_cards.size(), 5)
for card in s._ability_cards:
assert_false(card.name.to_lower().contains("lck"))
func test_no_node_on_the_screen_says_luck():
var s := _screen()
for node in s.find_children("*", "Label", true, false):
assert_false(node.text.to_lower().contains("luck"), "%s leaks Luck (§7)" % node.name)
assert_false(node.text.to_lower().contains("lck"), "%s leaks Luck (§7)" % node.name)
func test_only_the_origin_s_allowed_callings_are_shown():
var s := _screen()
var shown := 0
for card in s._calling_cards:
if card.visible:
shown += 1
assert_eq(shown, s.origin["build_constraints"]["allowed_callings"].size())
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(), "")
s._complete_a_valid_draft_for_test()
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_pressing_enter_emits_exactly_the_draft_s_creation_dict():
var s := _screen()
s._complete_a_valid_draft_for_test()
watch_signals(s)
s._on_enter_pressed()
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()
s._complete_a_valid_draft_for_test()
for i in range(Attributes.IDS.size()):
var stat: String = Attributes.IDS[i]
var value_label: Label = s._ability_cards[i].get_node("Box/Value")
assert_eq(value_label.text, str(int(s.draft.final()[stat])),
"%s shows what the draft holds" % stat)
func test_reroll_changes_what_is_on_screen():
var s := _screen()
var before := s.draft.character_seed
s._on_reroll_pressed()
assert_ne(s.draft.character_seed, before)
assert_eq(s._points.text, "points left: 3", "a re-roll returns the pool")

View File

@@ -0,0 +1 @@
uid://dmliumgn42tv8