refactor(contract)!: class_id -> calling_id, and the log gains race_id

class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.

race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').

LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.

The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.

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:
2026-07-12 20:27:56 -05:00
parent bb5d176262
commit a668cf98eb
16 changed files with 171 additions and 61 deletions

View File

@@ -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", ""))