feat(client): NewGame.construct — origin+world+creation -> {canon log, state}
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
client/scripts/newgame/new_game.gd
Normal file
70
client/scripts/newgame/new_game.gd
Normal file
@@ -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)
|
||||
1
client/scripts/newgame/new_game.gd.uid
Normal file
1
client/scripts/newgame/new_game.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cju7c1s06onrd
|
||||
87
client/tests/unit/test_new_game.gd
Normal file
87
client/tests/unit/test_new_game.gd
Normal file
@@ -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"])
|
||||
1
client/tests/unit/test_new_game.gd.uid
Normal file
1
client/tests/unit/test_new_game.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://gwhuesdye0xa
|
||||
Reference in New Issue
Block a user