Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.
Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.
Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".
Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
calling_id; a calling passed into the race_id slot (the exact shape
of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
allowed_callings enum matches Callings.IDS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
114 lines
4.2 KiB
GDScript
114 lines
4.2 KiB
GDScript
extends "res://addons/gut/test.gd"
|
|
|
|
const NewGame = preload("res://scripts/newgame/new_game.gd")
|
|
const ContentDB = preload("res://scripts/content/content_db.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 _rng() -> RandomNumberGenerator:
|
|
var r := RandomNumberGenerator.new()
|
|
r.seed = 7
|
|
return r
|
|
|
|
|
|
func _build(creation: Dictionary) -> Dictionary:
|
|
return NewGame.construct(_deserter(), world, creation, _rng())
|
|
|
|
|
|
func test_construct_succeeds():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_true(res["ok"], str(res["errors"]))
|
|
|
|
|
|
func test_numeric_luck_absent_from_log():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
var pd: Dictionary = res["log"].player.to_dict()
|
|
assert_false("luck" in pd)
|
|
assert_eq(pd.keys().size(), 4)
|
|
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
|
|
|
|
|
|
func test_inventory_and_dispo_land_in_state_not_log():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
|
|
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
|
|
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
|
|
assert_false("inventory" in res["log"].to_dict())
|
|
|
|
|
|
func test_companion_dispositions_from_overrides():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
|
|
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
|
|
|
|
|
|
func test_situation_and_facts_seeded():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_eq(res["log"].recent_events.size(), 2)
|
|
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
|
|
assert_eq(res["log"].humiliations.size(), 0)
|
|
|
|
|
|
func test_active_quest_resolved_from_world():
|
|
var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_eq(res["log"].active_quests.size(), 1)
|
|
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
|
|
assert_eq(res["log"].active_quests[0].status, "active")
|
|
|
|
|
|
func test_null_start_quest_yields_no_quest():
|
|
var o := _deserter()
|
|
o["start_quest_id"] = null
|
|
var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng())
|
|
assert_eq(res["log"].active_quests.size(), 0)
|
|
|
|
|
|
func test_calling_not_allowed_is_rejected():
|
|
var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"})
|
|
assert_false(res["ok"])
|
|
assert_true(res["errors"].size() > 0)
|
|
assert_null(res["log"])
|
|
|
|
|
|
func test_unresolved_origin_is_rejected():
|
|
var o := _deserter()
|
|
o["start_location_id"] = "nowhere"
|
|
var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng())
|
|
assert_false(res["ok"])
|
|
assert_true("unresolved ref: location:nowhere" in res["errors"])
|
|
|
|
|
|
func test_empty_name_is_rejected():
|
|
var missing_name := _build({"race_id": "human", "calling_id": "sellsword"})
|
|
assert_false(missing_name["ok"])
|
|
assert_true(missing_name["errors"].size() > 0)
|
|
assert_null(missing_name["log"])
|
|
|
|
var whitespace_name := _build({"name": " ", "race_id": "human", "calling_id": "sellsword"})
|
|
assert_false(whitespace_name["ok"])
|
|
assert_true(whitespace_name["errors"].size() > 0)
|
|
assert_null(whitespace_name["log"])
|
|
|
|
|
|
func test_non_companion_override_goes_to_state_not_log():
|
|
world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"}
|
|
var o := _deserter()
|
|
o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25}
|
|
var res := NewGame.construct(o, world, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng())
|
|
assert_true(res["ok"], str(res["errors"]))
|
|
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
|
|
assert_null(res["log"].party_member("oda_fenn"))
|
|
for m in res["log"].to_dict()["party"]:
|
|
assert_ne(m["id"], "oda_fenn")
|
|
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
|