class_name CreationCopy extends RefCounted ## Display strings for the creation screen, DERIVED from the rules tables. ## ## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads ## both and repeats neither. A hit die written into a JSON — or typed into a string ## literal here — is a second source of truth, and it will eventually disagree with ## the table. Every number below is read from Callings/Races/Skills at call time, so ## retuning a calling retunes its card. ## ## §7: nothing here may mention Luck, in any form. §2: presentation only. const ARMOR_WORD := { "none": "no armour", "light": "light armour", "medium": "medium armour", "heavy": "heavy armour", } ## The one line the player reads when the draft will not hold. §13: a fallback is ## CONTENT, not error handling — so even an error nobody anticipated speaks in the ## DM's voice rather than showing him a validator's grammar. const UNSPOKEN := "something in this does not hold — look again" const NUMBER_WORD := { 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine", } static func calling_detail(id: String) -> String: ## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab" if not Callings.exists(id): return "" var saves: Array = [] for s in Callings.saves(id): saves.append(s.to_upper()) return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [ Callings.hit_die(id), armor_word(id), resource_word(id), " + ".join(saves), Callings.skill_count(id), talent_word(id), ] static func talent_word(id: String) -> String: ## second_wind -> "second wind". Four of the seven talent IDS carry an ## underscore, and the card printed the raw id: the player read "talent ## second_wind" off the calling card. Ids are snake_case; humans are not — the ## same reason skill_label() exists. Read from the table at call time, so ## retuning a talent retunes the card. return Callings.talent(id).replace("_", " ") static func armor_word(id: String) -> String: ## The card says "no armour", not "none". Public so the test can assert against ## the DERIVED word rather than the raw table value — "none" is not a substring ## of "no armour", and asserting on the raw value only ever passed by accident. return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id)) static func resource_word(id: String) -> String: ## Martials live on cooldowns; casters on a pool off their casting stat. if not Callings.is_caster(id): return "cooldowns" return "MP (%s)" % Callings.casting_stat(id).to_upper() static func race_trait(id: String) -> String: ## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight" if not Races.exists(id): return "" var parts: Array = [] if Races.save_bonus(id) > 0: parts.append("+%d to every save" % Races.save_bonus(id)) for s in Races.granted_skills(id): parts.append(skill_label(s).capitalize()) if Races.poison_save_bonus(id) > 0: parts.append("Poison resist") if Races.flag(id, "nightsight"): parts.append("Nightsight") if Races.flag(id, "claws"): parts.append("Claws") if Races.flag(id, "keen_scent"): parts.append("Keen scent") if Races.wants_bonus_skill(id): parts.append("+1 skill of choice") return " · ".join(parts) static func skill_label(skill: String) -> String: ## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not. return skill.replace("_", " ") # ------------------------------------------------------------------- §13: the nag static func error_line(error: String, draft: CreationDraft) -> String: ## The validator's diagnostics are written for whoever is reading the errors ## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are ## a CONTRACT (other code reads those strings), so they are not rewritten; they ## are TRANSLATED, here, in the presentation layer, where translating them ## belongs. What the player reads on the second screen of the game is authored ## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13). ## ## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank. if error.begins_with("player name is required"): return "name yourself" if error.begins_with("unknown race"): return "decide what blood you carry" if error.begins_with("unknown calling"): return "decide what you do for coin" if error.begins_with("calling not allowed by origin"): return "that trade is closed to you — you left it behind" if error.contains(" picks ") and error.contains(" skills, got "): return _proficiency_line(draft) if error.begins_with("duplicate skill pick"): return "you have taken the same proficiency twice" if error.contains(" is not in the ") and error.ends_with(" pool"): return "your calling never taught you that" if error.contains(" is already granted by "): return "you have that one already — it buys you nothing" if error.contains(" must choose a bonus skill"): return "one more proficiency, any of them — take it" if error.begins_with("unknown bonus skill"): return "no one here would call that a skill" if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"): return "you have that one already — take another" if error.contains(" does not get a bonus skill"): return "your blood grants no proficiency of its own" return UNSPOKEN static func _proficiency_line(draft: CreationDraft) -> String: ## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count ## is the rules table's business and the copy asks it, every time, rather than ## carrying a second copy of it that would one day disagree. var left: int = draft.picks_left() if left <= 0: return "you have taken more proficiencies than your calling can carry" if left == 1: return "choose one more proficiency" return "choose %s more proficiencies" % number_word(left) static func number_word(n: int) -> String: return str(NUMBER_WORD.get(n, n))