diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd new file mode 100644 index 0000000..6ed2cac --- /dev/null +++ b/client/scripts/newgame/new_game.gd @@ -0,0 +1,70 @@ +class_name NewGame +extends RefCounted +## New-game construction (spec "New-game construction"). Reads three immutable +## inputs (origin, world, creation) and writes two products (CanonLog, GameState). +## Nothing flows back up — charter §2 on the client. Validation is front-loaded: +## a broken origin or illegal class fails here, loudly, not three scenes later. + +static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary: + var errors: Array = [] + + # 1. content integrity — every referenced id must resolve in world content. + for ref in world.unresolved_refs(origin): + errors.append("unresolved ref: %s" % ref) + + # 2. build constraints — chosen class must be allowed by the origin. + var bc: Dictionary = origin.get("build_constraints", {}) + var allowed: Array = bc.get("allowed_classes", []) + var class_id: String = creation.get("class_id", "") + if class_id not in allowed: + errors.append("class not allowed by origin: %s" % class_id) + + if not errors.is_empty(): + return {"ok": false, "errors": errors, "log": null, "state": null} + + # 3. roll Luck (numeric -> state only) and 4. the stat block (data only). + var state := GameState.new() + state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0))) + state.luck = state.luck_base + state.stats = { + "str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng), + "fth": _roll_stat(rng), "mag": _roll_stat(rng), + } + + # 5. assemble the canon log. + var log := CanonLog.new() + log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor()) + + var loc: Dictionary = world.location(origin["start_location_id"]) + log.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)))) + # non-companion overrides go to game state, never the log (spec §5 / charter §6). + 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) + for fact in origin.get("opening_facts", []): + log.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", qd.get("objective", ""))) + + # inventory grants -> game state (NOT the log). Charter §2. + for grant in origin.get("inventory_grants", []): + state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0))) + + return {"ok": true, "errors": [], "log": log, "state": state} + + +static func _roll_stat(rng: RandomNumberGenerator) -> int: + # 3d6 placeholder; the stat block is data-only in this plan. TUNABLE. + return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6) diff --git a/client/scripts/newgame/new_game.gd.uid b/client/scripts/newgame/new_game.gd.uid new file mode 100644 index 0000000..fa799da --- /dev/null +++ b/client/scripts/newgame/new_game.gd.uid @@ -0,0 +1 @@ +uid://cju7c1s06onrd diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd new file mode 100644 index 0000000..365706a --- /dev/null +++ b/client/tests/unit/test_new_game.gd @@ -0,0 +1,87 @@ +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", "class_id": "sellsword"}) + assert_true(res["ok"], str(res["errors"])) + + +func test_numeric_luck_absent_from_log(): + var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var pd: Dictionary = res["log"].player.to_dict() + assert_false("luck" in pd) + assert_eq(pd.keys().size(), 3) + 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", "class_id": "sellsword"}) + assert_eq(res["state"].inventory.get("coin", 0), 3) + assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) + assert_false("inventory" in res["log"].to_dict()) + + +func test_companion_dispositions_from_overrides(): + var res := _build({"name": "Aldric", "class_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", "class_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", "class_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", "class_id": "sellsword"}, _rng()) + assert_eq(res["log"].active_quests.size(), 0) + + +func test_class_not_allowed_is_rejected(): + var res := _build({"name": "X", "class_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", "class_id": "sellsword"}, _rng()) + assert_false(res["ok"]) + assert_true("unresolved ref: location:nowhere" in res["errors"]) diff --git a/client/tests/unit/test_new_game.gd.uid b/client/tests/unit/test_new_game.gd.uid new file mode 100644 index 0000000..de8a486 --- /dev/null +++ b/client/tests/unit/test_new_game.gd.uid @@ -0,0 +1 @@ +uid://gwhuesdye0xa