Merge feat/creation-model into dev (M4-a)
The character-creation model: race/calling/skill mechanics as static tables in code, their blurbs as content, a CharacterSheet that stores only what was rolled or chosen and derives the rest, and a seeded NewGame.construct. Creation carries a seed, not a stat block — construct 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). A golden-vector test pins the RNG stream. The roll is 3d6 with a hard floor of 8: straight 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character bad at the one thing he is FOR is the 'fine' §7 forbids. Migrates the cross-boundary contract: class_id -> calling_id (the contract should say what the world says) and a required race_id, so the AI narrates 'a beastfolk cutpurse' instead of 'a sellsword'. No UI — the creation screen is M4-b, the title->creation->shell flow is M4-c. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
@@ -252,7 +252,7 @@ construction fails loudly.
|
||||
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 }
|
||||
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,6 +12,29 @@ from pathlib import Path
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||
|
||||
|
||||
def _humanize(id_: str) -> str:
|
||||
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
|
||||
return id_.replace("_", "-")
|
||||
|
||||
|
||||
def _article(word: str) -> str:
|
||||
if not word:
|
||||
return "a"
|
||||
return "an" if word[:1].lower() in "aeiou" else "a"
|
||||
|
||||
|
||||
def _describe_player(player: dict) -> str:
|
||||
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
|
||||
race = _humanize(player.get("race_id", ""))
|
||||
calling = _humanize(player.get("calling_id", ""))
|
||||
name = player.get("name", "")
|
||||
parts = [p for p in (race, calling) if p]
|
||||
if not parts:
|
||||
return name
|
||||
descriptor = " ".join(parts)
|
||||
return f"{name}, {_article(parts[0])} {descriptor}".strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def system_prompt(role: str) -> str:
|
||||
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
||||
@@ -50,7 +73,7 @@ def render_digest(canon_log: dict) -> str:
|
||||
descriptor = player.get("luck_descriptor")
|
||||
if descriptor:
|
||||
lines.append(f"Fortune: {descriptor}")
|
||||
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
|
||||
lines.append(f"Player: {_describe_player(player)}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Describe the scene as it is now.")
|
||||
@@ -104,7 +127,7 @@ def render_npc_digest(
|
||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||
|
||||
player = canon_log.get("player", {})
|
||||
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
|
||||
lines.append(f"The player is {_describe_player(player)}.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f'The player says to you: "{utterance}"')
|
||||
|
||||
3
api/tests/fixtures/canon_log_valid.json
vendored
3
api/tests/fixtures/canon_log_valid.json
vendored
@@ -2,7 +2,8 @@
|
||||
"schema_version": 1,
|
||||
"player": {
|
||||
"name": "Aldric",
|
||||
"class_id": "sellsword",
|
||||
"race_id": "human",
|
||||
"calling_id": "sellsword",
|
||||
"luck_descriptor": "Fortune spits on you"
|
||||
},
|
||||
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
||||
|
||||
@@ -68,3 +68,20 @@ def test_negative_turn_is_rejected():
|
||||
doc = _valid()
|
||||
doc["humiliations"][0]["turn"] = -1
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_dead_class_id_field_is_rejected():
|
||||
# The player block migrated from {name, class_id, luck_descriptor} to
|
||||
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
|
||||
# back in via additionalProperties: false.
|
||||
doc = _valid()
|
||||
doc["player"]["class_id"] = "sellsword"
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
|
||||
def test_unknown_calling_id_is_rejected():
|
||||
# assassin/priest are dead ids from the pre-migration class roster and
|
||||
# must not validate as calling_id values.
|
||||
doc = _valid()
|
||||
doc["player"]["calling_id"] = "priest"
|
||||
assert validate_canon_log(doc) != []
|
||||
|
||||
@@ -30,7 +30,8 @@ def test_live_npc_speak_fenn():
|
||||
log = {
|
||||
"player": {
|
||||
"name": "Aldric",
|
||||
"class_id": "sellsword",
|
||||
"race_id": "human",
|
||||
"calling_id": "sellsword",
|
||||
"luck_descriptor": "the dice are kind",
|
||||
},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
|
||||
@@ -4,7 +4,7 @@ import app.npc as npc
|
||||
from app.ollama_client import ModelError
|
||||
|
||||
LOG = {
|
||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
|
||||
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": [], "established_facts": [],
|
||||
"active_quests": [], "humiliations": [],
|
||||
|
||||
@@ -14,7 +14,7 @@ VALID_ORIGIN = {
|
||||
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": {
|
||||
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
|
||||
"luck_modifier": 0,
|
||||
},
|
||||
}
|
||||
@@ -30,9 +30,9 @@ def test_null_start_quest_is_allowed():
|
||||
assert validate_origin(doc) == []
|
||||
|
||||
|
||||
def test_unknown_class_is_rejected():
|
||||
def test_unknown_calling_is_rejected():
|
||||
doc = copy.deepcopy(VALID_ORIGIN)
|
||||
doc["build_constraints"]["allowed_classes"] = ["bard"]
|
||||
doc["build_constraints"]["allowed_callings"] = ["bard"]
|
||||
assert validate_origin(doc) != []
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context():
|
||||
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
||||
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
||||
assert "Fortune: Fortune spits on you" in d
|
||||
assert "Player: Aldric, a sellsword" in d
|
||||
assert "Player: Aldric, a human sellsword" in d
|
||||
assert d.rstrip().endswith("Describe the scene as it is now.")
|
||||
|
||||
|
||||
@@ -42,10 +42,47 @@ def test_narrator_body_is_authored():
|
||||
assert "second person" in body # the core task is present
|
||||
|
||||
|
||||
def _with_player(**player) -> dict:
|
||||
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
|
||||
log["player"] = {"luck_descriptor": "the dice are kind", **player}
|
||||
return log
|
||||
|
||||
|
||||
def test_digest_names_the_race_and_the_calling():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
|
||||
assert "Aldric, a beastfolk cutpurse" in out
|
||||
|
||||
|
||||
def test_digest_says_an_elf_not_a_elf():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
|
||||
assert "an elf sellsword" in out
|
||||
|
||||
|
||||
def test_digest_does_not_leak_a_snake_case_id():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
|
||||
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
|
||||
assert "hedge-mage" in out
|
||||
|
||||
|
||||
def test_digest_omits_race_clause_when_race_is_empty():
|
||||
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
|
||||
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
|
||||
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
|
||||
assert "Player: Aldric, a sellsword" in out
|
||||
assert " " not in out
|
||||
assert ", an " not in out
|
||||
|
||||
|
||||
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
|
||||
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
|
||||
assert "\nPlayer: Aldric\n" in out
|
||||
assert " " not in out
|
||||
|
||||
|
||||
def test_render_npc_digest_includes_all_sections():
|
||||
from app import prompts
|
||||
log = {
|
||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
||||
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||
"party": [], "recent_events": ["a gull screamed"],
|
||||
"established_facts": ["the eastern bridge is out"],
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
class_name LogPlayer
|
||||
extends RefCounted
|
||||
## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7):
|
||||
## no numeric Luck, no stats — those live in GameState and never reach the AI.
|
||||
|
||||
const CLASSES := ["sellsword", "assassin", "priest"]
|
||||
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
|
||||
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
|
||||
## AI. race_id is here because the Narrator and NPCs must be able to describe the
|
||||
## player ("a beastfolk cutpurse"); the mechanical sheet must not.
|
||||
|
||||
var name: String
|
||||
var class_id: String
|
||||
var race_id: String
|
||||
var calling_id: String
|
||||
var luck_descriptor: String
|
||||
|
||||
|
||||
func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void:
|
||||
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
|
||||
name = p_name
|
||||
luck_descriptor = p_luck_descriptor
|
||||
if p_class_id != "":
|
||||
set_class_id(p_class_id)
|
||||
if p_race_id != "":
|
||||
set_race_id(p_race_id)
|
||||
if p_calling_id != "":
|
||||
set_calling_id(p_calling_id)
|
||||
|
||||
|
||||
func set_class_id(v: String) -> bool:
|
||||
if v not in CLASSES:
|
||||
push_error("invalid class_id: %s" % v)
|
||||
func set_race_id(v: String) -> bool:
|
||||
if not Races.exists(v):
|
||||
push_error("invalid race_id: %s" % v)
|
||||
return false
|
||||
class_id = v
|
||||
race_id = v
|
||||
return true
|
||||
|
||||
|
||||
func set_calling_id(v: String) -> bool:
|
||||
if not Callings.exists(v):
|
||||
push_error("invalid calling_id: %s" % v)
|
||||
return false
|
||||
calling_id = v
|
||||
return true
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor}
|
||||
return {"name": name, "race_id": race_id, "calling_id": calling_id,
|
||||
"luck_descriptor": luck_descriptor}
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary) -> LogPlayer:
|
||||
return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", ""))
|
||||
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
|
||||
d.get("calling_id", ""), d.get("luck_descriptor", ""))
|
||||
|
||||
@@ -8,6 +8,8 @@ var locations: Dictionary = {}
|
||||
var npcs: Dictionary = {}
|
||||
var quests: Dictionary = {}
|
||||
var items: Dictionary = {}
|
||||
var callings: Dictionary = {}
|
||||
var races: Dictionary = {}
|
||||
var canon_entities: Dictionary = {}
|
||||
var topics: Dictionary = {}
|
||||
|
||||
@@ -31,6 +33,8 @@ func load_from(content_root: String) -> void:
|
||||
npcs = _load_dir(world.path_join("npcs"))
|
||||
quests = _load_dir(world.path_join("quests"))
|
||||
items = _load_dir(world.path_join("items"))
|
||||
callings = _load_dir(world.path_join("callings"))
|
||||
races = _load_dir(world.path_join("races"))
|
||||
canon_entities = _load_dir(world.path_join("canon"))
|
||||
topics = _load_dir(world.path_join("topics"))
|
||||
|
||||
@@ -61,6 +65,10 @@ func has_location(id: String) -> bool: return locations.has(id)
|
||||
func has_npc(id: String) -> bool: return npcs.has(id)
|
||||
func has_quest(id: String) -> bool: return quests.has(id)
|
||||
func has_item(id: String) -> bool: return items.has(id)
|
||||
func calling(id: String) -> Dictionary: return callings.get(id, {})
|
||||
func race(id: String) -> Dictionary: return races.get(id, {})
|
||||
func has_calling(id: String) -> bool: return callings.has(id)
|
||||
func has_race(id: String) -> bool: return races.has(id)
|
||||
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
|
||||
func topic(id: String) -> Dictionary: return topics.get(id, {})
|
||||
func has_canon(id: String) -> bool: return canon_entities.has(id)
|
||||
|
||||
@@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void:
|
||||
|
||||
func _build_scene_log() -> CanonLog:
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
|
||||
log.set_location("greywater_docks", "the Greywater docks")
|
||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||
|
||||
@@ -39,7 +39,7 @@ func _ready() -> void:
|
||||
# Build the player through LogPlayer so luck_descriptor is non-empty — the
|
||||
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
|
||||
# the digest surfaces to the NPC. An empty string here 422s the request.
|
||||
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
|
||||
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
|
||||
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
|
||||
# can offer_quest it during the conversation.
|
||||
|
||||
|
||||
@@ -1,42 +1,66 @@
|
||||
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.
|
||||
## New-game construction. Reads three immutable inputs (origin, world, creation) and
|
||||
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
|
||||
##
|
||||
## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls
|
||||
## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same
|
||||
## seed always yields the same character (§10 — "the difference between a bug you can
|
||||
## reproduce and a bug you cannot"). The whole character is reproducible from four
|
||||
## primitives: seed, race_id, calling_id, spend.
|
||||
##
|
||||
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
|
||||
## UI entirely, so the creation SCENE must never become the only thing that can make it.
|
||||
|
||||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
|
||||
var errors: Array = []
|
||||
const SPEND_POOL := 3
|
||||
|
||||
# 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 creation.get("name", "").strip_edges() == "":
|
||||
errors.append("player name is required")
|
||||
|
||||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
|
||||
var errors := _validate(origin, world, creation)
|
||||
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 rng := RandomNumberGenerator.new()
|
||||
rng.seed = int(creation.get("seed", 0))
|
||||
|
||||
var state := GameState.new()
|
||||
var sheet := CharacterSheet.new()
|
||||
sheet.race_id = str(creation.get("race_id", ""))
|
||||
sheet.calling_id = str(creation.get("calling_id", ""))
|
||||
|
||||
# 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)
|
||||
|
||||
var bc: Dictionary = origin.get("build_constraints", {})
|
||||
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 spend: Dictionary = creation.get("spend", {})
|
||||
for stat in spend:
|
||||
attrs[stat] = int(attrs[stat]) + int(spend[stat])
|
||||
sheet.attributes = attrs
|
||||
|
||||
# Proficiencies: what the race grants + what the player picked + the human's bonus.
|
||||
# All land in one list, so is_proficient() has exactly one thing to read.
|
||||
var skills: Array = []
|
||||
skills.append_array(Races.granted_skills(sheet.race_id))
|
||||
skills.append_array(creation.get("skills", []))
|
||||
var bonus := str(creation.get("bonus_skill", ""))
|
||||
if bonus != "":
|
||||
skills.append(bonus)
|
||||
sheet.bonus_skill = bonus
|
||||
sheet.skills = skills
|
||||
|
||||
sheet.hp = sheet.max_hp() # the character starts whole
|
||||
sheet.mp = sheet.max_mp()
|
||||
state.sheet = sheet
|
||||
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor())
|
||||
log.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", ""))
|
||||
@@ -46,7 +70,6 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
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]))
|
||||
@@ -59,16 +82,118 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
|
||||
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", "")))
|
||||
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. grant() routes
|
||||
# currency to the purse; everything else to the inventory dict.
|
||||
for grant in origin.get("inventory_grants", []):
|
||||
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
||||
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
|
||||
# purse; everything else to the inventory dict.
|
||||
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}
|
||||
|
||||
|
||||
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)
|
||||
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
|
||||
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
|
||||
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
|
||||
# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
|
||||
# twenty hours.
|
||||
var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
|
||||
return maxi(8, roll)
|
||||
|
||||
|
||||
static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||||
var errors: Array = []
|
||||
|
||||
for ref in world.unresolved_refs(origin):
|
||||
errors.append("unresolved ref: %s" % ref)
|
||||
|
||||
if str(creation.get("name", "")).strip_edges() == "":
|
||||
errors.append("player name is required")
|
||||
|
||||
if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT:
|
||||
errors.append("seed is required and must be an integer")
|
||||
|
||||
var race_id: String = str(creation.get("race_id", ""))
|
||||
if not Races.exists(race_id):
|
||||
errors.append("unknown race: %s" % race_id)
|
||||
|
||||
var calling_id: String = str(creation.get("calling_id", ""))
|
||||
if not Callings.exists(calling_id):
|
||||
errors.append("unknown calling: %s" % calling_id)
|
||||
else:
|
||||
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
|
||||
if calling_id not in allowed:
|
||||
errors.append("calling not allowed by origin: %s" % calling_id)
|
||||
|
||||
# Guard container SHAPES before any statically-typed call below touches them.
|
||||
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
|
||||
# without this check, assigning that into a typed Dictionary/Array parameter
|
||||
# throws a GDScript runtime error INSIDE construct() instead of landing in the
|
||||
# front-loaded {"ok": false, "errors": [...]} the spec promises.
|
||||
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
|
||||
errors.append("spend must be an object")
|
||||
else:
|
||||
errors.append_array(_validate_spend(creation.get("spend", {})))
|
||||
|
||||
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
|
||||
errors.append("skills must be an array")
|
||||
elif Races.exists(race_id) and Callings.exists(calling_id):
|
||||
errors.append_array(_validate_skills(creation, race_id, calling_id))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
static func _validate_spend(spend: Dictionary) -> Array:
|
||||
var errors: Array = []
|
||||
var total := 0
|
||||
for stat in spend:
|
||||
# LCK is not merely hidden at creation — it is unspendable (§7).
|
||||
if not Attributes.exists(str(stat)):
|
||||
errors.append("cannot spend on '%s' — not an attribute" % stat)
|
||||
continue
|
||||
if typeof(spend[stat]) != TYPE_INT:
|
||||
errors.append("spend value for '%s' must be an integer" % stat)
|
||||
continue
|
||||
var v := int(spend[stat])
|
||||
if v < 0:
|
||||
errors.append("spend is additive only: %s is negative" % stat)
|
||||
continue
|
||||
total += v
|
||||
if total > SPEND_POOL:
|
||||
errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
|
||||
return errors
|
||||
|
||||
|
||||
static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
|
||||
var errors: Array = []
|
||||
var picks: Array = creation.get("skills", [])
|
||||
var granted: Array = Races.granted_skills(race_id)
|
||||
var pool: Array = Callings.skill_pool(calling_id)
|
||||
var want := Callings.skill_count(calling_id)
|
||||
|
||||
if picks.size() != want:
|
||||
errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])
|
||||
|
||||
var seen := {}
|
||||
for s in picks:
|
||||
if s in seen:
|
||||
errors.append("duplicate skill pick: %s" % s)
|
||||
seen[s] = true
|
||||
if s not in pool:
|
||||
errors.append("%s is not in the %s pool" % [s, calling_id])
|
||||
if s in granted:
|
||||
errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])
|
||||
|
||||
var bonus := str(creation.get("bonus_skill", ""))
|
||||
if Races.wants_bonus_skill(race_id):
|
||||
if bonus == "":
|
||||
errors.append("%s must choose a bonus skill" % race_id)
|
||||
elif not Skills.exists(bonus):
|
||||
errors.append("unknown bonus skill: %s" % bonus)
|
||||
elif bonus in picks or bonus in granted:
|
||||
errors.append("bonus skill %s is already proficient" % bonus)
|
||||
elif bonus != "":
|
||||
errors.append("%s does not get a bonus skill" % race_id)
|
||||
|
||||
return errors
|
||||
|
||||
15
client/scripts/rules/attributes.gd
Normal file
15
client/scripts/rules/attributes.gd
Normal file
@@ -0,0 +1,15 @@
|
||||
class_name Attributes
|
||||
extends RefCounted
|
||||
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
|
||||
## here — it is never an attribute row, never a save, never a skill (§7). It lives
|
||||
## in GameState.luck.
|
||||
|
||||
const IDS := ["str", "dex", "con", "fth", "mag"]
|
||||
|
||||
|
||||
static func exists(id: String) -> bool:
|
||||
return id in IDS
|
||||
|
||||
|
||||
static func modifier(score: int) -> int:
|
||||
return floori((score - 10) / 2.0)
|
||||
1
client/scripts/rules/attributes.gd.uid
Normal file
1
client/scripts/rules/attributes.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dnlwajfs3vysm
|
||||
93
client/scripts/rules/callings.gd
Normal file
93
client/scripts/rules/callings.gd
Normal file
@@ -0,0 +1,93 @@
|
||||
class_name Callings
|
||||
extends RefCounted
|
||||
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
|
||||
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
|
||||
##
|
||||
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
|
||||
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
|
||||
## calling's primary-stat skills plus a small adjacent slice, always larger than the
|
||||
## pick count so the choice is real.
|
||||
##
|
||||
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
|
||||
## armor is an item (M7).
|
||||
|
||||
const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]
|
||||
|
||||
const TABLE := {
|
||||
"sellsword": {
|
||||
"primary": "str", "hit_die": 10, "saves": ["str", "con"],
|
||||
"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
|
||||
"casting_stat": "", "armor": "heavy", "talent": "second_wind",
|
||||
},
|
||||
"reaver": {
|
||||
"primary": "str", "hit_die": 12, "saves": ["str", "con"],
|
||||
"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
|
||||
"casting_stat": "", "armor": "medium", "talent": "blood_fury",
|
||||
},
|
||||
"cutpurse": {
|
||||
"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
|
||||
"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
|
||||
"skill_count": 4,
|
||||
"casting_stat": "", "armor": "light", "talent": "backstab",
|
||||
},
|
||||
"trapper": {
|
||||
"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
|
||||
"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
|
||||
"casting_stat": "", "armor": "light", "talent": "set_snare",
|
||||
},
|
||||
"hedge_mage": {
|
||||
"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
|
||||
"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
|
||||
"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
|
||||
},
|
||||
"bonesetter": {
|
||||
"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
|
||||
"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
|
||||
"casting_stat": "fth", "armor": "medium", "talent": "mend",
|
||||
},
|
||||
"bloodsworn": {
|
||||
"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
|
||||
"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
|
||||
"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func exists(id: String) -> bool:
|
||||
return TABLE.has(id)
|
||||
|
||||
|
||||
static func primary(id: String) -> String:
|
||||
return str(TABLE.get(id, {}).get("primary", ""))
|
||||
|
||||
|
||||
static func hit_die(id: String) -> int:
|
||||
return int(TABLE.get(id, {}).get("hit_die", 0))
|
||||
|
||||
|
||||
static func saves(id: String) -> Array:
|
||||
return TABLE.get(id, {}).get("saves", [])
|
||||
|
||||
|
||||
static func skill_pool(id: String) -> Array:
|
||||
return TABLE.get(id, {}).get("skill_pool", [])
|
||||
|
||||
|
||||
static func skill_count(id: String) -> int:
|
||||
return int(TABLE.get(id, {}).get("skill_count", 0))
|
||||
|
||||
|
||||
static func casting_stat(id: String) -> String:
|
||||
return str(TABLE.get(id, {}).get("casting_stat", ""))
|
||||
|
||||
|
||||
static func is_caster(id: String) -> bool:
|
||||
return casting_stat(id) != ""
|
||||
|
||||
|
||||
static func armor(id: String) -> String:
|
||||
return str(TABLE.get(id, {}).get("armor", ""))
|
||||
|
||||
|
||||
static func talent(id: String) -> String:
|
||||
return str(TABLE.get(id, {}).get("talent", ""))
|
||||
1
client/scripts/rules/callings.gd.uid
Normal file
1
client/scripts/rules/callings.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cewlixhavh1h4
|
||||
60
client/scripts/rules/races.gd
Normal file
60
client/scripts/rules/races.gd
Normal file
@@ -0,0 +1,60 @@
|
||||
class_name Races
|
||||
extends RefCounted
|
||||
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
|
||||
## attribute score, which is what keeps the roll + spend pure.
|
||||
##
|
||||
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
|
||||
## exploration checks consume them; creation only stores them.
|
||||
##
|
||||
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
|
||||
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
|
||||
## lying. It is exposed separately as poison_save_bonus().
|
||||
|
||||
const IDS := ["human", "elf", "dwarf", "beastfolk"]
|
||||
|
||||
const TABLE := {
|
||||
"human": {
|
||||
"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
|
||||
"poison_save_bonus": 0,
|
||||
"flags": {"nightsight": false, "claws": false, "keen_scent": false},
|
||||
},
|
||||
"elf": {
|
||||
"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
|
||||
"poison_save_bonus": 0,
|
||||
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||||
},
|
||||
"dwarf": {
|
||||
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||||
"poison_save_bonus": 2,
|
||||
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||||
},
|
||||
"beastfolk": {
|
||||
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||||
"poison_save_bonus": 0,
|
||||
"flags": {"nightsight": false, "claws": true, "keen_scent": true},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func exists(id: String) -> bool:
|
||||
return TABLE.has(id)
|
||||
|
||||
|
||||
static func save_bonus(id: String) -> int:
|
||||
return int(TABLE.get(id, {}).get("save_bonus", 0))
|
||||
|
||||
|
||||
static func granted_skills(id: String) -> Array:
|
||||
return TABLE.get(id, {}).get("granted_skills", [])
|
||||
|
||||
|
||||
static func wants_bonus_skill(id: String) -> bool:
|
||||
return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))
|
||||
|
||||
|
||||
static func poison_save_bonus(id: String) -> int:
|
||||
return int(TABLE.get(id, {}).get("poison_save_bonus", 0))
|
||||
|
||||
|
||||
static func flag(id: String, name: String) -> bool:
|
||||
return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))
|
||||
1
client/scripts/rules/races.gd.uid
Normal file
1
client/scripts/rules/races.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmkoxq4eabfcq
|
||||
28
client/scripts/rules/skills.gd
Normal file
28
client/scripts/rules/skills.gd
Normal file
@@ -0,0 +1,28 @@
|
||||
class_name Skills
|
||||
extends RefCounted
|
||||
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
|
||||
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
|
||||
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).
|
||||
|
||||
const BY_ATTRIBUTE := {
|
||||
"athletics": "str",
|
||||
"intimidation": "str",
|
||||
"stealth": "dex",
|
||||
"sleight_of_hand": "dex",
|
||||
"acrobatics": "dex",
|
||||
"endurance": "con",
|
||||
"perception": "fth",
|
||||
"faith_lore": "fth",
|
||||
"sorcery": "mag",
|
||||
}
|
||||
|
||||
const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
|
||||
"endurance", "perception", "faith_lore", "sorcery"]
|
||||
|
||||
|
||||
static func exists(skill: String) -> bool:
|
||||
return BY_ATTRIBUTE.has(skill)
|
||||
|
||||
|
||||
static func attribute(skill: String) -> String:
|
||||
return str(BY_ATTRIBUTE.get(skill, ""))
|
||||
1
client/scripts/rules/skills.gd.uid
Normal file
1
client/scripts/rules/skills.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2pvtvrrhc27b
|
||||
94
client/scripts/state/character_sheet.gd
Normal file
94
client/scripts/state/character_sheet.gd
Normal file
@@ -0,0 +1,94 @@
|
||||
class_name CharacterSheet
|
||||
extends RefCounted
|
||||
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
|
||||
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
|
||||
## so a derived number can never drift from the inputs that produced it, and M5's level
|
||||
## curve becomes a function change rather than a stored-field migration.
|
||||
##
|
||||
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
|
||||
## save, no skill, no row (§7). That holds by construction, not by discipline.
|
||||
|
||||
const PROF_BONUS_L1 := 2
|
||||
|
||||
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
|
||||
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
|
||||
var race_id: String = ""
|
||||
var calling_id: String = ""
|
||||
var level: int = 1
|
||||
var skills: Array = [] # chosen + race-granted + the human's bonus pick
|
||||
var bonus_skill: String = "" # human only; also present in `skills`
|
||||
var hp: int = 0 # CURRENT hp — combat writes this
|
||||
var mp: int = 0 # CURRENT mp
|
||||
|
||||
|
||||
# --- derived: functions, never stored -------------------------------------------
|
||||
func modifier(stat: String) -> int:
|
||||
return Attributes.modifier(int(attributes.get(stat, 10)))
|
||||
|
||||
|
||||
func prof_bonus() -> int:
|
||||
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
|
||||
|
||||
|
||||
func max_hp() -> int:
|
||||
return Callings.hit_die(calling_id) + modifier("con")
|
||||
|
||||
|
||||
func max_mp() -> int:
|
||||
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
|
||||
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
|
||||
# caster's scales off the casting stat, with a floor so a bad roll still casts.
|
||||
if not Callings.is_caster(calling_id):
|
||||
return 0
|
||||
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
|
||||
|
||||
|
||||
func ac() -> int:
|
||||
return 10 + modifier("dex") # armor is an item -> M7
|
||||
|
||||
|
||||
func initiative() -> int:
|
||||
return modifier("dex")
|
||||
|
||||
|
||||
func is_proficient(skill: String) -> bool:
|
||||
return skill in skills
|
||||
|
||||
|
||||
func save(stat: String) -> int:
|
||||
var v := modifier(stat)
|
||||
if stat in Callings.saves(calling_id):
|
||||
v += prof_bonus()
|
||||
v += Races.save_bonus(race_id) # human's +1 to all five
|
||||
return v
|
||||
|
||||
|
||||
func poison_save_bonus() -> int:
|
||||
# Situational (vs poison + the affliction track) — deliberately NOT folded into
|
||||
# save("con"), because a conditional inside a flat number is a sheet that lies.
|
||||
return Races.poison_save_bonus(race_id)
|
||||
|
||||
|
||||
func skill_bonus(skill: String) -> int:
|
||||
var v := modifier(Skills.attribute(skill))
|
||||
if is_proficient(skill):
|
||||
v += prof_bonus()
|
||||
return v
|
||||
|
||||
|
||||
func spell_dc() -> int:
|
||||
if not Callings.is_caster(calling_id):
|
||||
return 0
|
||||
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
|
||||
|
||||
|
||||
func has_nightsight() -> bool:
|
||||
return Races.flag(race_id, "nightsight")
|
||||
|
||||
|
||||
func has_claws() -> bool:
|
||||
return Races.flag(race_id, "claws")
|
||||
|
||||
|
||||
func has_keen_scent() -> bool:
|
||||
return Races.flag(race_id, "keen_scent")
|
||||
1
client/scripts/state/character_sheet.gd.uid
Normal file
1
client/scripts/state/character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://mofimtmsxuui
|
||||
@@ -1,12 +1,13 @@
|
||||
class_name GameState
|
||||
extends RefCounted
|
||||
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
|
||||
## stats, world-NPC dispositions, and inventory — none of which enter the canon
|
||||
## log (charter §7/§2). The log reads only luck_descriptor() from here.
|
||||
## the character sheet, world-NPC dispositions, and inventory — none of which
|
||||
## enter the canon log (charter §7/§2). The log reads only luck_descriptor()
|
||||
## from here.
|
||||
|
||||
var luck: int = 0
|
||||
var luck_base: int = 0
|
||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
||||
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
|
||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
|
||||
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
|
||||
|
||||
@@ -121,7 +121,7 @@ func _build_seed_log() -> CanonLog:
|
||||
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
|
||||
# number is rendered anywhere (§7); the descriptor is prose-only.
|
||||
var log := CanonLog.new()
|
||||
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
|
||||
log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you")
|
||||
log.set_location("lower_ward", "the Lower Ward")
|
||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||
|
||||
91
client/tests/unit/test_character_sheet.gd
Normal file
91
client/tests/unit/test_character_sheet.gd
Normal file
@@ -0,0 +1,91 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
const CharacterSheet = preload("res://scripts/state/character_sheet.gd")
|
||||
|
||||
|
||||
func _sheet(race := "human", calling := "sellsword", attrs := {}) -> CharacterSheet:
|
||||
var s = CharacterSheet.new()
|
||||
s.race_id = race
|
||||
s.calling_id = calling
|
||||
s.attributes = {"str": 14, "dex": 12, "con": 14, "fth": 10, "mag": 8}
|
||||
for k in attrs:
|
||||
s.attributes[k] = attrs[k]
|
||||
return s
|
||||
|
||||
|
||||
func test_max_hp_is_hit_die_plus_con_mod():
|
||||
var s = _sheet("human", "reaver") # d12, CON 14 -> +2
|
||||
assert_eq(s.max_hp(), 14)
|
||||
var frail = _sheet("human", "hedge_mage", {"con": 8}) # d6, CON 8 -> -1
|
||||
assert_eq(frail.max_hp(), 5)
|
||||
|
||||
|
||||
func test_ac_is_ten_plus_dex():
|
||||
assert_eq(_sheet().ac(), 11) # DEX 12 -> +1. Armor is an item (M7).
|
||||
|
||||
|
||||
func test_initiative_is_dex():
|
||||
assert_eq(_sheet().initiative(), 1)
|
||||
|
||||
|
||||
func test_save_picks_up_calling_proficiency():
|
||||
var s = _sheet("elf", "sellsword") # saves STR, CON; prof +2
|
||||
assert_eq(s.save("str"), 4) # STR 14 -> +2, +2 prof
|
||||
assert_eq(s.save("dex"), 1) # DEX 12 -> +1, not proficient
|
||||
|
||||
|
||||
func test_human_plus_one_lands_on_all_five_saves():
|
||||
var human = _sheet("human", "sellsword")
|
||||
var elf = _sheet("elf", "sellsword")
|
||||
for stat in ["str", "dex", "con", "fth", "mag"]:
|
||||
assert_eq(human.save(stat), elf.save(stat) + 1, "human +1 on %s" % stat)
|
||||
|
||||
|
||||
func test_dwarf_poison_bonus_is_not_in_the_con_save():
|
||||
var dwarf = _sheet("dwarf", "sellsword")
|
||||
var elf = _sheet("elf", "sellsword")
|
||||
assert_eq(dwarf.save("con"), elf.save("con"), "the +2 is situational, not a flat CON save")
|
||||
assert_eq(dwarf.poison_save_bonus(), 2)
|
||||
assert_eq(elf.poison_save_bonus(), 0)
|
||||
|
||||
|
||||
func test_skill_bonus_uses_the_governing_attribute():
|
||||
var s = _sheet() # STR 14 -> +2
|
||||
assert_eq(s.skill_bonus("athletics"), 2, "not proficient: attribute only")
|
||||
s.skills = ["athletics"]
|
||||
assert_eq(s.skill_bonus("athletics"), 4, "proficient: +2 prof")
|
||||
|
||||
|
||||
func test_bonus_skill_confers_proficiency_via_the_skills_list():
|
||||
var s = _sheet()
|
||||
s.skills = ["athletics", "endurance"] # Task 5 appends the human's pick here
|
||||
s.bonus_skill = "endurance"
|
||||
assert_true(s.is_proficient("endurance"))
|
||||
assert_false(s.is_proficient("stealth"))
|
||||
|
||||
|
||||
func test_martials_have_no_mp_and_no_spell_dc():
|
||||
var s = _sheet("human", "sellsword")
|
||||
assert_eq(s.max_mp(), 0)
|
||||
assert_eq(s.spell_dc(), 0)
|
||||
|
||||
|
||||
func test_casters_have_mp_and_a_spell_dc():
|
||||
var s = _sheet("human", "bonesetter", {"fth": 16}) # FTH 16 -> +3
|
||||
assert_true(s.max_mp() > 0, "a caster has a pool")
|
||||
assert_eq(s.spell_dc(), 13) # 8 + 2 prof + 3
|
||||
|
||||
|
||||
func test_race_flags_are_exposed():
|
||||
assert_true(_sheet("elf").has_nightsight())
|
||||
assert_false(_sheet("human").has_nightsight())
|
||||
assert_true(_sheet("beastfolk").has_claws())
|
||||
assert_true(_sheet("beastfolk").has_keen_scent())
|
||||
assert_false(_sheet("dwarf").has_claws())
|
||||
|
||||
|
||||
func test_luck_is_nowhere_on_the_sheet():
|
||||
# §7: LCK has no save, no skill, no row, no accessor. It lives in GameState.
|
||||
var s = _sheet()
|
||||
assert_false("lck" in s.attributes, "LCK is never an attribute row")
|
||||
assert_false(s.has_method("luck"), "the sheet must not expose Luck")
|
||||
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
1
client/tests/unit/test_character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgi5hu3y2fg2u
|
||||
@@ -91,3 +91,42 @@ func test_no_loaded_topic_has_a_body():
|
||||
|
||||
func test_legacy_npcs_still_load():
|
||||
assert_true(db.has_npc("fenn"))
|
||||
|
||||
|
||||
func test_loads_calling_and_race_content():
|
||||
assert_true(db.has_calling("reaver"))
|
||||
assert_true(db.has_calling("bloodsworn"))
|
||||
assert_true(db.has_race("beastfolk"))
|
||||
assert_eq(db.calling("reaver")["name"], "Reaver")
|
||||
assert_eq(db.race("beastfolk")["name"], "Beastfolk")
|
||||
|
||||
|
||||
func test_calling_content_matches_the_code_table():
|
||||
# The roster exists in code (Callings.IDS — mechanics are rules) and in content
|
||||
# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
|
||||
var content_ids: Array = db.callings.keys()
|
||||
content_ids.sort()
|
||||
var code_ids: Array = Callings.IDS.duplicate()
|
||||
code_ids.sort()
|
||||
assert_eq(content_ids, code_ids)
|
||||
|
||||
|
||||
func test_race_content_matches_the_code_table():
|
||||
var content_ids: Array = db.races.keys()
|
||||
content_ids.sort()
|
||||
var code_ids: Array = Races.IDS.duplicate()
|
||||
code_ids.sort()
|
||||
assert_eq(content_ids, code_ids)
|
||||
|
||||
|
||||
func test_blurb_content_carries_no_mechanics():
|
||||
# Mechanics are code. A hit_die in a content file is a second source of truth.
|
||||
for id in db.callings:
|
||||
for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]:
|
||||
assert_false(db.callings[id].has(banned),
|
||||
"%s.json leaks the mechanic '%s' — that lives in Callings" % [id, banned])
|
||||
for id in db.races:
|
||||
for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags",
|
||||
"nightsight", "claws", "keen_scent"]:
|
||||
assert_false(db.races[id].has(banned),
|
||||
"%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned])
|
||||
|
||||
25
client/tests/unit/test_dev_harness_seed_logs.gd
Normal file
25
client/tests/unit/test_dev_harness_seed_logs.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
## Guards the two dev-only proving-scene seed logs (narrate_harness,
|
||||
## npc_harness) against the same LogPlayer arg-slot regression the shell test
|
||||
## guards (see test_main_window_shell.gd). Both scenes build their seed log's
|
||||
## LogPlayer in _ready() with no network call until a button is pressed, so
|
||||
## instantiate + add_child_autofree is cheap and touches no network.
|
||||
|
||||
const NARRATE_SCENE := "res://scenes/narrate_harness.tscn"
|
||||
const NPC_SCENE := "res://scenes/npc_harness.tscn"
|
||||
|
||||
|
||||
func test_narrate_harness_seed_log_has_real_race_and_calling():
|
||||
var node = load(NARRATE_SCENE).instantiate()
|
||||
add_child_autofree(node)
|
||||
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
|
||||
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
|
||||
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
|
||||
|
||||
|
||||
func test_npc_harness_seed_log_has_real_race_and_calling():
|
||||
var node = load(NPC_SCENE).instantiate()
|
||||
add_child_autofree(node)
|
||||
assert_true(Races.exists(node._canon_log.player.race_id), "seed race must be a real race")
|
||||
assert_true(Callings.exists(node._canon_log.player.calling_id), "seed calling must be a real calling")
|
||||
assert_ne(node._canon_log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
|
||||
1
client/tests/unit/test_dev_harness_seed_logs.gd.uid
Normal file
1
client/tests/unit/test_dev_harness_seed_logs.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmaooitlx0ox4
|
||||
@@ -14,18 +14,36 @@ func test_ids_regex():
|
||||
assert_false(Ids.is_valid("has-hyphen"))
|
||||
|
||||
|
||||
func test_player_dict_has_only_three_keys():
|
||||
var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
var d = p.to_dict()
|
||||
assert_eq(d.keys().size(), 3)
|
||||
assert_false("luck" in d)
|
||||
assert_eq(d["class_id"], "sellsword")
|
||||
func test_log_player_carries_race_and_calling_and_no_luck_number():
|
||||
var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
|
||||
var d := p.to_dict()
|
||||
assert_eq(d["race_id"], "beastfolk")
|
||||
assert_eq(d["calling_id"], "cutpurse")
|
||||
assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
|
||||
assert_false("class_id" in d)
|
||||
assert_eq(d.keys().size(), 4)
|
||||
|
||||
|
||||
func test_player_rejects_unknown_class():
|
||||
var p = LogPlayer.new("Aldric", "sellsword", "x")
|
||||
assert_false(p.set_class_id("bard"))
|
||||
assert_eq(p.class_id, "sellsword")
|
||||
func test_log_player_rejects_a_dead_calling():
|
||||
var p := LogPlayer.new()
|
||||
assert_false(p.set_calling_id("priest"), "priest is not a calling")
|
||||
assert_true(p.set_calling_id("bonesetter"))
|
||||
assert_false(p.set_race_id("orc"))
|
||||
assert_true(p.set_race_id("dwarf"))
|
||||
|
||||
|
||||
func test_log_player_ctor_rejects_calling_passed_as_race():
|
||||
# The exact shape of the bug: calling three broken call sites used to pass —
|
||||
# LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor
|
||||
# (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the
|
||||
# race_id slot (rejected — not a race), the descriptor lands in the
|
||||
# calling_id slot (rejected — not a calling), and luck_descriptor is left
|
||||
# at its default. Every field ends up "", which 422s against the schema.
|
||||
var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
||||
var d := p.to_dict()
|
||||
assert_eq(d["race_id"], "", "a calling id is not a legal race_id")
|
||||
assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id")
|
||||
assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row")
|
||||
|
||||
|
||||
func test_party_member_clamps_disposition():
|
||||
|
||||
@@ -3,6 +3,12 @@ extends "res://addons/gut/test.gd"
|
||||
const GameState = preload("res://scripts/state/game_state.gd")
|
||||
|
||||
|
||||
func test_state_holds_a_sheet_not_a_stat_bag():
|
||||
var gs = GameState.new()
|
||||
assert_null(gs.sheet, "the sheet is built by NewGame.construct")
|
||||
assert_false("stats" in gs, "the flat stats Dictionary is gone")
|
||||
|
||||
|
||||
func test_add_item_accumulates():
|
||||
var s = GameState.new()
|
||||
s.add_item("worn_shortsword", 3)
|
||||
|
||||
@@ -35,3 +35,15 @@ func test_shell_builds_a_code_owned_seed_log():
|
||||
var node := _shell()
|
||||
assert_not_null(node._log, "a canon log exists to post (§11)")
|
||||
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
|
||||
|
||||
|
||||
func test_shell_seed_player_has_a_real_race_and_calling():
|
||||
# Regression: the shell's _build_seed_log() once called LogPlayer.new() with
|
||||
# the OLD 3-arg (name, class_id, luck_descriptor) shape against the NEW
|
||||
# 4-arg ctor. Every param defaults, so it compiled silently and both
|
||||
# race_id/calling_id came out "" — which 422s against the canon-log schema.
|
||||
# Assert against the log the shell actually builds, not a fresh LogPlayer.
|
||||
var node := _shell()
|
||||
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
|
||||
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
|
||||
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
|
||||
|
||||
@@ -15,31 +15,212 @@ func _deserter() -> Dictionary:
|
||||
return ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
|
||||
|
||||
func _rng() -> RandomNumberGenerator:
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 7
|
||||
return r
|
||||
func _creation(overrides := {}) -> Dictionary:
|
||||
var c := {
|
||||
"name": "Aldric",
|
||||
"race_id": "human",
|
||||
"calling_id": "sellsword",
|
||||
"seed": 8675309,
|
||||
"spend": {},
|
||||
"skills": ["athletics", "endurance"],
|
||||
"bonus_skill": "perception",
|
||||
}
|
||||
for k in overrides:
|
||||
c[k] = overrides[k]
|
||||
return c
|
||||
|
||||
|
||||
func _build(creation: Dictionary) -> Dictionary:
|
||||
return NewGame.construct(_deserter(), world, creation, _rng())
|
||||
return NewGame.construct(_deserter(), world, creation)
|
||||
|
||||
|
||||
func test_construct_succeeds():
|
||||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||||
func test_same_seed_yields_an_identical_character():
|
||||
# The determinism guarantee (§10). Two independent constructs, one seed.
|
||||
var a := _build(_creation())
|
||||
var b := _build(_creation())
|
||||
assert_true(a["ok"] and b["ok"])
|
||||
assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
|
||||
assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")
|
||||
|
||||
|
||||
func test_golden_vector_pins_the_rng_stream():
|
||||
# The determinism test above (`test_same_seed_yields_an_identical_character`)
|
||||
# compares two `construct` runs against EACH OTHER. Both would drift together
|
||||
# if the roll order changed, so it proves nothing about the ORDER itself.
|
||||
# Swap two entries in `Attributes.IDS` and that test still passes while every
|
||||
# already-persisted seed silently becomes a different character.
|
||||
#
|
||||
# This test pins one seed to its EXACT rolled values — all five attributes and
|
||||
# the hidden Luck, drawn from the same stream immediately after — so reordering
|
||||
# `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of
|
||||
# silently rewriting every saved character.
|
||||
var res := _build(_creation({"seed": 8675309}))
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
var attrs: Dictionary = res["state"].sheet.attributes
|
||||
assert_eq(int(attrs["str"]), 8)
|
||||
assert_eq(int(attrs["dex"]), 13)
|
||||
assert_eq(int(attrs["con"]), 8)
|
||||
assert_eq(int(attrs["fth"]), 14)
|
||||
assert_eq(int(attrs["mag"]), 8)
|
||||
assert_eq(int(res["state"].luck), 6)
|
||||
|
||||
|
||||
func test_numeric_luck_absent_from_log():
|
||||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||||
func test_a_missing_seed_is_rejected():
|
||||
# A seed silently defaulting to 0 builds a perfectly valid, deterministic
|
||||
# character — which is exactly the problem: a saga that forgets the key
|
||||
# gets no signal at all. Require it explicitly.
|
||||
var creation := _creation()
|
||||
creation.erase("seed")
|
||||
var res := NewGame.construct(_deserter(), world, creation)
|
||||
assert_false(res["ok"])
|
||||
assert_true("seed" in str(res["errors"]).to_lower())
|
||||
|
||||
|
||||
func test_a_non_integer_seed_is_rejected():
|
||||
var res := _build(_creation({"seed": "8675309"}))
|
||||
assert_false(res["ok"])
|
||||
assert_true("seed" in str(res["errors"]).to_lower())
|
||||
|
||||
|
||||
func test_a_different_seed_yields_a_different_character():
|
||||
var a := _build(_creation())
|
||||
var b := _build(_creation({"seed": 1234567}))
|
||||
assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)
|
||||
|
||||
|
||||
func test_every_attribute_respects_the_floor_of_eight():
|
||||
# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
|
||||
for s in range(40):
|
||||
var res := _build(_creation({"seed": s}))
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
for stat in Attributes.IDS:
|
||||
assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
|
||||
"seed %d rolled %s below the floor" % [s, stat])
|
||||
|
||||
|
||||
func test_spend_is_additive_on_top_of_the_roll():
|
||||
var base := _build(_creation())
|
||||
var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
|
||||
assert_eq(int(spent["state"].sheet.attributes["str"]),
|
||||
int(base["state"].sheet.attributes["str"]) + 2)
|
||||
assert_eq(int(spent["state"].sheet.attributes["con"]),
|
||||
int(base["state"].sheet.attributes["con"]) + 1)
|
||||
assert_eq(int(spent["state"].sheet.attributes["dex"]),
|
||||
int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")
|
||||
|
||||
|
||||
func test_spend_over_the_pool_is_rejected():
|
||||
var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
|
||||
assert_false(res["ok"])
|
||||
assert_true("spend" in str(res["errors"]).to_lower())
|
||||
|
||||
|
||||
func test_spend_cannot_be_negative():
|
||||
var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
|
||||
assert_false(res["ok"], "additive only — there is no [-]")
|
||||
|
||||
|
||||
func test_a_negative_spend_does_not_mask_an_over_pool_spend():
|
||||
# {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a
|
||||
# `continue`/floor after the negative-value error, the over-pool check
|
||||
# never independently fires; only the negative-value error does.
|
||||
var res := _build(_creation({"spend": {"str": -1, "dex": 4}}))
|
||||
assert_false(res["ok"])
|
||||
var joined := str(res["errors"]).to_lower()
|
||||
assert_true("negative" in joined, "the negative-value error should fire")
|
||||
assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire")
|
||||
|
||||
|
||||
func test_spend_rejects_non_integer_values():
|
||||
var floats := _build(_creation({"spend": {"str": 2.9}}))
|
||||
assert_false(floats["ok"], "a float must be rejected, not truncated to +2")
|
||||
|
||||
var bools := _build(_creation({"spend": {"str": true}}))
|
||||
assert_false(bools["ok"], "a bool must be rejected, not coerced to +1")
|
||||
|
||||
var strings := _build(_creation({"spend": {"str": "abc"}}))
|
||||
assert_false(strings["ok"], "a string must be rejected, not coerced to +0")
|
||||
|
||||
|
||||
func test_luck_can_never_be_spent():
|
||||
# §7: LCK is untouchable at creation. Not hidden — impossible.
|
||||
var res := _build(_creation({"spend": {"lck": 3}}))
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_skills_must_come_from_the_callings_pool():
|
||||
var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
|
||||
assert_false(res["ok"], "sorcery is not in the sellsword's pool")
|
||||
|
||||
|
||||
func test_skills_must_be_the_right_count():
|
||||
var res := _build(_creation({"skills": ["athletics"]}))
|
||||
assert_false(res["ok"], "a sellsword picks 2")
|
||||
|
||||
|
||||
func test_skills_reject_a_duplicate():
|
||||
var res := _build(_creation({"skills": ["athletics", "athletics"]}))
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_an_elf_cannot_spend_a_pick_on_perception():
|
||||
# He already has it. A pick that buys nothing is a trap, not a choice.
|
||||
var res := _build(_creation({
|
||||
"race_id": "elf", "bonus_skill": "",
|
||||
"skills": ["athletics", "perception"]}))
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_the_elf_is_granted_perception_anyway():
|
||||
var res := _build(_creation({
|
||||
"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
assert_true(res["state"].sheet.is_proficient("perception"))
|
||||
|
||||
|
||||
func test_a_human_must_choose_a_bonus_skill():
|
||||
var res := _build(_creation({"bonus_skill": ""}))
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_a_non_human_must_not_have_one():
|
||||
var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_the_humans_bonus_skill_confers_proficiency():
|
||||
var res := _build(_creation()) # human, bonus_skill perception
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
assert_true(res["state"].sheet.is_proficient("perception"))
|
||||
|
||||
|
||||
func test_the_character_starts_whole():
|
||||
var res := _build(_creation())
|
||||
var sheet = res["state"].sheet
|
||||
assert_eq(sheet.hp, sheet.max_hp())
|
||||
assert_eq(sheet.mp, sheet.max_mp())
|
||||
|
||||
|
||||
func test_a_calling_the_origin_forbids_is_rejected():
|
||||
var o := _deserter()
|
||||
o["build_constraints"] = o["build_constraints"].duplicate(true)
|
||||
o["build_constraints"]["allowed_callings"] = ["bonesetter"]
|
||||
var res := NewGame.construct(o, world, _creation())
|
||||
assert_false(res["ok"])
|
||||
|
||||
|
||||
func test_no_luck_number_and_no_attributes_reach_the_log():
|
||||
# §7 + §11: the log carries only what the AI narrates from.
|
||||
var res := _build(_creation())
|
||||
var pd: Dictionary = res["log"].player.to_dict()
|
||||
assert_eq(pd.keys().size(), 4)
|
||||
assert_false("luck" in pd)
|
||||
assert_eq(pd.keys().size(), 3)
|
||||
assert_false("attributes" in pd)
|
||||
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"})
|
||||
var res := _build(_creation())
|
||||
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")
|
||||
@@ -47,20 +228,20 @@ func test_inventory_and_dispo_land_in_state_not_log():
|
||||
|
||||
|
||||
func test_companion_dispositions_from_overrides():
|
||||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||||
var res := _build(_creation())
|
||||
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"})
|
||||
var res := _build(_creation())
|
||||
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"})
|
||||
var res := _build(_creation())
|
||||
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")
|
||||
@@ -69,42 +250,50 @@ func test_active_quest_resolved_from_world():
|
||||
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())
|
||||
var res := NewGame.construct(o, world, _creation())
|
||||
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())
|
||||
var res := NewGame.construct(o, world, _creation())
|
||||
assert_false(res["ok"])
|
||||
assert_true("unresolved ref: location:nowhere" in res["errors"])
|
||||
|
||||
|
||||
func test_empty_name_is_rejected():
|
||||
var missing_name := _build({"class_id": "sellsword"})
|
||||
var missing_name := _build(_creation({"name": ""}))
|
||||
assert_false(missing_name["ok"])
|
||||
assert_true(missing_name["errors"].size() > 0)
|
||||
assert_null(missing_name["log"])
|
||||
|
||||
var whitespace_name := _build({"name": " ", "class_id": "sellsword"})
|
||||
var whitespace_name := _build(_creation({"name": " "}))
|
||||
assert_false(whitespace_name["ok"])
|
||||
assert_true(whitespace_name["errors"].size() > 0)
|
||||
assert_null(whitespace_name["log"])
|
||||
|
||||
|
||||
func test_a_null_spend_is_rejected_not_crashed():
|
||||
# A JSON round-trip can hand back {"spend": null}. Without a container-type
|
||||
# guard, assigning null into a statically-typed Dictionary parameter throws
|
||||
# a GDScript runtime error INSIDE construct() instead of a front-loaded error.
|
||||
var res := _build(_creation({"spend": null}))
|
||||
assert_false(res["ok"])
|
||||
assert_true("spend" in str(res["errors"]).to_lower())
|
||||
|
||||
|
||||
func test_non_array_skills_is_rejected_not_crashed():
|
||||
var res := _build(_creation({"skills": "athletics"}))
|
||||
assert_false(res["ok"])
|
||||
assert_true("skills" in str(res["errors"]).to_lower())
|
||||
|
||||
|
||||
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", "class_id": "sellsword"}, _rng())
|
||||
var res := NewGame.construct(o, world, _creation())
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
|
||||
assert_null(res["log"].party_member("oda_fenn"))
|
||||
|
||||
@@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd")
|
||||
func _built_log():
|
||||
var world = ContentDB.new()
|
||||
world.load_from(ContentDB.default_content_root())
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 3
|
||||
var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||
var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng)
|
||||
var creation := {
|
||||
"name": "Aldric",
|
||||
"race_id": "human",
|
||||
"calling_id": "bonesetter",
|
||||
"seed": 3,
|
||||
"spend": {},
|
||||
"skills": ["faith_lore", "perception"],
|
||||
"bonus_skill": "endurance",
|
||||
}
|
||||
var res := NewGame.construct(origin, world, creation)
|
||||
assert_true(res["ok"], str(res["errors"]))
|
||||
return res["log"]
|
||||
|
||||
@@ -28,9 +35,10 @@ func test_constructed_log_satisfies_contract_invariants():
|
||||
var d: Dictionary = _built_log().to_dict()
|
||||
assert_eq(d["schema_version"], 1)
|
||||
assert_true(d["recent_events"].size() <= 5)
|
||||
assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor
|
||||
assert_eq(d["player"].keys().size(), 4) # exactly name/race_id/calling_id/luck_descriptor
|
||||
assert_false("luck" in d["player"]) # §7: no numeric luck
|
||||
assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"])
|
||||
assert_true(d["player"]["calling_id"] in Callings.IDS)
|
||||
assert_true(d["player"]["race_id"] in Races.IDS)
|
||||
for m in d["party"]:
|
||||
assert_true(m["disposition"] >= -100 and m["disposition"] <= 100)
|
||||
for q in d["active_quests"]:
|
||||
|
||||
97
client/tests/unit/test_rules_tables.gd
Normal file
97
client/tests/unit/test_rules_tables.gd
Normal file
@@ -0,0 +1,97 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
|
||||
func test_modifier_at_the_boundaries():
|
||||
assert_eq(Attributes.modifier(7), -2)
|
||||
assert_eq(Attributes.modifier(8), -1)
|
||||
assert_eq(Attributes.modifier(9), -1)
|
||||
assert_eq(Attributes.modifier(10), 0)
|
||||
assert_eq(Attributes.modifier(11), 0)
|
||||
assert_eq(Attributes.modifier(12), 1)
|
||||
assert_eq(Attributes.modifier(18), 4)
|
||||
|
||||
|
||||
func test_the_five_attributes_exclude_luck():
|
||||
assert_eq(Attributes.IDS, ["str", "dex", "con", "fth", "mag"])
|
||||
assert_false("lck" in Attributes.IDS, "LCK is never an attribute row (§7)")
|
||||
|
||||
|
||||
func test_nine_skills_each_governed_by_an_attribute():
|
||||
assert_eq(Skills.IDS.size(), 9)
|
||||
for s in Skills.IDS:
|
||||
assert_true(Skills.attribute(s) in Attributes.IDS, "%s has no governing attribute" % s)
|
||||
|
||||
|
||||
func test_perception_is_faith_and_sorcery_is_magic():
|
||||
assert_eq(Skills.attribute("perception"), "fth")
|
||||
assert_eq(Skills.attribute("sorcery"), "mag")
|
||||
assert_false(Skills.exists("lockpicking"))
|
||||
|
||||
|
||||
func test_four_races():
|
||||
assert_eq(Races.IDS, ["human", "elf", "dwarf", "beastfolk"])
|
||||
|
||||
|
||||
func test_human_gets_a_save_bonus_and_a_skill_choice():
|
||||
assert_eq(Races.save_bonus("human"), 1)
|
||||
assert_true(Races.wants_bonus_skill("human"))
|
||||
assert_eq(Races.granted_skills("human"), [])
|
||||
|
||||
|
||||
func test_elf_is_granted_perception_and_nightsight():
|
||||
assert_eq(Races.granted_skills("elf"), ["perception"])
|
||||
assert_true(Races.flag("elf", "nightsight"))
|
||||
assert_false(Races.wants_bonus_skill("elf"))
|
||||
|
||||
|
||||
func test_dwarf_poison_bonus_is_situational_not_a_save_bonus():
|
||||
assert_eq(Races.poison_save_bonus("dwarf"), 2)
|
||||
assert_eq(Races.save_bonus("dwarf"), 0, "the +2 is vs poison only — it is NOT a flat save bonus")
|
||||
|
||||
|
||||
func test_beastfolk_has_claws_and_scent():
|
||||
assert_true(Races.flag("beastfolk", "claws"))
|
||||
assert_true(Races.flag("beastfolk", "keen_scent"))
|
||||
|
||||
|
||||
func test_seven_callings():
|
||||
assert_eq(Callings.IDS,
|
||||
["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"])
|
||||
|
||||
|
||||
func test_hit_dice():
|
||||
assert_eq(Callings.hit_die("reaver"), 12)
|
||||
assert_eq(Callings.hit_die("sellsword"), 10)
|
||||
assert_eq(Callings.hit_die("hedge_mage"), 6)
|
||||
|
||||
|
||||
func test_casters_have_a_casting_stat_and_martials_do_not():
|
||||
for id in ["hedge_mage", "bonesetter", "bloodsworn"]:
|
||||
assert_true(Callings.is_caster(id), "%s is a caster" % id)
|
||||
assert_true(Callings.casting_stat(id) in Attributes.IDS)
|
||||
for id in ["sellsword", "reaver", "cutpurse", "trapper"]:
|
||||
assert_false(Callings.is_caster(id), "%s lives on cooldowns" % id)
|
||||
assert_eq(Callings.casting_stat(id), "")
|
||||
|
||||
|
||||
func test_cutpurse_picks_four_skills_trapper_three_rest_two():
|
||||
assert_eq(Callings.skill_count("cutpurse"), 4)
|
||||
assert_eq(Callings.skill_count("trapper"), 3)
|
||||
for id in ["sellsword", "reaver", "hedge_mage", "bonesetter", "bloodsworn"]:
|
||||
assert_eq(Callings.skill_count(id), 2, "%s picks 2" % id)
|
||||
|
||||
|
||||
func test_every_skill_pool_is_real_skills_and_big_enough_to_choose_from():
|
||||
for id in Callings.IDS:
|
||||
var pool: Array = Callings.skill_pool(id)
|
||||
for s in pool:
|
||||
assert_true(Skills.exists(s), "%s pool has a bogus skill: %s" % [id, s])
|
||||
assert_true(pool.size() > Callings.skill_count(id),
|
||||
"%s must have more pool than picks or the choice is fake" % id)
|
||||
|
||||
|
||||
func test_every_calling_saves_are_real_attributes():
|
||||
for id in Callings.IDS:
|
||||
assert_eq(Callings.saves(id).size(), 2)
|
||||
for s in Callings.saves(id):
|
||||
assert_true(s in Attributes.IDS)
|
||||
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
1
client/tests/unit/test_rules_tables.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bjld6ctq8tqnk
|
||||
53
client/tests/unit/test_schema_parity.gd
Normal file
53
client/tests/unit/test_schema_parity.gd
Normal file
@@ -0,0 +1,53 @@
|
||||
extends "res://addons/gut/test.gd"
|
||||
## The canon log crosses the HTTP boundary, so its roster exists twice: in code
|
||||
## (Callings.IDS / Races.IDS) and in the JSON Schema the API validates against.
|
||||
## Nothing at runtime reconciles them. This does.
|
||||
|
||||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||||
|
||||
|
||||
func _player_schema() -> Dictionary:
|
||||
var path := ProjectSettings.globalize_path("res://") \
|
||||
.path_join("../docs/schemas/canon-log.schema.json").simplify_path()
|
||||
var doc: Variant = ContentDB.load_json(path)
|
||||
assert_eq(typeof(doc), TYPE_DICTIONARY, "canon-log schema did not parse")
|
||||
return doc["properties"]["player"]
|
||||
|
||||
|
||||
func test_schema_calling_enum_matches_the_code():
|
||||
var enum_ids: Array = _player_schema()["properties"]["calling_id"]["enum"]
|
||||
enum_ids.sort()
|
||||
var code_ids: Array = Callings.IDS.duplicate()
|
||||
code_ids.sort()
|
||||
assert_eq(enum_ids, code_ids)
|
||||
|
||||
|
||||
func test_schema_race_enum_matches_the_code():
|
||||
var enum_ids: Array = _player_schema()["properties"]["race_id"]["enum"]
|
||||
enum_ids.sort()
|
||||
var code_ids: Array = Races.IDS.duplicate()
|
||||
code_ids.sort()
|
||||
assert_eq(enum_ids, code_ids)
|
||||
|
||||
|
||||
func test_schema_requires_race_and_calling():
|
||||
var required: Array = _player_schema()["required"]
|
||||
assert_true("race_id" in required)
|
||||
assert_true("calling_id" in required)
|
||||
assert_false("class_id" in required, "class_id is the rulebook's word — it is gone")
|
||||
|
||||
|
||||
func test_origin_schema_allowed_callings_matches_the_code():
|
||||
# origin.schema.json's build_constraints.allowed_callings duplicates the
|
||||
# same seven calling ids inline (a JSON Schema "enum" cannot $ref another
|
||||
# file's enum), so nothing at runtime reconciles it with Callings.IDS
|
||||
# either. This guards it the same way _player_schema() guards canon-log.
|
||||
var path := ProjectSettings.globalize_path("res://") \
|
||||
.path_join("../docs/schemas/origin.schema.json").simplify_path()
|
||||
var doc: Variant = ContentDB.load_json(path)
|
||||
assert_eq(typeof(doc), TYPE_DICTIONARY, "origin schema did not parse")
|
||||
var enum_ids: Array = doc["properties"]["build_constraints"]["properties"]["allowed_callings"]["items"]["enum"]
|
||||
enum_ids.sort()
|
||||
var code_ids: Array = Callings.IDS.duplicate()
|
||||
code_ids.sort()
|
||||
assert_eq(enum_ids, code_ids)
|
||||
1
client/tests/unit/test_schema_parity.gd.uid
Normal file
1
client/tests/unit/test_schema_parity.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dond21vwh46rt
|
||||
@@ -19,7 +19,8 @@
|
||||
],
|
||||
"start_quest_id": "find_the_ledger",
|
||||
"build_constraints": {
|
||||
"allowed_classes": ["sellsword", "assassin", "priest"],
|
||||
"allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
|
||||
"hedge_mage", "bonesetter", "bloodsworn"],
|
||||
"luck_modifier": 0
|
||||
}
|
||||
}
|
||||
|
||||
1
content/world/callings/bloodsworn.json
Normal file
1
content/world/callings/bloodsworn.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/callings/bonesetter.json
Normal file
1
content/world/callings/bonesetter.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/callings/cutpurse.json
Normal file
1
content/world/callings/cutpurse.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/callings/hedge_mage.json
Normal file
1
content/world/callings/hedge_mage.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/callings/reaver.json
Normal file
1
content/world/callings/reaver.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/callings/sellsword.json
Normal file
1
content/world/callings/sellsword.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }
|
||||
1
content/world/callings/trapper.json
Normal file
1
content/world/callings/trapper.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/races/beastfolk.json
Normal file
1
content/world/races/beastfolk.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/races/dwarf.json
Normal file
1
content/world/races/dwarf.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/races/elf.json
Normal file
1
content/world/races/elf.json
Normal file
@@ -0,0 +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." }
|
||||
1
content/world/races/human.json
Normal file
1
content/world/races/human.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }
|
||||
@@ -27,7 +27,7 @@ Full field rules live in `canon-log.schema.json`. Summary:
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" },
|
||||
"player": { "name": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "luck_descriptor": "…" },
|
||||
"location": { "id": "…", "name": "…" },
|
||||
"party": [ { "id": "…", "name": "…", "disposition": -100..100 } ],
|
||||
"recent_events": ["… (≤5, rolling)"],
|
||||
@@ -39,9 +39,13 @@ Full field rules live in `canon-log.schema.json`. Summary:
|
||||
|
||||
**The §7 boundary (enforced structurally).** The schema sets
|
||||
`additionalProperties: false` on the root and on `player`, and `player` admits
|
||||
only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and
|
||||
inventory cannot be represented in the canon log** — the AI never sees a number
|
||||
it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
|
||||
only `name`, `race_id`, `calling_id`, `luck_descriptor`. `race_id` and
|
||||
`calling_id` are there so the Narrator and NPCs can *describe* the player (a
|
||||
"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") —
|
||||
they are identity for prose, not mechanics. **Numeric Luck, stats, HP/MP, and
|
||||
inventory cannot be represented in the canon log** — the mechanical sheet stays
|
||||
in `GameState` and never reaches the log, and the AI never sees a number it
|
||||
could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
|
||||
you") crosses the boundary.
|
||||
|
||||
All string ids match `^[a-z0-9_]+$`.
|
||||
@@ -59,7 +63,7 @@ Full rules in `origin.schema.json`. An origin seeds the initial log:
|
||||
"disposition_overrides": { "<npc_id>": -100..100 },
|
||||
"inventory_grants": [ { "item_id": "…", "qty": 1.. } ],
|
||||
"start_quest_id": "…|null",
|
||||
"build_constraints": { "allowed_classes": ["sellsword",…], "luck_modifier": 0 }
|
||||
"build_constraints": { "allowed_callings": ["sellsword",…], "luck_modifier": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
|
||||
|
||||
### 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.***
|
||||
- ○ **Character creation UI** — 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). **Two contract migrations land here, not silently:** the canon log gains a `race` field (the Narrator needs it to describe the player), and `content/origins/deserter.json` still carries the dead `allowed_classes: ["sellsword","assassin","priest"]`. **⛨ 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). Emit `race_id` + `calling_id` into it. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
||||
- ✅ **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.*
|
||||
- ○ **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)
|
||||
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
"player": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "class_id", "luck_descriptor"],
|
||||
"required": ["name", "race_id", "calling_id", "luck_descriptor"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"class_id": { "enum": ["sellsword", "assassin", "priest"] },
|
||||
"race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] },
|
||||
"calling_id": {
|
||||
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
|
||||
"hedge_mage", "bonesetter", "bloodsworn"]
|
||||
},
|
||||
"luck_descriptor": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -39,12 +39,15 @@
|
||||
"build_constraints": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["allowed_classes", "luck_modifier"],
|
||||
"required": ["allowed_callings", "luck_modifier"],
|
||||
"properties": {
|
||||
"allowed_classes": {
|
||||
"allowed_callings": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "enum": ["sellsword", "assassin", "priest"] }
|
||||
"items": {
|
||||
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
|
||||
"hedge_mage", "bonesetter", "bloodsworn"]
|
||||
}
|
||||
},
|
||||
"luck_modifier": { "type": "integer" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user