Stats: stat sheets, formulas, archetypes, traits, stat pools, actions, conditions and sheet panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c6e53dedd3
66 files changed
+3976
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||
* text=auto eol=lf
|
||||
|
||||
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binaries: never converted, never diffed as text.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.svg text
|
||||
*.ogg binary
|
||||
*.wav binary
|
||||
*.mp3 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.pdb binary
|
||||
*.exe binary
|
||||
*.zip binary
|
||||
*.pck binary
|
||||
*.moc3 binary
|
||||
*.res binary
|
||||
*.scn binary
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Godot 4.x
|
||||
.godot/
|
||||
.idea/
|
||||
sdk/
|
||||
bin/
|
||||
obj/
|
||||
|
||||
#ai
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
gen/
|
||||
obj/
|
||||
bin/
|
||||
|
||||
*.tres~
|
||||
*.tscn~
|
||||
|
||||
# GDScript LSP
|
||||
.godot/
|
||||
|
||||
# Export
|
||||
*.exe
|
||||
*.dmg
|
||||
*.apk
|
||||
*.aab
|
||||
*.pck
|
||||
*.zip
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
*.import
|
||||
*.godot.uid
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = Stats
|
||||
mod_stats_base_desc = A stats engine for any genre: stats as data (strength, charisma, health, sanity — there is no built-in list), archetypes (RPG classes or a novel's temperaments) with per-level formulas and an exp curve, traits — modifiers any entity may carry (an item, a character, a status). Formulas for derived stats and battles, a character sheet, levels from exp. Bound stats are written into the battle attributes, so every battle core uses them unchanged.
|
||||
stats/level_up = {0}: level {1}!
|
||||
stats/learned = {0} learned {1}
|
||||
stats/sheet/title = Character sheet
|
||||
stats/sheet/party = Party
|
||||
stats/sheet/close = Close
|
||||
stats/sheet/level = Level {0}
|
||||
stats/sheet/level_short = lv {0}
|
||||
stats/sheet/level_exp = Level {0} · exp {1} of {2}
|
||||
stats/sheet/no_archetype = No archetype
|
||||
stats/sheet/empty = This game has no stats: no mod defines any.
|
||||
stats/group/attributes = Attributes
|
||||
stats/group/combat = Combat
|
||||
stats/group/social = Social
|
||||
stats/schema/stats/add = Change a stat for good
|
||||
stats/schema/stats/add_desc = Training, a potion, a choice that made the hero braver. A minus lowers it.
|
||||
stats/schema/field/stat = Stat
|
||||
stats/schema/field/value = Value
|
||||
stats/schema/field/who = Who
|
||||
stats/schema/stats/archetype = Give an archetype
|
||||
stats/schema/stats/archetype_desc = A class or a temperament — the prologue's "who were you?" choice
|
||||
stats/schema/field/archetype = Archetype
|
||||
stats/schema/stats/exp = Give exp
|
||||
stats/schema/stats/exp_desc = A quest reward or a lesson; levels follow at once
|
||||
stats/schema/stats/restore = Restore
|
||||
stats/schema/stats/restore_desc = An inn, a fountain, a good night's sleep: health and pools
|
||||
stats/schema/stats/restore_stat = health, mp… — empty: everything
|
||||
stats/schema/kind/stat = Stats
|
||||
stats/schema/kind/stat_desc = What the game counts: attributes, health, charisma
|
||||
stats/schema/field/name = Name
|
||||
stats/schema/field/short = Short label
|
||||
stats/schema/field/description = Description
|
||||
stats/schema/field/group = Group
|
||||
stats/schema/field/order = Order
|
||||
stats/schema/field/default = Default
|
||||
stats/schema/field/formula = Formula
|
||||
stats/schema/field/formula_desc = For example: 30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = Minimum
|
||||
stats/schema/field/max = Maximum
|
||||
stats/schema/field/pool = Pool
|
||||
stats/schema/field/pool_desc = Spent and refilled, like mana
|
||||
stats/schema/field/binds = Battle attribute
|
||||
stats/schema/field/binds_desc = The value goes into any battle core's health, attack, shield or points
|
||||
stats/schema/field/hidden = Hidden
|
||||
stats/schema/kind/archetype = Archetypes
|
||||
stats/schema/kind/archetype_desc = RPG classes or a novel's temperaments: stat growth, traits, skills
|
||||
stats/schema/field/stats = Own stats
|
||||
stats/schema/field/stats_desc = Stat → a number or a formula of the level and other stats
|
||||
stats/schema/field/traits = Traits
|
||||
stats/schema/field/traits_desc = Stat modifiers: a flat amount, a percent or a formula
|
||||
stats/schema/field/add = Add
|
||||
stats/schema/field/percent = Percent
|
||||
stats/schema/field/learnings = Skills by level
|
||||
stats/schema/field/level = Level
|
||||
stats/schema/field/action = Action
|
||||
stats/schema/field/exp_curve = Exp curve
|
||||
stats/schema/field/exp_curve_desc = Total exp needed for a level, e.g. 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = Level cap
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = Estadísticas
|
||||
mod_stats_base_desc = Un motor de estadísticas para cualquier género: estadísticas como datos (fuerza, carisma, salud, cordura — no hay lista fija), arquetipos (clases de RPG o temperamentos de una novela) con fórmulas por nivel y curva de experiencia, rasgos — modificadores que puede llevar cualquier entidad (un objeto, un personaje, un estado). Fórmulas para estadísticas derivadas y combates, hoja de personaje, niveles por experiencia. Las estadísticas vinculadas se escriben en los atributos de combate, así que cualquier núcleo de combate las usa sin cambios.
|
||||
stats/level_up = {0}: ¡nivel {1}!
|
||||
stats/learned = {0} aprende: {1}
|
||||
stats/sheet/title = Hoja de personaje
|
||||
stats/sheet/party = Grupo
|
||||
stats/sheet/close = Cerrar
|
||||
stats/sheet/level = Nivel {0}
|
||||
stats/sheet/level_short = nv. {0}
|
||||
stats/sheet/level_exp = Nivel {0} · exp. {1} de {2}
|
||||
stats/sheet/no_archetype = Sin arquetipo
|
||||
stats/sheet/empty = Este juego no tiene estadísticas: ningún mod define ninguna.
|
||||
stats/group/attributes = Atributos
|
||||
stats/group/combat = Combate
|
||||
stats/group/social = Social
|
||||
stats/schema/stats/add = Cambiar una estadística para siempre
|
||||
stats/schema/stats/add_desc = Un entrenamiento, una poción, una decisión que hizo al héroe más valiente. Un menos la baja.
|
||||
stats/schema/field/stat = Estadística
|
||||
stats/schema/field/value = Valor
|
||||
stats/schema/field/who = Quién
|
||||
stats/schema/stats/archetype = Dar un arquetipo
|
||||
stats/schema/stats/archetype_desc = Una clase o un temperamento: la elección del prólogo «¿quién eras?»
|
||||
stats/schema/field/archetype = Arquetipo
|
||||
stats/schema/stats/exp = Dar experiencia
|
||||
stats/schema/stats/exp_desc = Una recompensa de misión o una lección; los niveles suben al momento
|
||||
stats/schema/stats/restore = Restaurar
|
||||
stats/schema/stats/restore_desc = Una posada, una fuente, un buen sueño: salud y reservas
|
||||
stats/schema/stats/restore_stat = health, mp…: vacío es todo
|
||||
stats/schema/kind/stat = Estadísticas
|
||||
stats/schema/kind/stat_desc = Lo que cuenta el juego: atributos, salud, carisma
|
||||
stats/schema/field/name = Nombre
|
||||
stats/schema/field/short = Abreviatura
|
||||
stats/schema/field/description = Descripción
|
||||
stats/schema/field/group = Grupo
|
||||
stats/schema/field/order = Orden
|
||||
stats/schema/field/default = Por defecto
|
||||
stats/schema/field/formula = Fórmula
|
||||
stats/schema/field/formula_desc = Por ejemplo: 30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = Mínimo
|
||||
stats/schema/field/max = Máximo
|
||||
stats/schema/field/pool = Reserva
|
||||
stats/schema/field/pool_desc = Se gasta y se recupera, como el maná
|
||||
stats/schema/field/binds = Atributo de combate
|
||||
stats/schema/field/binds_desc = El valor va a la salud, ataque, escudo o puntos de cualquier núcleo de combate
|
||||
stats/schema/field/hidden = Oculto
|
||||
stats/schema/kind/archetype = Arquetipos
|
||||
stats/schema/kind/archetype_desc = Clases de RPG o temperamentos de novela: crecimiento, rasgos, habilidades
|
||||
stats/schema/field/stats = Estadísticas propias
|
||||
stats/schema/field/stats_desc = Estadística → un número o una fórmula del nivel y de otras estadísticas
|
||||
stats/schema/field/traits = Rasgos
|
||||
stats/schema/field/traits_desc = Modificadores de estadísticas: una suma, un porcentaje o una fórmula
|
||||
stats/schema/field/add = Suma
|
||||
stats/schema/field/percent = Porcentaje
|
||||
stats/schema/field/learnings = Habilidades por nivel
|
||||
stats/schema/field/level = Nivel
|
||||
stats/schema/field/action = Acción
|
||||
stats/schema/field/exp_curve = Curva de experiencia
|
||||
stats/schema/field/exp_curve_desc = Experiencia total para un nivel, p. ej. 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = Nivel máximo
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = ステータス
|
||||
mod_stats_base_desc = どのジャンルにも使えるステータスエンジン:ステータスはデータ(筋力、カリスマ、HP、正気度——組み込みの一覧はない)、アーキタイプ(RPG の職業やノベルの気質)はレベルごとの式と経験値曲線を持ち、特性はどのエンティティ(アイテム、キャラクター、状態)にも付けられる修正。派生ステータスとバトルの式、キャラクターシート、経験値によるレベルアップ。結び付けたステータスはバトル属性に書き込まれるので、どのバトルコアもそのまま使える。
|
||||
stats/level_up = {0}:レベル {1}!
|
||||
stats/learned = {0} は {1} を覚えた
|
||||
stats/sheet/title = キャラクターシート
|
||||
stats/sheet/party = パーティー
|
||||
stats/sheet/close = 閉じる
|
||||
stats/sheet/level = レベル {0}
|
||||
stats/sheet/level_short = Lv {0}
|
||||
stats/sheet/level_exp = レベル {0} · 経験値 {1} / {2}
|
||||
stats/sheet/no_archetype = アーキタイプなし
|
||||
stats/sheet/empty = このゲームにはステータスがない:どの MOD も定義していない。
|
||||
stats/group/attributes = 能力値
|
||||
stats/group/combat = 戦闘
|
||||
stats/group/social = 社交
|
||||
stats/schema/stats/add = ステータスを永続的に変える
|
||||
stats/schema/stats/add_desc = 訓練、薬、主人公を勇敢にした選択。マイナスなら下がる。
|
||||
stats/schema/field/stat = ステータス
|
||||
stats/schema/field/value = 値
|
||||
stats/schema/field/who = 誰
|
||||
stats/schema/stats/archetype = アーキタイプを与える
|
||||
stats/schema/stats/archetype_desc = 職業や気質——プロローグの「何者だった?」の選択
|
||||
stats/schema/field/archetype = アーキタイプ
|
||||
stats/schema/stats/exp = 経験値を与える
|
||||
stats/schema/stats/exp_desc = クエストの報酬や教訓。レベルはすぐに上がる
|
||||
stats/schema/stats/restore = 回復
|
||||
stats/schema/stats/restore_desc = 宿屋、泉、ぐっすり眠る:HP と蓄え
|
||||
stats/schema/stats/restore_stat = health、mp…——空ならすべて
|
||||
stats/schema/kind/stat = ステータス
|
||||
stats/schema/kind/stat_desc = ゲームが数えるもの:能力値、HP、カリスマ
|
||||
stats/schema/field/name = 名前
|
||||
stats/schema/field/short = 略称
|
||||
stats/schema/field/description = 説明
|
||||
stats/schema/field/group = グループ
|
||||
stats/schema/field/order = 順序
|
||||
stats/schema/field/default = 既定値
|
||||
stats/schema/field/formula = 式
|
||||
stats/schema/field/formula_desc = 例:30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = 最小
|
||||
stats/schema/field/max = 最大
|
||||
stats/schema/field/pool = 消費型
|
||||
stats/schema/field/pool_desc = マナのように消費し回復する
|
||||
stats/schema/field/binds = 戦闘属性
|
||||
stats/schema/field/binds_desc = 値はどのバトルコアの HP・攻撃・シールド・ポイントにも入る
|
||||
stats/schema/field/hidden = 非表示
|
||||
stats/schema/kind/archetype = アーキタイプ
|
||||
stats/schema/kind/archetype_desc = RPG の職業やノベルの気質:成長、特性、スキル
|
||||
stats/schema/field/stats = 固有ステータス
|
||||
stats/schema/field/stats_desc = ステータス → 数値、またはレベルと他のステータスの式
|
||||
stats/schema/field/traits = 特性
|
||||
stats/schema/field/traits_desc = ステータス修正:固定値、パーセント、または式
|
||||
stats/schema/field/add = 加算
|
||||
stats/schema/field/percent = パーセント
|
||||
stats/schema/field/learnings = レベル習得
|
||||
stats/schema/field/level = レベル
|
||||
stats/schema/field/action = 行動
|
||||
stats/schema/field/exp_curve = 経験値曲線
|
||||
stats/schema/field/exp_curve_desc = そのレベルに必要な総経験値。例:10 * level * (level - 1)
|
||||
stats/schema/field/max_level = レベル上限
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = 능력치
|
||||
mod_stats_base_desc = 어떤 장르에도 쓰는 능력치 엔진: 데이터로 정의하는 능력치(힘, 매력, 체력, 정신력 — 내장 목록은 없음), 레벨별 공식과 경험치 곡선을 가진 원형(RPG 직업이나 노벨의 기질), 어떤 엔티티(아이템, 캐릭터, 상태)든 지닐 수 있는 수정치인 특성. 파생 능력치와 전투용 공식, 캐릭터 시트, 경험치로 오르는 레벨. 연결된 능력치는 전투 속성에 기록되므로 모든 전투 코어가 그대로 사용합니다.
|
||||
stats/level_up = {0}: 레벨 {1}!
|
||||
stats/learned = {0}: {1} 습득
|
||||
stats/sheet/title = 캐릭터 시트
|
||||
stats/sheet/party = 파티
|
||||
stats/sheet/close = 닫기
|
||||
stats/sheet/level = 레벨 {0}
|
||||
stats/sheet/level_short = Lv {0}
|
||||
stats/sheet/level_exp = 레벨 {0} · 경험치 {1} / {2}
|
||||
stats/sheet/no_archetype = 원형 없음
|
||||
stats/sheet/empty = 이 게임에는 능력치가 없습니다: 어떤 모드도 정의하지 않았습니다.
|
||||
stats/group/attributes = 능력
|
||||
stats/group/combat = 전투
|
||||
stats/group/social = 사교
|
||||
stats/schema/stats/add = 능력치를 영구히 변경
|
||||
stats/schema/stats/add_desc = 훈련, 물약, 주인공을 더 용감하게 만든 선택. 음수면 낮춤.
|
||||
stats/schema/field/stat = 능력치
|
||||
stats/schema/field/value = 값
|
||||
stats/schema/field/who = 누구
|
||||
stats/schema/stats/archetype = 원형 부여
|
||||
stats/schema/stats/archetype_desc = 직업이나 기질 — 프롤로그의 ‘너는 누구였지?’ 선택
|
||||
stats/schema/field/archetype = 원형
|
||||
stats/schema/stats/exp = 경험치 지급
|
||||
stats/schema/stats/exp_desc = 퀘스트 보상이나 교훈. 레벨이 바로 오릅니다
|
||||
stats/schema/stats/restore = 회복
|
||||
stats/schema/stats/restore_desc = 여관, 샘, 푹 자기: 체력과 비축
|
||||
stats/schema/stats/restore_stat = health, mp… — 비우면 전부
|
||||
stats/schema/kind/stat = 능력치
|
||||
stats/schema/kind/stat_desc = 게임이 세는 것: 능력, 체력, 매력
|
||||
stats/schema/field/name = 이름
|
||||
stats/schema/field/short = 약칭
|
||||
stats/schema/field/description = 설명
|
||||
stats/schema/field/group = 그룹
|
||||
stats/schema/field/order = 순서
|
||||
stats/schema/field/default = 기본값
|
||||
stats/schema/field/formula = 공식
|
||||
stats/schema/field/formula_desc = 예: 30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = 최소
|
||||
stats/schema/field/max = 최대
|
||||
stats/schema/field/pool = 소모형
|
||||
stats/schema/field/pool_desc = 마나처럼 쓰고 회복
|
||||
stats/schema/field/binds = 전투 속성
|
||||
stats/schema/field/binds_desc = 값이 모든 전투 코어의 체력·공격·보호막·포인트로 들어갑니다
|
||||
stats/schema/field/hidden = 숨김
|
||||
stats/schema/kind/archetype = 원형
|
||||
stats/schema/kind/archetype_desc = RPG 직업이나 노벨의 기질: 성장, 특성, 스킬
|
||||
stats/schema/field/stats = 고유 능력치
|
||||
stats/schema/field/stats_desc = 능력치 → 숫자, 또는 레벨과 다른 능력치의 공식
|
||||
stats/schema/field/traits = 특성
|
||||
stats/schema/field/traits_desc = 능력치 수정: 고정값, 퍼센트 또는 공식
|
||||
stats/schema/field/add = 더하기
|
||||
stats/schema/field/percent = 퍼센트
|
||||
stats/schema/field/learnings = 레벨별 습득
|
||||
stats/schema/field/level = 레벨
|
||||
stats/schema/field/action = 행동
|
||||
stats/schema/field/exp_curve = 경험치 곡선
|
||||
stats/schema/field/exp_curve_desc = 그 레벨에 필요한 총 경험치. 예: 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = 레벨 상한
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = Статы
|
||||
mod_stats_base_desc = Движок статов для любого жанра: статы как данные (сила, харизма, здоровье, рассудок — встроенного списка нет), архетипы (классы RPG или характеры новеллы) с формулами по уровню и кривой опыта, черты — модификаторы, которые несёт любая сущность (предмет, персонаж, состояние). Формулы для производных статов и боя, лист персонажа, уровни из опыта. Привязанные статы пишутся в боевые атрибуты, поэтому любое боевое ядро работает с ними без изменений.
|
||||
stats/level_up = {0}: уровень {1}!
|
||||
stats/learned = {0} осваивает: {1}
|
||||
stats/sheet/title = Лист персонажа
|
||||
stats/sheet/party = Отряд
|
||||
stats/sheet/close = Закрыть
|
||||
stats/sheet/level = Уровень {0}
|
||||
stats/sheet/level_short = ур. {0}
|
||||
stats/sheet/level_exp = Уровень {0} · опыт {1} из {2}
|
||||
stats/sheet/no_archetype = Без класса
|
||||
stats/sheet/empty = В этой игре нет статов: ни один мод не описал ни одного.
|
||||
stats/group/attributes = Атрибуты
|
||||
stats/group/combat = Бой
|
||||
stats/group/social = Общение
|
||||
stats/schema/stats/add = Изменить стат навсегда
|
||||
stats/schema/stats/add_desc = Тренировка, зелье, решение, после которого герой стал смелее. Минус — уменьшает.
|
||||
stats/schema/field/stat = Стат
|
||||
stats/schema/field/value = Значение
|
||||
stats/schema/field/who = Кто
|
||||
stats/schema/stats/archetype = Дать архетип
|
||||
stats/schema/stats/archetype_desc = Класс или характер — выбор пролога «кем ты был?»
|
||||
stats/schema/field/archetype = Архетип
|
||||
stats/schema/stats/exp = Дать опыт
|
||||
stats/schema/stats/exp_desc = Награда за квест или урок; уровни растут сразу
|
||||
stats/schema/stats/restore = Восстановить силы
|
||||
stats/schema/stats/restore_desc = Трактир, источник, крепкий сон: здоровье и запасы
|
||||
stats/schema/stats/restore_stat = health, mp… — пусто: всё
|
||||
stats/schema/kind/stat = Статы
|
||||
stats/schema/kind/stat_desc = Что считает игра: атрибуты, здоровье, харизма
|
||||
stats/schema/field/name = Название
|
||||
stats/schema/field/short = Сокращение
|
||||
stats/schema/field/description = Описание
|
||||
stats/schema/field/group = Группа
|
||||
stats/schema/field/order = Порядок
|
||||
stats/schema/field/default = По умолчанию
|
||||
stats/schema/field/formula = Формула
|
||||
stats/schema/field/formula_desc = Например: 30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = Минимум
|
||||
stats/schema/field/max = Максимум
|
||||
stats/schema/field/pool = Расходуемый
|
||||
stats/schema/field/pool_desc = Тратится и восполняется, как мана
|
||||
stats/schema/field/binds = Боевой атрибут
|
||||
stats/schema/field/binds_desc = Значение уходит в здоровье, атаку, щит или очки любого боевого ядра
|
||||
stats/schema/field/hidden = Скрытый
|
||||
stats/schema/kind/archetype = Архетипы
|
||||
stats/schema/kind/archetype_desc = Классы RPG или характеры новеллы: рост статов, черты, навыки
|
||||
stats/schema/field/stats = Свои статы
|
||||
stats/schema/field/stats_desc = Стат → число или формула от уровня и других статов
|
||||
stats/schema/field/traits = Черты
|
||||
stats/schema/field/traits_desc = Модификаторы статов: прибавка, процент или формула
|
||||
stats/schema/field/add = Прибавка
|
||||
stats/schema/field/percent = Процент
|
||||
stats/schema/field/learnings = Навыки по уровням
|
||||
stats/schema/field/level = Уровень
|
||||
stats/schema/field/action = Действие
|
||||
stats/schema/field/exp_curve = Кривая опыта
|
||||
stats/schema/field/exp_curve_desc = Сколько опыта нужно для уровня level, например 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = Предел уровня
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = Chỉ số
|
||||
mod_stats_base_desc = Bộ máy chỉ số cho mọi thể loại: chỉ số là dữ liệu (sức mạnh, sức hút, máu, lý trí — không có danh sách cố định), nguyên mẫu (lớp nhân vật RPG hay tính cách trong tiểu thuyết) có công thức theo cấp và đường kinh nghiệm, đặc tính — bộ điều chỉnh mà mọi thực thể (vật phẩm, nhân vật, trạng thái) đều mang được. Công thức cho chỉ số phái sinh và chiến đấu, bảng nhân vật, lên cấp nhờ kinh nghiệm. Chỉ số được gắn sẽ ghi vào thuộc tính chiến đấu, nên mọi lõi chiến đấu dùng được mà không cần sửa.
|
||||
stats/level_up = {0}: cấp {1}!
|
||||
stats/learned = {0} học được: {1}
|
||||
stats/sheet/title = Bảng nhân vật
|
||||
stats/sheet/party = Đội
|
||||
stats/sheet/close = Đóng
|
||||
stats/sheet/level = Cấp {0}
|
||||
stats/sheet/level_short = cấp {0}
|
||||
stats/sheet/level_exp = Cấp {0} · kinh nghiệm {1} / {2}
|
||||
stats/sheet/no_archetype = Chưa có nguyên mẫu
|
||||
stats/sheet/empty = Game này không có chỉ số: chưa mod nào định nghĩa.
|
||||
stats/group/attributes = Thuộc tính
|
||||
stats/group/combat = Chiến đấu
|
||||
stats/group/social = Giao tiếp
|
||||
stats/schema/stats/add = Thay đổi chỉ số vĩnh viễn
|
||||
stats/schema/stats/add_desc = Luyện tập, thuốc, một lựa chọn khiến anh hùng can đảm hơn. Số âm thì giảm.
|
||||
stats/schema/field/stat = Chỉ số
|
||||
stats/schema/field/value = Giá trị
|
||||
stats/schema/field/who = Ai
|
||||
stats/schema/stats/archetype = Gán nguyên mẫu
|
||||
stats/schema/stats/archetype_desc = Một lớp hay tính cách — lựa chọn “ngươi là ai?” ở phần mở đầu
|
||||
stats/schema/field/archetype = Nguyên mẫu
|
||||
stats/schema/stats/exp = Cho kinh nghiệm
|
||||
stats/schema/stats/exp_desc = Phần thưởng nhiệm vụ hay bài học; lên cấp ngay
|
||||
stats/schema/stats/restore = Hồi phục
|
||||
stats/schema/stats/restore_desc = Quán trọ, suối nước, một giấc ngon: máu và các nguồn
|
||||
stats/schema/stats/restore_stat = health, mp… — để trống: tất cả
|
||||
stats/schema/kind/stat = Chỉ số
|
||||
stats/schema/kind/stat_desc = Những gì game đếm: thuộc tính, máu, sức hút
|
||||
stats/schema/field/name = Tên
|
||||
stats/schema/field/short = Viết tắt
|
||||
stats/schema/field/description = Mô tả
|
||||
stats/schema/field/group = Nhóm
|
||||
stats/schema/field/order = Thứ tự
|
||||
stats/schema/field/default = Mặc định
|
||||
stats/schema/field/formula = Công thức
|
||||
stats/schema/field/formula_desc = Ví dụ: 30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = Tối thiểu
|
||||
stats/schema/field/max = Tối đa
|
||||
stats/schema/field/pool = Có tiêu hao
|
||||
stats/schema/field/pool_desc = Tiêu hao và hồi lại, như mana
|
||||
stats/schema/field/binds = Thuộc tính chiến đấu
|
||||
stats/schema/field/binds_desc = Giá trị đi vào máu, tấn công, khiên hoặc điểm của mọi lõi chiến đấu
|
||||
stats/schema/field/hidden = Ẩn
|
||||
stats/schema/kind/archetype = Nguyên mẫu
|
||||
stats/schema/kind/archetype_desc = Lớp RPG hay tính cách tiểu thuyết: tăng trưởng, đặc tính, kỹ năng
|
||||
stats/schema/field/stats = Chỉ số riêng
|
||||
stats/schema/field/stats_desc = Chỉ số → một con số hoặc công thức theo cấp và các chỉ số khác
|
||||
stats/schema/field/traits = Đặc tính
|
||||
stats/schema/field/traits_desc = Bộ điều chỉnh chỉ số: số cộng, phần trăm hoặc công thức
|
||||
stats/schema/field/add = Cộng
|
||||
stats/schema/field/percent = Phần trăm
|
||||
stats/schema/field/learnings = Kỹ năng theo cấp
|
||||
stats/schema/field/level = Cấp
|
||||
stats/schema/field/action = Hành động
|
||||
stats/schema/field/exp_curve = Đường kinh nghiệm
|
||||
stats/schema/field/exp_curve_desc = Tổng kinh nghiệm cần cho một cấp, ví dụ 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = Cấp tối đa
|
||||
@@ -0,0 +1,59 @@
|
||||
mod_stats_base_name = 属性
|
||||
mod_stats_base_desc = 适用于任何类型的属性引擎:属性即数据(力量、魅力、生命、理智——没有内置列表),原型(RPG 职业或小说人物的性格)带有按等级的公式和经验曲线,特性——任何实体(物品、角色、状态)都能携带的修正。用于派生属性和战斗的公式、角色面板、由经验带来的等级。绑定的属性会写入战斗属性,因此任何战斗核心无需改动即可使用。
|
||||
stats/level_up = {0}:等级 {1}!
|
||||
stats/learned = {0} 学会了:{1}
|
||||
stats/sheet/title = 角色面板
|
||||
stats/sheet/party = 队伍
|
||||
stats/sheet/close = 关闭
|
||||
stats/sheet/level = 等级 {0}
|
||||
stats/sheet/level_short = {0} 级
|
||||
stats/sheet/level_exp = 等级 {0} · 经验 {1} / {2}
|
||||
stats/sheet/no_archetype = 无原型
|
||||
stats/sheet/empty = 这个游戏没有属性:没有任何模组定义过。
|
||||
stats/group/attributes = 属性
|
||||
stats/group/combat = 战斗
|
||||
stats/group/social = 社交
|
||||
stats/schema/stats/add = 永久改变属性
|
||||
stats/schema/stats/add_desc = 训练、药水、让主角更勇敢的选择。负数则降低。
|
||||
stats/schema/field/stat = 属性
|
||||
stats/schema/field/value = 数值
|
||||
stats/schema/field/who = 谁
|
||||
stats/schema/stats/archetype = 赋予原型
|
||||
stats/schema/stats/archetype_desc = 职业或性格——序章里“你是谁”的选择
|
||||
stats/schema/field/archetype = 原型
|
||||
stats/schema/stats/exp = 给予经验
|
||||
stats/schema/stats/exp_desc = 任务奖励或一次教训;等级立即提升
|
||||
stats/schema/stats/restore = 恢复
|
||||
stats/schema/stats/restore_desc = 旅店、泉水、一夜好眠:生命与储备
|
||||
stats/schema/stats/restore_stat = health、mp……——留空:全部
|
||||
stats/schema/kind/stat = 属性
|
||||
stats/schema/kind/stat_desc = 游戏计算的东西:属性、生命、魅力
|
||||
stats/schema/field/name = 名称
|
||||
stats/schema/field/short = 简称
|
||||
stats/schema/field/description = 描述
|
||||
stats/schema/field/group = 分组
|
||||
stats/schema/field/order = 顺序
|
||||
stats/schema/field/default = 默认值
|
||||
stats/schema/field/formula = 公式
|
||||
stats/schema/field/formula_desc = 例如:30 + vit * 6 + level * 4
|
||||
stats/schema/field/min = 最小值
|
||||
stats/schema/field/max = 最大值
|
||||
stats/schema/field/pool = 可消耗
|
||||
stats/schema/field/pool_desc = 会消耗也会恢复,就像法力
|
||||
stats/schema/field/binds = 战斗属性
|
||||
stats/schema/field/binds_desc = 该值写入任何战斗核心的生命、攻击、护盾或行动点
|
||||
stats/schema/field/hidden = 隐藏
|
||||
stats/schema/kind/archetype = 原型
|
||||
stats/schema/kind/archetype_desc = RPG 职业或小说性格:属性成长、特性、技能
|
||||
stats/schema/field/stats = 自身属性
|
||||
stats/schema/field/stats_desc = 属性 → 数字,或基于等级和其他属性的公式
|
||||
stats/schema/field/traits = 特性
|
||||
stats/schema/field/traits_desc = 属性修正:固定值、百分比或公式
|
||||
stats/schema/field/add = 加值
|
||||
stats/schema/field/percent = 百分比
|
||||
stats/schema/field/learnings = 按等级习得
|
||||
stats/schema/field/level = 等级
|
||||
stats/schema/field/action = 动作
|
||||
stats/schema/field/exp_curve = 经验曲线
|
||||
stats/schema/field/exp_curve_desc = 达到某等级所需的总经验,例如 10 * level * (level - 1)
|
||||
stats/schema/field/max_level = 等级上限
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "stats_base",
|
||||
"name": "Stats",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Genre-neutral stats engine: stats as data (\"Kind\": \"stat\": strength, charisma, max health, sanity — no built-in list), archetypes (\"Kind\": \"archetype\": RPG classes or a novel's temperaments) with stat formulas per level and exp curves, traits (stat modifiers any entity may carry in a \"Traits\" key: items, characters, statuses) and trait sources other mods plug in. A formula language for derived stats and battle numbers, sheets per character, permanent bonuses, pools, levels from exp. Bound stats are written into the battle attributes, so every battle core uses them unchanged. Conditions stat(who, stat), level(who), archetype(who, id); actions stat.add, stat.archetype, stat.exp, stat.restore; a character sheet panel.",
|
||||
"category": "engine, library",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"characters_base>=1",
|
||||
"shell2d_base>=1",
|
||||
"ui_base>=1"
|
||||
],
|
||||
"ui": [
|
||||
{
|
||||
"id": "sheet",
|
||||
"kind": "panel",
|
||||
"slot": "ModBeforeUIPanel",
|
||||
"scene_path": "scenes/ui/sheet_panel.tscn",
|
||||
"button": {
|
||||
"icon": "sheet",
|
||||
"locale": "stats/sheet/title",
|
||||
"index": 25
|
||||
},
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://mods/stats_base/src/UI/StatSheetPanel.cs" id="1_script"]
|
||||
|
||||
[node name="SheetPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Bg" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0, 0, 0, 0.9)
|
||||
|
||||
[node name="Window" type="Panel" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 40.0
|
||||
offset_top = 78.0
|
||||
offset_right = -40.0
|
||||
offset_bottom = -30.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Window"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/margin_left = 24
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 24
|
||||
theme_override_constants/margin_bottom = 14
|
||||
|
||||
[node name="Root" type="VBoxContainer" parent="Window/Margin"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="Window/Margin/Root"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Title" type="Label" parent="Window/Margin/Root/Header"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Spacer" type="Control" parent="Window/Margin/Root/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="CloseButton" type="Button" parent="Window/Margin/Root/Header"]
|
||||
custom_minimum_size = Vector2(38, 38)
|
||||
layout_mode = 2
|
||||
theme_type_variation = "CthangoverIcon"
|
||||
text = "✕"
|
||||
|
||||
[node name="Rule" type="Panel" parent="Window/Margin/Root"]
|
||||
custom_minimum_size = Vector2(0, 1)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Body" type="HBoxContainer" parent="Window/Margin/Root"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Party" type="VBoxContainer" parent="Window/Margin/Root/Body"]
|
||||
custom_minimum_size = Vector2(260, 0)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="PartyLabel" type="Label" parent="Window/Margin/Root/Body/Party"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="PartyList" type="VBoxContainer" parent="Window/Margin/Root/Body/Party"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="Window/Margin/Root/Body"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="Main" type="VBoxContainer" parent="Window/Margin/Root/Body/Scroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="Hero" type="HBoxContainer" parent="Window/Margin/Root/Body/Scroll/Main"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 16
|
||||
|
||||
[node name="Portrait" type="TextureRect" parent="Window/Margin/Root/Body/Scroll/Main/Hero"]
|
||||
custom_minimum_size = Vector2(128, 128)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="Info" type="VBoxContainer" parent="Window/Margin/Root/Body/Scroll/Main/Hero"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="Name" type="Label" parent="Window/Margin/Root/Body/Scroll/Main/Hero/Info"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Archetype" type="Label" parent="Window/Margin/Root/Body/Scroll/Main/Hero/Info"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Level" type="Label" parent="Window/Margin/Root/Body/Scroll/Main/Hero/Info"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="ExpBar" type="ProgressBar" parent="Window/Margin/Root/Body/Scroll/Main/Hero/Info"]
|
||||
custom_minimum_size = Vector2(0, 10)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
show_percentage = false
|
||||
|
||||
[node name="ArchetypeHint" type="Label" parent="Window/Margin/Root/Body/Scroll/Main/Hero/Info"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="Stats" type="VBoxContainer" parent="Window/Margin/Root/Body/Scroll/Main"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Sections" type="VBoxContainer" parent="Window/Margin/Root/Body/Scroll/Main"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 14
|
||||
|
||||
[node name="Empty" type="Label" parent="Window/Margin/Root/Body/Scroll/Main"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
autowrap_mode = 3
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"Actions": [
|
||||
{ "Id": "stat.add", "Name": "stats/schema/stats/add", "Description": "stats/schema/stats/add_desc", "Category": "stats",
|
||||
"Params": [
|
||||
{ "Key": "stat", "Type": "ref", "Kind": "stat", "Required": true, "Name": "stats/schema/field/stat" },
|
||||
{ "Key": "value", "Type": "number", "Default": "1", "Name": "stats/schema/field/value" },
|
||||
{ "Key": "who", "Type": "who", "Name": "stats/schema/field/who" } ] },
|
||||
{ "Id": "stat.archetype", "Name": "stats/schema/stats/archetype", "Description": "stats/schema/stats/archetype_desc", "Category": "stats",
|
||||
"Params": [
|
||||
{ "Key": "id", "Type": "ref", "Kind": "archetype", "Name": "stats/schema/field/archetype" },
|
||||
{ "Key": "who", "Type": "who", "Name": "stats/schema/field/who" } ] },
|
||||
{ "Id": "stat.exp", "Name": "stats/schema/stats/exp", "Description": "stats/schema/stats/exp_desc", "Category": "stats",
|
||||
"Params": [
|
||||
{ "Key": "value", "Type": "int", "Required": true, "Name": "stats/schema/field/value" },
|
||||
{ "Key": "who", "Type": "who", "Default": "party", "Name": "stats/schema/field/who" } ] },
|
||||
{ "Id": "stat.restore", "Name": "stats/schema/stats/restore", "Description": "stats/schema/stats/restore_desc", "Category": "stats",
|
||||
"Params": [
|
||||
{ "Key": "who", "Type": "who", "Default": "party", "Name": "stats/schema/field/who" },
|
||||
{ "Key": "stat", "Type": "text", "Name": "stats/schema/field/stat", "Description": "stats/schema/stats/restore_stat" } ] }
|
||||
],
|
||||
"Kinds": [
|
||||
{ "Kind": "stat", "Name": "stats/schema/kind/stat", "Description": "stats/schema/kind/stat_desc", "Folder": "stats", "NewId": "new_stat", "Order": 1,
|
||||
"Fields": [
|
||||
{ "Key": "Name", "Type": "locale", "KeyPattern": "{mod}/stat/{id}", "Name": "stats/schema/field/name" },
|
||||
{ "Key": "Short", "Type": "locale", "KeyPattern": "{mod}/stat/{id}_short", "Name": "stats/schema/field/short" },
|
||||
{ "Key": "Description", "Type": "locale", "KeyPattern": "{mod}/stat/{id}_desc", "Name": "stats/schema/field/description" },
|
||||
{ "Key": "Group", "Type": "text", "Name": "stats/schema/field/group" },
|
||||
{ "Key": "Order", "Type": "int", "Name": "stats/schema/field/order" },
|
||||
{ "Key": "Default", "Type": "number", "Name": "stats/schema/field/default" },
|
||||
{ "Key": "Formula", "Type": "formula", "Name": "stats/schema/field/formula", "Description": "stats/schema/field/formula_desc" },
|
||||
{ "Key": "Min", "Type": "number", "Name": "stats/schema/field/min" },
|
||||
{ "Key": "Max", "Type": "number", "Name": "stats/schema/field/max" },
|
||||
{ "Key": "Pool", "Type": "bool", "Name": "stats/schema/field/pool", "Description": "stats/schema/field/pool_desc" },
|
||||
{ "Key": "Binds", "Type": "choice", "Options": ["health", "attack", "defence", "point"], "Name": "stats/schema/field/binds", "Description": "stats/schema/field/binds_desc" },
|
||||
{ "Key": "Hidden", "Type": "bool", "Name": "stats/schema/field/hidden" } ] },
|
||||
{ "Kind": "archetype", "Name": "stats/schema/kind/archetype", "Description": "stats/schema/kind/archetype_desc", "Folder": "archetypes", "NewId": "{mod}/new_archetype", "Order": 2,
|
||||
"Fields": [
|
||||
{ "Key": "Name", "Type": "locale", "KeyPattern": "{mod}/archetype/{id}", "Name": "stats/schema/field/name" },
|
||||
{ "Key": "Description", "Type": "locale", "KeyPattern": "{mod}/archetype/{id}_desc", "Name": "stats/schema/field/description" },
|
||||
{ "Key": "Stats", "Type": "map", "Kind": "stat", "Name": "stats/schema/field/stats", "Description": "stats/schema/field/stats_desc" },
|
||||
{ "Key": "Traits", "Type": "list", "Name": "stats/schema/field/traits", "Description": "stats/schema/field/traits_desc", "Of": [
|
||||
{ "Key": "Stat", "Type": "ref", "Kind": "stat", "Required": true, "Name": "stats/schema/field/stat" },
|
||||
{ "Key": "Add", "Type": "number", "Name": "stats/schema/field/add" },
|
||||
{ "Key": "Percent", "Type": "number", "Name": "stats/schema/field/percent" },
|
||||
{ "Key": "Formula", "Type": "formula", "Name": "stats/schema/field/formula" } ] },
|
||||
{ "Key": "Learnings", "Type": "list", "Name": "stats/schema/field/learnings", "Of": [
|
||||
{ "Key": "Level", "Type": "int", "Default": "1", "Name": "stats/schema/field/level" },
|
||||
{ "Key": "Action", "Type": "ref", "Kind": "action", "Required": true, "Name": "stats/schema/field/action" } ] },
|
||||
{ "Key": "ExpCurve", "Type": "formula", "Name": "stats/schema/field/exp_curve", "Description": "stats/schema/field/exp_curve_desc" },
|
||||
{ "Key": "MaxLevel", "Type": "int", "Name": "stats/schema/field/max_level" } ] },
|
||||
{ "Kind": "character",
|
||||
"Fields": [
|
||||
{ "Key": "Archetype", "Type": "ref", "Kind": "archetype", "Name": "stats/schema/field/archetype" },
|
||||
{ "Key": "Stats", "Type": "map", "Kind": "stat", "Name": "stats/schema/field/stats", "Description": "stats/schema/field/stats_desc" },
|
||||
{ "Key": "Traits", "Type": "list", "Name": "stats/schema/field/traits", "Description": "stats/schema/field/traits_desc", "Of": [
|
||||
{ "Key": "Stat", "Type": "ref", "Kind": "stat", "Required": true, "Name": "stats/schema/field/stat" },
|
||||
{ "Key": "Add", "Type": "number", "Name": "stats/schema/field/add" },
|
||||
{ "Key": "Percent", "Type": "number", "Name": "stats/schema/field/percent" },
|
||||
{ "Key": "Formula", "Type": "formula", "Name": "stats/schema/field/formula" } ] } ] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// <c>action stat.add stat=cha value=1 [who=player|party|<id>]</c>: a permanent change
|
||||
/// of a stat — a training session, a potion of strength, a choice that made the hero
|
||||
/// braver. Negative values lower the stat.
|
||||
/// </summary>
|
||||
public class StatAddAction : IScenarioAction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "stat.add";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var stat = ctx.GetParam("stat");
|
||||
var value = StatArgs.Number(ctx.GetParam("value"), 1);
|
||||
if (string.IsNullOrWhiteSpace(stat))
|
||||
{
|
||||
ctx.LogWarning("STATS", "stat.add: missing 'stat'");
|
||||
return;
|
||||
}
|
||||
foreach (var id in StatsService.Targets(ctx.GetParam("who")))
|
||||
StatsService.AddBonus(id, stat, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// <c>action stat.archetype id=warrior [who=player|<id>]</c>: gives a character an
|
||||
/// archetype (a class, a temperament). A prologue's "who are you?" choice is three
|
||||
/// options each ending with this action. An empty id returns to the template's.
|
||||
/// </summary>
|
||||
public class StatArchetypeAction : IScenarioAction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "stat.archetype";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var archetype = ctx.GetParam("id");
|
||||
if (!string.IsNullOrWhiteSpace(archetype) && StatsService.Archetype(archetype) == null)
|
||||
ctx.LogWarning("STATS", $"stat.archetype: unknown archetype '{archetype}'");
|
||||
foreach (var id in StatsService.Targets(ctx.GetParam("who")))
|
||||
StatsService.SetArchetype(id, archetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Globalization;
|
||||
|
||||
/// <summary>Number arguments of the stats actions: invariant culture, a sign allowed (<c>value=-1</c>).</summary>
|
||||
public static class StatArgs
|
||||
{
|
||||
/// <summary>The number in <paramref name="text"/>, or <paramref name="fallback"/> when it is empty or not a number.</summary>
|
||||
public static double Number(string text, double fallback = 0)
|
||||
=> double.TryParse(text?.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// <c>action stat.exp value=30 [who=party|player|<id>]</c>: exp outside battles — a
|
||||
/// quest reward, a lesson learned; level-ups follow at once. <c>who</c> defaults to the
|
||||
/// whole party.
|
||||
/// </summary>
|
||||
public class StatExpAction : IScenarioAction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "stat.exp";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var value = (int)StatArgs.Number(ctx.GetParam("value"));
|
||||
if (value <= 0)
|
||||
{
|
||||
ctx.LogWarning("STATS", "stat.exp: 'value' must be a positive number");
|
||||
return;
|
||||
}
|
||||
var who = ctx.GetParam("who");
|
||||
foreach (var id in StatsService.Targets(string.IsNullOrWhiteSpace(who) ? "party" : who))
|
||||
StatProgression.AddExp(id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// <c>action stat.restore [who=party|player|<id>] [stat=mp]</c>: an inn, a fountain, a
|
||||
/// good night's sleep. Without <c>stat</c> it fills health (living members only) and
|
||||
/// every pool; with <c>stat=health</c> or a pool id only that one. <c>who</c> defaults to
|
||||
/// the whole party.
|
||||
/// </summary>
|
||||
public class StatRestoreAction : IScenarioAction
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "stat.restore";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Run(IActionContext ctx)
|
||||
{
|
||||
var stat = ctx.GetParam("stat");
|
||||
var who = ctx.GetParam("who");
|
||||
foreach (var id in StatsService.Targets(string.IsNullOrWhiteSpace(who) ? "party" : who))
|
||||
{
|
||||
var all = string.IsNullOrWhiteSpace(stat);
|
||||
if (all || stat.Trim() is "health" or "hp")
|
||||
RestoreHealth(id);
|
||||
if (all)
|
||||
StatPools.Refill(id);
|
||||
else if (StatPools.IsPool(stat))
|
||||
StatPools.Refill(id, stat);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RestoreHealth(string id)
|
||||
{
|
||||
var health = StatsService.RosterEntry(id)?.Attributes?.Health;
|
||||
if (health == null || health.Value <= 0)
|
||||
return;
|
||||
health.Value = health.BaseValue;
|
||||
StatsService.Changed(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Scenes;
|
||||
|
||||
/// <summary><c>archetype(who, id)</c>: true when the character's archetype is <see cref="Archetype"/>.</summary>
|
||||
public class ArchetypeCondition : ConditionNode
|
||||
{
|
||||
/// <summary>Creates the condition.</summary>
|
||||
public ArchetypeCondition(string who, string archetype)
|
||||
{
|
||||
Who = who;
|
||||
Archetype = archetype;
|
||||
}
|
||||
|
||||
/// <summary>Whose archetype.</summary>
|
||||
public string Who { get; }
|
||||
|
||||
/// <summary>The archetype id it must be.</summary>
|
||||
public string Archetype { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Evaluate(EvaluationContext ctx)
|
||||
=> string.Equals(StatsService.ArchetypeOf(Who), Archetype, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// <c>stat(who, stat) >= n</c> and <c>level(who) >= n</c> in any condition: a
|
||||
/// scenario header, a map event, an interactive, an option gate. Values compare as
|
||||
/// whole numbers, rounded like battles and checks round them.
|
||||
/// </summary>
|
||||
public class StatCompareCondition : ConditionNode
|
||||
{
|
||||
/// <summary>Creates the condition; a null <paramref name="stat"/> compares the level.</summary>
|
||||
public StatCompareCondition(string who, string stat, string op, int value)
|
||||
{
|
||||
Who = who;
|
||||
Stat = stat;
|
||||
Op = op;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>Whose stat (a character id or <c>player</c>).</summary>
|
||||
public string Who { get; }
|
||||
|
||||
/// <summary>The stat id; null for the level.</summary>
|
||||
public string Stat { get; }
|
||||
|
||||
/// <summary>The comparison operator.</summary>
|
||||
public string Op { get; }
|
||||
|
||||
/// <summary>The number compared with.</summary>
|
||||
public int Value { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Evaluate(EvaluationContext ctx)
|
||||
{
|
||||
var current = Stat == null
|
||||
? StatsService.LevelOf(Who)
|
||||
: (int)Math.Round(StatsService.Sheet(Who).Get(Stat), MidpointRounding.AwayFromZero);
|
||||
return ConditionCompare.Compare(Op, current, Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Scenes.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Adds the stats to the shared condition language:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>stat(who, stat) OP n</c>, or <c>stat(stat) OP n</c> for the player;</item>
|
||||
/// <item><c>level(who) OP n</c>, or <c>level() OP n</c>;</item>
|
||||
/// <item><c>archetype(who, id)</c>, or <c>archetype(id)</c>.</item>
|
||||
/// </list>
|
||||
/// Arguments are names or quoted strings (ids with a slash must be quoted:
|
||||
/// <c>stat('village/slime', atk)</c>). The call form never collides with flags
|
||||
/// (<c>name OP value</c>) or quests (<c>quest.Property</c>): it claims only its three
|
||||
/// function names followed by <c>(</c>.
|
||||
/// </summary>
|
||||
public class StatLeafParser : ILeafParser
|
||||
{
|
||||
private static readonly string[] Functions = { "stat", "level", "archetype" };
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(TokenPeeker peeker)
|
||||
{
|
||||
if (peeker.AtEnd || peeker.Peek.Type != ConditionParser.TokenType.Ident)
|
||||
return false;
|
||||
return Array.IndexOf(Functions, peeker.Peek.Value) >= 0
|
||||
&& peeker.PeekAhead(1).Type == ConditionParser.TokenType.LParen;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ICondition Parse(TokenPeeker peeker, Func<ICondition> parseSubExpr)
|
||||
{
|
||||
var function = peeker.Advance().Value;
|
||||
var args = Arguments(peeker);
|
||||
switch (function)
|
||||
{
|
||||
case "stat":
|
||||
if (args.Count is < 1 or > 2)
|
||||
throw new FormatException("Condition parse error: stat(who, stat) takes one or two arguments");
|
||||
var who = args.Count == 2 ? args[0] : null;
|
||||
return Compare(peeker, who, args[^1]);
|
||||
case "level":
|
||||
if (args.Count > 1)
|
||||
throw new FormatException("Condition parse error: level(who) takes at most one argument");
|
||||
return Compare(peeker, args.Count == 1 ? args[0] : null, null);
|
||||
default:
|
||||
if (args.Count is < 1 or > 2)
|
||||
throw new FormatException("Condition parse error: archetype(who, id) takes one or two arguments");
|
||||
return new ArchetypeCondition(args.Count == 2 ? args[0] : null, args[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> Arguments(TokenPeeker peeker)
|
||||
{
|
||||
peeker.Expect(ConditionParser.TokenType.LParen, "expected '('");
|
||||
var args = new List<string>();
|
||||
if (peeker.Check(ConditionParser.TokenType.RParen))
|
||||
{
|
||||
peeker.Advance();
|
||||
return args;
|
||||
}
|
||||
while (true)
|
||||
{
|
||||
if (peeker.Check(ConditionParser.TokenType.Ident) || peeker.Check(ConditionParser.TokenType.String))
|
||||
args.Add(peeker.Advance().Value);
|
||||
else
|
||||
throw new FormatException($"Condition parse error: expected a name or a quoted id, got '{peeker.Peek.Value}'");
|
||||
if (peeker.Check(ConditionParser.TokenType.Comma))
|
||||
{
|
||||
peeker.Advance();
|
||||
continue;
|
||||
}
|
||||
peeker.Expect(ConditionParser.TokenType.RParen, "expected ')'");
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
private static StatCompareCondition Compare(TokenPeeker peeker, string who, string stat)
|
||||
{
|
||||
var op = CompareOp(peeker);
|
||||
var number = peeker.Expect(ConditionParser.TokenType.Number, "expected a number after the comparison");
|
||||
return new StatCompareCondition(who, stat, op, int.Parse(number.Value));
|
||||
}
|
||||
|
||||
private static string CompareOp(TokenPeeker peeker)
|
||||
{
|
||||
var type = peeker.AtEnd ? ConditionParser.TokenType.EOF : peeker.Peek.Type;
|
||||
var op = type switch
|
||||
{
|
||||
ConditionParser.TokenType.Eq => "==",
|
||||
ConditionParser.TokenType.Neq => "!=",
|
||||
ConditionParser.TokenType.Ge => ">=",
|
||||
ConditionParser.TokenType.Le => "<=",
|
||||
ConditionParser.TokenType.Gt => ">",
|
||||
ConditionParser.TokenType.Lt => "<",
|
||||
_ => null
|
||||
};
|
||||
if (op == null)
|
||||
throw new FormatException($"Condition parse error: expected a comparison after stat()/level(), got '{(peeker.AtEnd ? "EOF" : peeker.Peek.Value)}'");
|
||||
peeker.Advance();
|
||||
return op;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// What kind of person a character is (<c>"Kind": "archetype"</c>): an RPG class
|
||||
/// (warrior, mage), a novel's temperament (dreamer, cynic), a 3D game's background.
|
||||
/// It gives stat formulas per level, traits and the exp curve. A character names its
|
||||
/// archetype in its template (<c>"Archetype"</c>), and a scenario may change it
|
||||
/// (<c>action stat.archetype</c>), which is how a prologue lets the player choose.
|
||||
/// </summary>
|
||||
[EntityKind("archetype")]
|
||||
public class ArchetypeInfo : IIdentifiable
|
||||
{
|
||||
/// <summary>Archetype id.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
|
||||
/// <summary>Locale key of the name.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the description.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Optional picture in the <c>stats</c> file group (shown by the sheet and choice screens).</summary>
|
||||
public string Icon { get; set; }
|
||||
|
||||
/// <summary>Stat id → value or formula of <c>level</c> and other stats (<c>"str": "6 + level * 2"</c>).</summary>
|
||||
public Dictionary<string, string> Stats { get; set; } = new();
|
||||
|
||||
/// <summary>Modifiers every character of the archetype gets.</summary>
|
||||
public List<TraitInfo> Traits { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Total exp needed to reach <c>level</c> (a formula of <c>level</c>); null takes the
|
||||
/// game's <c>stats.exp_curve</c> setting or the default curve.
|
||||
/// </summary>
|
||||
public string ExpCurve { get; set; }
|
||||
|
||||
/// <summary>Highest level a character of the archetype reaches; 0 = the default 99.</summary>
|
||||
public int MaxLevel { get; set; }
|
||||
|
||||
/// <summary>Battle actions learned on reaching a level (see <see cref="StatLearnings"/>).</summary>
|
||||
public List<ArchetypeLearning> Learnings { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
/// <summary>A battle action an archetype teaches at a level (an RPG class's skill list).</summary>
|
||||
public class ArchetypeLearning
|
||||
{
|
||||
/// <summary>The level at which it is learned (1 = from the start).</summary>
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
/// <summary>The id of the <c>"Kind": "action"</c> entity learned.</summary>
|
||||
public string Action { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the stats keys other mods' entities carry in their JSON extension data
|
||||
/// (<c>[JsonExtensionData] ExtraProperties</c> of characters, items, …). This is how the
|
||||
/// stats reach entities they do not own: a character gets <c>"Archetype"</c>,
|
||||
/// <c>"Stats"</c> and <c>"Traits"</c>, an item gets <c>"Traits"</c>, and neither the
|
||||
/// characters nor the items mod has to know. Keys match in any case, like the entity
|
||||
/// JSON itself; a malformed value is logged under <c>STATS</c> and read as empty.
|
||||
/// </summary>
|
||||
public static class ExtraData
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>The value of <paramref name="key"/> (any case), or null.</summary>
|
||||
public static object Find(IDictionary<string, object> extra, string key)
|
||||
{
|
||||
if (extra == null)
|
||||
return null;
|
||||
foreach (var pair in extra)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
return pair.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>A string value (a number is written invariantly), or null.</summary>
|
||||
public static string Text(IDictionary<string, object> extra, string key)
|
||||
{
|
||||
return Find(extra, key) switch
|
||||
{
|
||||
JsonElement { ValueKind: JsonValueKind.String } s => s.GetString(),
|
||||
JsonElement { ValueKind: JsonValueKind.Number } n => n.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
string s => s,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The <c>"Traits"</c> list, or an empty list.</summary>
|
||||
public static List<TraitInfo> Traits(IDictionary<string, object> extra, string owner)
|
||||
=> Read<List<TraitInfo>>(extra, "Traits", owner) ?? new List<TraitInfo>();
|
||||
|
||||
/// <summary>
|
||||
/// The <c>"Stats"</c> object as stat id (lowercase) → value or formula text: numbers
|
||||
/// and strings are both accepted, <c>{"str": 8, "max_hp": "20 + vit * 4"}</c>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> Stats(IDictionary<string, object> extra, string owner)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Find(extra, "Stats") is not JsonElement { ValueKind: JsonValueKind.Object } obj)
|
||||
return result;
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
var value = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => prop.Value.GetDouble().ToString(CultureInfo.InvariantCulture),
|
||||
JsonValueKind.String => prop.Value.GetString(),
|
||||
_ => null
|
||||
};
|
||||
if (value == null)
|
||||
GameLogger.Log("STATS", $"{owner}: Stats.{prop.Name} must be a number or a formula text", LogLevel.Error);
|
||||
else
|
||||
result[prop.Name.ToLowerInvariant()] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Deserializes <paramref name="key"/> into <typeparamref name="T"/>; null when absent or malformed.</summary>
|
||||
public static T Read<T>(IDictionary<string, object> extra, string key, string owner) where T : class
|
||||
{
|
||||
if (Find(extra, key) is not JsonElement element || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
return null;
|
||||
try
|
||||
{
|
||||
return element.Deserialize<T>(Options);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
GameLogger.Log("STATS", $"{owner}: '{key}' is malformed: {ex.Message}", LogLevel.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Text.Json.Serialization;
|
||||
using Cthangover.Core.Factories;
|
||||
|
||||
/// <summary>
|
||||
/// One stat a game counts (<c>"Kind": "stat"</c>): strength, charisma, max health,
|
||||
/// sanity, reputation — the engine has no built-in list. A game or a pack defines its
|
||||
/// stats as data, so a visual novel can count "courage" and "trust" with the same
|
||||
/// machinery an RPG uses for "atk" and "def", and a mod can add a stat to someone
|
||||
/// else's game without code.
|
||||
/// <para/>
|
||||
/// A stat's value for a character is, in this order of precedence: the character's own
|
||||
/// <c>Stats</c> entry, its archetype's <c>Stats</c> entry, this stat's
|
||||
/// <see cref="Formula"/>, else <see cref="Default"/>; then permanent bonuses and
|
||||
/// traits are applied and the result is clamped to <see cref="Min"/>/<see cref="Max"/>.
|
||||
/// Formulas may read other stats by id and <c>level</c>, so derived stats
|
||||
/// (<c>"max_hp": "30 + vit * 6"</c>) need no code.
|
||||
/// </summary>
|
||||
[EntityKind("stat")]
|
||||
public class StatInfo : IIdentifiable
|
||||
{
|
||||
/// <summary>Stat id, lowercase; formulas, conditions and traits refer to it.</summary>
|
||||
[JsonPropertyName("Id")]
|
||||
public string ID { get; set; }
|
||||
|
||||
/// <summary>Locale key of the full name ("Strength").</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Locale key of the short label in tables ("STR"); the name is used when empty.</summary>
|
||||
public string Short { get; set; }
|
||||
|
||||
/// <summary>Locale key of the explanation shown in the character sheet and the editors.</summary>
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Optional picture in the <c>stats</c> file group.</summary>
|
||||
public string Icon { get; set; }
|
||||
|
||||
/// <summary>Free group name the sheet groups rows by ("attributes", "combat", "social").</summary>
|
||||
public string Group { get; set; }
|
||||
|
||||
/// <summary>Display order inside the sheet (lower first, then by id).</summary>
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>Value used when nothing else defines the stat.</summary>
|
||||
public double Default { get; set; }
|
||||
|
||||
/// <summary>Formula of the value when neither the character nor its archetype defines it.</summary>
|
||||
public string Formula { get; set; }
|
||||
|
||||
/// <summary>Lower bound after bonuses and traits; null = none.</summary>
|
||||
public double? Min { get; set; }
|
||||
|
||||
/// <summary>Upper bound after bonuses and traits; null = none.</summary>
|
||||
public double? Max { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True for a stat that is spent and refilled (mana, stamina): the sheet value is
|
||||
/// its maximum and the current amount is kept per character in the stats state.
|
||||
/// </summary>
|
||||
public bool Pool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the battle attribute the value is written to (<c>health</c>,
|
||||
/// <c>attack</c>, <c>defence</c>, <c>point</c>): the bridge that lets every battle
|
||||
/// core use the stats without knowing about them. Null = not bound.
|
||||
/// </summary>
|
||||
public string Binds { get; set; }
|
||||
|
||||
/// <summary>Hidden stats are computed and usable in formulas but not listed in the sheet.</summary>
|
||||
public bool Hidden { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
/// <summary>
|
||||
/// A modifier of one stat, carried by anything: an archetype, a character template, an
|
||||
/// equipped item, a status, a passive skill — any entity may hold a <c>"Traits"</c> list
|
||||
/// in its JSON, and any mod may contribute traits through an <see cref="ITraitSource"/>.
|
||||
/// A stat's final value is <c>(value + Σ Add + Σ Formula) × (1 + Σ Percent / 100)</c>.
|
||||
/// <para/>
|
||||
/// Traits are the reason gear and statuses need no code of their own: a sword is an
|
||||
/// item with <c>{"Stat": "atk", "Add": 5}</c>, a blessing is a trait of a status.
|
||||
/// </summary>
|
||||
public class TraitInfo
|
||||
{
|
||||
/// <summary>Stat id the trait changes.</summary>
|
||||
public string Stat { get; set; }
|
||||
|
||||
/// <summary>Flat amount added to the stat.</summary>
|
||||
public double Add { get; set; }
|
||||
|
||||
/// <summary>Percent the stat grows by (negative shrinks), summed over all traits of the stat.</summary>
|
||||
public double Percent { get; set; }
|
||||
|
||||
/// <summary>Formula of a flat amount, evaluated against the owner's other stats (<c>"level * 2"</c>).</summary>
|
||||
public string Formula { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point of the formula language: texts like <c>"30 + vit * 6 + level * 4"</c>
|
||||
/// or <c>"max(1, a.atk * 2 - b.def)"</c> that live in data (stat definitions,
|
||||
/// archetypes, traits, battle actions, exp curves) instead of C#.
|
||||
/// <para/>
|
||||
/// Parsed trees are cached per text for the whole session (formulas are a closed set
|
||||
/// written in data, so the cache stays small). A text that does not parse is logged
|
||||
/// once under <c>STATS</c> and evaluates as 0, so a broken formula in a mod shows up
|
||||
/// in the log and the validator instead of throwing in the middle of play.
|
||||
/// </summary>
|
||||
public static class Formula
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, FormulaNode> Cache = new();
|
||||
private static readonly ConcurrentDictionary<string, string> Broken = new();
|
||||
|
||||
/// <summary>The parsed tree (cached); throws <see cref="FormulaException"/> on a syntax error.</summary>
|
||||
public static FormulaNode Parse(string text)
|
||||
=> Cache.GetOrAdd(text ?? string.Empty, FormulaParser.Parse);
|
||||
|
||||
/// <summary>Parses without throwing: false and the parser's message for a broken text.</summary>
|
||||
public static bool TryParse(string text, out FormulaNode node, out string error)
|
||||
{
|
||||
try
|
||||
{
|
||||
node = Parse(text);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (FormulaException ex)
|
||||
{
|
||||
node = null;
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates <paramref name="text"/> in <paramref name="scope"/>. A plain number is
|
||||
/// read directly; an empty text gives <paramref name="fallback"/>; a broken one is
|
||||
/// logged once and gives the fallback too.
|
||||
/// </summary>
|
||||
public static double Evaluate(string text, IFormulaScope scope, double fallback = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return fallback;
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
|
||||
return number;
|
||||
if (TryParse(text, out var node, out var error))
|
||||
return node.Evaluate(scope);
|
||||
if (Broken.TryAdd(text, error))
|
||||
GameLogger.Log("STATS", error, LogLevel.Error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System;
|
||||
|
||||
/// <summary>A formula text that cannot be parsed; the message names the position and what was expected.</summary>
|
||||
public class FormulaException : Exception
|
||||
{
|
||||
/// <summary>Creates the error for <paramref name="formula"/> at character <paramref name="position"/>.</summary>
|
||||
public FormulaException(string formula, int position, string message)
|
||||
: base($"formula '{formula}' at {position}: {message}")
|
||||
{
|
||||
Position = position;
|
||||
}
|
||||
|
||||
/// <summary>Character index in the formula text where parsing stopped.</summary>
|
||||
public int Position { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
/// <summary>
|
||||
/// The functions a formula may call. The set is closed and small on purpose: a formula
|
||||
/// is data an author writes in an editor field, not a script, and every name here is
|
||||
/// one a spreadsheet user already knows. The parser rejects unknown names, so a typo
|
||||
/// is an authoring error at load time, not a silent 0 in the middle of a battle.
|
||||
/// </summary>
|
||||
public static class FormulaFunctions
|
||||
{
|
||||
/// <summary>Function names with their allowed argument counts (min, max; -1 = any).</summary>
|
||||
public static readonly IReadOnlyDictionary<string, (int Min, int Max)> Arity = new Dictionary<string, (int, int)>
|
||||
{
|
||||
["min"] = (1, -1),
|
||||
["max"] = (1, -1),
|
||||
["floor"] = (1, 1),
|
||||
["ceil"] = (1, 1),
|
||||
["round"] = (1, 1),
|
||||
["abs"] = (1, 1),
|
||||
["sqrt"] = (1, 1),
|
||||
["clamp"] = (3, 3),
|
||||
["rand"] = (2, 2),
|
||||
["chance"] = (1, 1),
|
||||
["if"] = (3, 3)
|
||||
};
|
||||
|
||||
/// <summary>Whether <paramref name="name"/> is a function and takes <paramref name="count"/> arguments.</summary>
|
||||
public static bool Accepts(string name, int count)
|
||||
=> Arity.TryGetValue(name, out var arity) && count >= arity.Min && (arity.Max < 0 || count <= arity.Max);
|
||||
|
||||
/// <summary>
|
||||
/// Calls a function. <c>rand(lo, hi)</c> is a whole number between the bounds,
|
||||
/// both included (swapped bounds are fine); <c>chance(p)</c> is 1 with
|
||||
/// <c>p</c> percent probability. Without dice both use the shared generator.
|
||||
/// </summary>
|
||||
public static double Call(string name, double[] args, Random random)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "min": return args.Min();
|
||||
case "max": return args.Max();
|
||||
case "floor": return Math.Floor(args[0]);
|
||||
case "ceil": return Math.Ceiling(args[0]);
|
||||
case "round": return Math.Round(args[0], MidpointRounding.AwayFromZero);
|
||||
case "abs": return Math.Abs(args[0]);
|
||||
case "sqrt": return args[0] <= 0 ? 0 : Math.Sqrt(args[0]);
|
||||
case "clamp": return Math.Clamp(args[0], Math.Min(args[1], args[2]), Math.Max(args[1], args[2]));
|
||||
case "if": return args[0] != 0 ? args[1] : args[2];
|
||||
case "rand":
|
||||
var lo = (int)Math.Ceiling(Math.Min(args[0], args[1]));
|
||||
var hi = (int)Math.Floor(Math.Max(args[0], args[1]));
|
||||
return hi < lo ? lo : (random ?? Random.Shared).Next(lo, hi + 1);
|
||||
case "chance":
|
||||
return (random ?? Random.Shared).NextDouble() * 100 < args[0] ? 1 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// One node of a parsed formula. A formula is parsed once (<see cref="Formula.Parse"/>
|
||||
/// caches the tree per text) and evaluated many times against different scopes, so the
|
||||
/// tree is immutable and holds no values of its own beyond literals.
|
||||
/// <para/>
|
||||
/// Arithmetic is forgiving on purpose, because formulas are written by game authors and
|
||||
/// run in the middle of play: division or remainder by zero gives 0, a comparison or a
|
||||
/// logical operator gives 1 or 0, and any non-zero value counts as true.
|
||||
/// </summary>
|
||||
public class FormulaNode
|
||||
{
|
||||
private static readonly FormulaNode[] NoChildren = Array.Empty<FormulaNode>();
|
||||
|
||||
/// <summary>Creates a node; <paramref name="children"/> may be null for leaves.</summary>
|
||||
public FormulaNode(FormulaNodeKind kind, double value = 0, string name = null, string op = null, FormulaNode[] children = null)
|
||||
{
|
||||
Kind = kind;
|
||||
Value = value;
|
||||
Name = name;
|
||||
Op = op;
|
||||
Children = children ?? NoChildren;
|
||||
}
|
||||
|
||||
/// <summary>What the node computes.</summary>
|
||||
public FormulaNodeKind Kind { get; }
|
||||
|
||||
/// <summary>The literal of a <see cref="FormulaNodeKind.Number"/>.</summary>
|
||||
public double Value { get; }
|
||||
|
||||
/// <summary>The variable or function name (lowercase).</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>The operator of a unary or binary node.</summary>
|
||||
public string Op { get; }
|
||||
|
||||
/// <summary>Operands or arguments, in source order.</summary>
|
||||
public IReadOnlyList<FormulaNode> Children { get; }
|
||||
|
||||
/// <summary>Computes the node in <paramref name="scope"/>; an unknown name reads as 0.</summary>
|
||||
public double Evaluate(IFormulaScope scope)
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case FormulaNodeKind.Number:
|
||||
return Value;
|
||||
case FormulaNodeKind.Variable:
|
||||
return scope != null && scope.TryGet(Name, out var v) ? v : 0;
|
||||
case FormulaNodeKind.Unary:
|
||||
var operand = Children[0].Evaluate(scope);
|
||||
return Op switch
|
||||
{
|
||||
"-" => -operand,
|
||||
"!" => operand == 0 ? 1 : 0,
|
||||
_ => operand
|
||||
};
|
||||
case FormulaNodeKind.Binary:
|
||||
return EvaluateBinary(scope);
|
||||
case FormulaNodeKind.Conditional:
|
||||
return Children[0].Evaluate(scope) != 0 ? Children[1].Evaluate(scope) : Children[2].Evaluate(scope);
|
||||
case FormulaNodeKind.Call:
|
||||
var args = new double[Children.Count];
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
args[i] = Children[i].Evaluate(scope);
|
||||
return FormulaFunctions.Call(Name, args, scope?.Random);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every variable name the formula reads, once each, in first-use order (tools list and check them).</summary>
|
||||
public List<string> Variables()
|
||||
{
|
||||
var names = new List<string>();
|
||||
Collect(names);
|
||||
return names;
|
||||
}
|
||||
|
||||
private void Collect(List<string> names)
|
||||
{
|
||||
if (Kind == FormulaNodeKind.Variable && !names.Contains(Name))
|
||||
names.Add(Name);
|
||||
foreach (var child in Children)
|
||||
child.Collect(names);
|
||||
}
|
||||
|
||||
private double EvaluateBinary(IFormulaScope scope)
|
||||
{
|
||||
// && and || short-circuit, so "b.hp > 0 && 100 / b.hp" never divides by zero needlessly.
|
||||
if (Op == "&&")
|
||||
return Children[0].Evaluate(scope) != 0 && Children[1].Evaluate(scope) != 0 ? 1 : 0;
|
||||
if (Op == "||")
|
||||
return Children[0].Evaluate(scope) != 0 || Children[1].Evaluate(scope) != 0 ? 1 : 0;
|
||||
|
||||
var a = Children[0].Evaluate(scope);
|
||||
var b = Children[1].Evaluate(scope);
|
||||
return Op switch
|
||||
{
|
||||
"+" => a + b,
|
||||
"-" => a - b,
|
||||
"*" => a * b,
|
||||
"/" => b == 0 ? 0 : a / b,
|
||||
"%" => b == 0 ? 0 : a % b,
|
||||
"^" => Math.Pow(a, b),
|
||||
"<" => a < b ? 1 : 0,
|
||||
">" => a > b ? 1 : 0,
|
||||
"<=" => a <= b ? 1 : 0,
|
||||
">=" => a >= b ? 1 : 0,
|
||||
"==" => a == b ? 1 : 0,
|
||||
"!=" => a != b ? 1 : 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
/// <summary>What a <see cref="FormulaNode"/> computes.</summary>
|
||||
public enum FormulaNodeKind
|
||||
{
|
||||
/// <summary>A number literal (<see cref="FormulaNode.Value"/>).</summary>
|
||||
Number,
|
||||
|
||||
/// <summary>A named value supplied by the scope (<see cref="FormulaNode.Name"/>): a stat, <c>level</c>, <c>a.atk</c>.</summary>
|
||||
Variable,
|
||||
|
||||
/// <summary>A prefix operator over one child: <c>-</c>, <c>+</c>, <c>!</c>.</summary>
|
||||
Unary,
|
||||
|
||||
/// <summary>An infix operator over two children (<see cref="FormulaNode.Op"/>).</summary>
|
||||
Binary,
|
||||
|
||||
/// <summary><c>condition ? then : else</c> over three children.</summary>
|
||||
Conditional,
|
||||
|
||||
/// <summary>A function call (<see cref="FormulaNode.Name"/>) over its argument children.</summary>
|
||||
Call
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
/// <summary>
|
||||
/// Recursive-descent parser of the formula language. Precedence from loose to tight:
|
||||
/// <c>?:</c>, <c>||</c>, <c>&&</c>, comparisons, <c>+ -</c>, <c>* / %</c>,
|
||||
/// prefix <c>- + !</c>, <c>^</c> (right-associative), then numbers, names, calls and
|
||||
/// parentheses. A name may contain dots (<c>a.atk</c>, <c>hero.str</c>): the dot is
|
||||
/// part of the name, the scope decides what it means. Names and functions are case
|
||||
/// insensitive and stored lowercase, like stat ids.
|
||||
/// </summary>
|
||||
public class FormulaParser
|
||||
{
|
||||
private readonly string text;
|
||||
private int pos;
|
||||
|
||||
private FormulaParser(string text)
|
||||
{
|
||||
this.text = text ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Parses <paramref name="formula"/>; throws <see cref="FormulaException"/> on a syntax error.</summary>
|
||||
public static FormulaNode Parse(string formula)
|
||||
{
|
||||
var parser = new FormulaParser(formula);
|
||||
parser.SkipSpace();
|
||||
if (parser.AtEnd)
|
||||
throw new FormulaException(parser.text, 0, "empty formula");
|
||||
var node = parser.ParseConditional();
|
||||
parser.SkipSpace();
|
||||
if (!parser.AtEnd)
|
||||
throw parser.Error($"unexpected '{parser.text[parser.pos]}'");
|
||||
return node;
|
||||
}
|
||||
|
||||
private bool AtEnd => pos >= text.Length;
|
||||
|
||||
private FormulaNode ParseConditional()
|
||||
{
|
||||
var condition = ParseOr();
|
||||
if (!Match("?"))
|
||||
return condition;
|
||||
var then = ParseConditional();
|
||||
Expect(":");
|
||||
var otherwise = ParseConditional();
|
||||
return new FormulaNode(FormulaNodeKind.Conditional, children: new[] { condition, then, otherwise });
|
||||
}
|
||||
|
||||
private FormulaNode ParseOr()
|
||||
{
|
||||
var left = ParseAnd();
|
||||
while (Match("||"))
|
||||
left = Binary("||", left, ParseAnd());
|
||||
return left;
|
||||
}
|
||||
|
||||
private FormulaNode ParseAnd()
|
||||
{
|
||||
var left = ParseComparison();
|
||||
while (Match("&&"))
|
||||
left = Binary("&&", left, ParseComparison());
|
||||
return left;
|
||||
}
|
||||
|
||||
private FormulaNode ParseComparison()
|
||||
{
|
||||
var left = ParseAdditive();
|
||||
foreach (var op in new[] { "<=", ">=", "==", "!=", "<", ">" })
|
||||
{
|
||||
if (Match(op))
|
||||
return Binary(op, left, ParseAdditive());
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private FormulaNode ParseAdditive()
|
||||
{
|
||||
var left = ParseMultiplicative();
|
||||
while (true)
|
||||
{
|
||||
if (Match("+"))
|
||||
left = Binary("+", left, ParseMultiplicative());
|
||||
else if (Match("-"))
|
||||
left = Binary("-", left, ParseMultiplicative());
|
||||
else
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
private FormulaNode ParseMultiplicative()
|
||||
{
|
||||
var left = ParseUnary();
|
||||
while (true)
|
||||
{
|
||||
if (Match("*"))
|
||||
left = Binary("*", left, ParseUnary());
|
||||
else if (Match("/"))
|
||||
left = Binary("/", left, ParseUnary());
|
||||
else if (Match("%"))
|
||||
left = Binary("%", left, ParseUnary());
|
||||
else
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
private FormulaNode ParseUnary()
|
||||
{
|
||||
// "!=" is a comparison, never a negation followed by "=".
|
||||
if (Peek("!") && !Peek("!="))
|
||||
{
|
||||
Match("!");
|
||||
return new FormulaNode(FormulaNodeKind.Unary, op: "!", children: new[] { ParseUnary() });
|
||||
}
|
||||
if (Match("-"))
|
||||
return new FormulaNode(FormulaNodeKind.Unary, op: "-", children: new[] { ParseUnary() });
|
||||
if (Match("+"))
|
||||
return ParseUnary();
|
||||
return ParsePower();
|
||||
}
|
||||
|
||||
private FormulaNode ParsePower()
|
||||
{
|
||||
var left = ParsePrimary();
|
||||
return Match("^") ? Binary("^", left, ParseUnary()) : left;
|
||||
}
|
||||
|
||||
private FormulaNode ParsePrimary()
|
||||
{
|
||||
SkipSpace();
|
||||
if (AtEnd)
|
||||
throw Error("expected a number, a name or '('");
|
||||
|
||||
var c = text[pos];
|
||||
if (Match("("))
|
||||
{
|
||||
var inner = ParseConditional();
|
||||
Expect(")");
|
||||
return inner;
|
||||
}
|
||||
if (char.IsDigit(c) || c == '.')
|
||||
return ParseNumber();
|
||||
if (char.IsLetter(c) || c == '_')
|
||||
return ParseName();
|
||||
throw Error($"unexpected '{c}'");
|
||||
}
|
||||
|
||||
private FormulaNode ParseNumber()
|
||||
{
|
||||
var start = pos;
|
||||
while (!AtEnd && (char.IsDigit(text[pos]) || text[pos] == '.'))
|
||||
pos++;
|
||||
var literal = text.Substring(start, pos - start);
|
||||
if (!double.TryParse(literal, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
|
||||
throw new FormulaException(text, start, $"bad number '{literal}'");
|
||||
return new FormulaNode(FormulaNodeKind.Number, value);
|
||||
}
|
||||
|
||||
private FormulaNode ParseName()
|
||||
{
|
||||
var start = pos;
|
||||
while (!AtEnd && (char.IsLetterOrDigit(text[pos]) || text[pos] == '_' || text[pos] == '.'))
|
||||
pos++;
|
||||
var name = text.Substring(start, pos - start).TrimEnd('.').ToLowerInvariant();
|
||||
if (!Match("("))
|
||||
return new FormulaNode(FormulaNodeKind.Variable, name: name);
|
||||
|
||||
var args = new List<FormulaNode>();
|
||||
if (!Match(")"))
|
||||
{
|
||||
do
|
||||
args.Add(ParseConditional());
|
||||
while (Match(","));
|
||||
Expect(")");
|
||||
}
|
||||
if (!FormulaFunctions.Arity.ContainsKey(name))
|
||||
throw new FormulaException(text, start, $"unknown function '{name}'");
|
||||
if (!FormulaFunctions.Accepts(name, args.Count))
|
||||
throw new FormulaException(text, start, $"'{name}' does not take {args.Count} argument(s)");
|
||||
return new FormulaNode(FormulaNodeKind.Call, name: name, children: args.ToArray());
|
||||
}
|
||||
|
||||
private static FormulaNode Binary(string op, FormulaNode left, FormulaNode right)
|
||||
=> new(FormulaNodeKind.Binary, op: op, children: new[] { left, right });
|
||||
|
||||
private void SkipSpace()
|
||||
{
|
||||
while (!AtEnd && char.IsWhiteSpace(text[pos]))
|
||||
pos++;
|
||||
}
|
||||
|
||||
private bool Peek(string token)
|
||||
{
|
||||
SkipSpace();
|
||||
return string.CompareOrdinal(text, pos, token, 0, token.Length) == 0;
|
||||
}
|
||||
|
||||
private bool Match(string token)
|
||||
{
|
||||
if (!Peek(token))
|
||||
return false;
|
||||
pos += token.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Expect(string token)
|
||||
{
|
||||
if (!Match(token))
|
||||
throw Error($"expected '{token}'");
|
||||
}
|
||||
|
||||
private FormulaException Error(string message) => new(text, pos, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Cthangover.Mods.Stats.Formulas
|
||||
{
|
||||
using System;
|
||||
|
||||
/// <summary>
|
||||
/// The names a formula can read. Who answers them decides what a formula
|
||||
/// means: a stat sheet answers <c>str</c> and <c>level</c> of one character,
|
||||
/// a battle skill answers <c>a.atk</c> (the user) and <c>b.def</c> (the
|
||||
/// target). Formulas never reach game state any other way, which keeps them
|
||||
/// pure data that tools can check and tests can evaluate without a game.
|
||||
/// </summary>
|
||||
public interface IFormulaScope
|
||||
{
|
||||
/// <summary>The value of <paramref name="name"/>; false when the scope does not know the name (it then reads as 0).</summary>
|
||||
bool TryGet(string name, out double value);
|
||||
|
||||
/// <summary>The dice of <c>rand</c> and <c>chance</c>; a test passes a seeded one.</summary>
|
||||
Random Random { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>The scope of an exp curve: only <c>level</c>, the level being reached.</summary>
|
||||
public class LevelScope : IFormulaScope
|
||||
{
|
||||
private readonly int level;
|
||||
|
||||
/// <summary>Scope for reaching <paramref name="level"/>.</summary>
|
||||
public LevelScope(int level)
|
||||
{
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string name, out double value)
|
||||
{
|
||||
value = level;
|
||||
return string.Equals(name, "level", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Random Random => Random.Shared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.UI;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Teaches the battle actions of an archetype's <see cref="ArchetypeInfo.Learnings"/>
|
||||
/// when a party member reaches their level or takes the archetype. It works with the
|
||||
/// engine's own action model rather than beside it: a learned action goes into a free
|
||||
/// action slot of the member (three slots, as the character panel shows), else into the
|
||||
/// shared action pool, from where the player assigns it in the character panel — the
|
||||
/// same way a granted skill card arrives.
|
||||
/// <para/>
|
||||
/// Every action is taught once per character (remembered in the stats state), so
|
||||
/// switching archetypes back and forth never duplicates cards.
|
||||
/// </summary>
|
||||
public static class StatLearnings
|
||||
{
|
||||
/// <summary>Slots a party member has for actions (the character panel's three).</summary>
|
||||
public const int SlotCount = 3;
|
||||
|
||||
/// <summary>Teaches what the archetype of <paramref name="characterId"/> offers up to its level; returns the new action ids.</summary>
|
||||
public static List<string> Apply(string characterId)
|
||||
{
|
||||
var taught = new List<string>();
|
||||
var entry = StatsService.RosterEntry(characterId);
|
||||
var archetype = StatsService.Archetype(StatsService.ArchetypeOf(characterId));
|
||||
if (entry == null || archetype?.Learnings == null)
|
||||
return taught;
|
||||
var level = StatsService.LevelOf(characterId);
|
||||
var state = StatState.Instance.Of(characterId);
|
||||
foreach (var learning in archetype.Learnings.Where(l => l != null && !string.IsNullOrWhiteSpace(l.Action) && l.Level <= level))
|
||||
{
|
||||
if (state.Learned.Contains(learning.Action, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
state.Learned.Add(learning.Action);
|
||||
Place(entry, learning.Action);
|
||||
taught.Add(learning.Action);
|
||||
Notify(characterId, learning.Action);
|
||||
}
|
||||
return taught;
|
||||
}
|
||||
|
||||
private static void Place(CharacterInfoData entry, string action)
|
||||
{
|
||||
entry.ActionSlots ??= new List<string>();
|
||||
if (entry.ActionSlots.Contains(action))
|
||||
return;
|
||||
var free = entry.ActionSlots.FindIndex(string.IsNullOrEmpty);
|
||||
if (free >= 0)
|
||||
{
|
||||
entry.ActionSlots[free] = action;
|
||||
return;
|
||||
}
|
||||
if (entry.ActionSlots.Count < SlotCount)
|
||||
{
|
||||
entry.ActionSlots.Add(action);
|
||||
return;
|
||||
}
|
||||
var pool = GameData.Instance?.Runtime?.ActionPool;
|
||||
if (pool != null && !pool.ActionIds.Contains(action))
|
||||
pool.ActionIds.Add(action);
|
||||
}
|
||||
|
||||
private static void Notify(string characterId, string action)
|
||||
{
|
||||
var who = StatsService.Template(characterId)?.Name;
|
||||
var actionName = Entities.Collect<ActionCharacterInfo>().TryGetValue(action, out var info) ? info.Name : action;
|
||||
NotificationBridge.Show(string.Format(TranslationServer.Translate("stats/learned").ToString(),
|
||||
TranslationServer.Translate(who ?? characterId).ToString(), TranslationServer.Translate(actionName ?? action).ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
/// <summary>
|
||||
/// Current amounts of pool stats (<see cref="StatInfo.Pool"/>: mana, stamina, willpower).
|
||||
/// The sheet value is the maximum; the state keeps only what was spent, so a missing
|
||||
/// entry means full and a grown maximum is felt at once. Health is not a pool here: it
|
||||
/// already lives in the battle attributes, which every battle core reads and writes.
|
||||
/// </summary>
|
||||
public static class StatPools
|
||||
{
|
||||
/// <summary>The current amount of <paramref name="stat"/> (0..max).</summary>
|
||||
public static double Current(string who, string stat)
|
||||
{
|
||||
var id = StatsService.Resolve(who);
|
||||
var max = StatsService.Sheet(id).Get(stat);
|
||||
var state = StatState.Instance.Find(id);
|
||||
if (state == null || stat == null || !state.Pools.TryGetValue(stat.ToLowerInvariant(), out var current))
|
||||
return max;
|
||||
return Math.Clamp(current, 0, Math.Max(0, max));
|
||||
}
|
||||
|
||||
/// <summary>Spends <paramref name="amount"/>; false (nothing spent) when there is not enough.</summary>
|
||||
public static bool Spend(string who, string stat, double amount)
|
||||
{
|
||||
var id = StatsService.Resolve(who);
|
||||
if (amount <= 0)
|
||||
return true;
|
||||
var current = Current(id, stat);
|
||||
if (current < amount)
|
||||
return false;
|
||||
StatState.Instance.Of(id).Pools[stat.ToLowerInvariant()] = current - amount;
|
||||
StatsService.Changed(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Fills <paramref name="stat"/> (null: every pool) of <paramref name="who"/> to the maximum.</summary>
|
||||
public static void Refill(string who, string stat = null)
|
||||
{
|
||||
var id = StatsService.Resolve(who);
|
||||
var state = StatState.Instance.Find(id);
|
||||
if (state == null)
|
||||
return;
|
||||
if (stat == null)
|
||||
state.Pools.Clear();
|
||||
else
|
||||
state.Pools.Remove(stat.ToLowerInvariant());
|
||||
StatsService.Changed(id);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="stat"/> is defined as a pool.</summary>
|
||||
public static bool IsPool(string stat) => StatsService.Definitions.Any(d => d.Pool && string.Equals(d.ID, stat, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.UI;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Exp and levels. Battles already add exp to the party roster; this class turns exp
|
||||
/// into levels by the character's exp curve, and lets scenarios give exp to anyone
|
||||
/// (<c>action stat.exp</c>) — a quest reward in an RPG, a lesson learned in a novel.
|
||||
/// <para/>
|
||||
/// The curve is the total exp needed to reach a level, a formula of <c>level</c>: the
|
||||
/// archetype's <see cref="ArchetypeInfo.ExpCurve"/>, else the game's setting
|
||||
/// <c>stats.exp_curve</c>, else <see cref="DefaultCurve"/> (20 for level 2, 60 for 3,
|
||||
/// 120 for 4…). Only characters with an archetype level up, unless the game sets
|
||||
/// <c>stats.leveling</c> to <c>all</c>: games that never heard of archetypes keep
|
||||
/// their levels exactly as before.
|
||||
/// </summary>
|
||||
public static class StatProgression
|
||||
{
|
||||
/// <summary>The curve used when neither the archetype nor the game names one.</summary>
|
||||
public const string DefaultCurve = "10 * level * (level - 1)";
|
||||
|
||||
/// <summary>The level cap when the archetype names none.</summary>
|
||||
public const int DefaultMaxLevel = 99;
|
||||
|
||||
/// <summary>Total exp needed to reach <paramref name="level"/> for <paramref name="who"/>.</summary>
|
||||
public static int ExpFor(string who, int level)
|
||||
{
|
||||
var archetype = StatsService.Archetype(StatsService.ArchetypeOf(who));
|
||||
var curve = archetype?.ExpCurve;
|
||||
if (string.IsNullOrWhiteSpace(curve))
|
||||
curve = ActiveGame.GetSetting("stats.exp_curve");
|
||||
if (string.IsNullOrWhiteSpace(curve))
|
||||
curve = DefaultCurve;
|
||||
return (int)Math.Max(0, Math.Round(Formula.Evaluate(curve, new LevelScope(level))));
|
||||
}
|
||||
|
||||
/// <summary>The level cap of <paramref name="who"/>.</summary>
|
||||
public static int MaxLevel(string who)
|
||||
{
|
||||
var max = StatsService.Archetype(StatsService.ArchetypeOf(who))?.MaxLevel ?? 0;
|
||||
return max > 0 ? max : DefaultMaxLevel;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="who"/> gains levels from exp.</summary>
|
||||
public static bool Levels(string who)
|
||||
=> !string.IsNullOrWhiteSpace(StatsService.ArchetypeOf(who))
|
||||
|| string.Equals(ActiveGame.GetSetting("stats.leveling"), "all", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Adds exp (negative is ignored) and applies the level-ups it brings.</summary>
|
||||
public static void AddExp(string who, int amount)
|
||||
{
|
||||
var id = StatsService.Resolve(who);
|
||||
if (amount <= 0)
|
||||
return;
|
||||
var entry = StatsService.RosterEntry(id);
|
||||
if (entry != null)
|
||||
entry.Exp += amount;
|
||||
else
|
||||
StatState.Instance.Of(id).Exp += amount;
|
||||
ApplyLevelUps(id);
|
||||
StatsService.Changed(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the level of <paramref name="characterId"/> while its exp reaches the
|
||||
/// next level, showing one notification per level; returns the levels gained.
|
||||
/// </summary>
|
||||
public static int ApplyLevelUps(string characterId)
|
||||
{
|
||||
if (!Levels(characterId))
|
||||
return 0;
|
||||
var gained = 0;
|
||||
var level = StatsService.LevelOf(characterId);
|
||||
var exp = StatsService.ExpOf(characterId);
|
||||
var max = MaxLevel(characterId);
|
||||
while (level < max && exp >= ExpFor(characterId, level + 1))
|
||||
{
|
||||
level++;
|
||||
gained++;
|
||||
SetLevel(characterId, level);
|
||||
Notify(characterId, level);
|
||||
}
|
||||
return gained;
|
||||
}
|
||||
|
||||
private static void SetLevel(string characterId, int level)
|
||||
{
|
||||
var entry = StatsService.RosterEntry(characterId);
|
||||
if (entry != null)
|
||||
entry.Level = level;
|
||||
else
|
||||
StatState.Instance.Of(characterId).Level = level;
|
||||
}
|
||||
|
||||
private static void Notify(string characterId, int level)
|
||||
{
|
||||
var nameKey = StatsService.Template(characterId)?.Name;
|
||||
var name = string.IsNullOrEmpty(nameKey) ? characterId : TranslationServer.Translate(nameKey).ToString();
|
||||
NotificationBridge.Show(string.Format(TranslationServer.Translate("stats/level_up").ToString(), name, level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a <see cref="StatSheet"/> from <see cref="StatInputs"/>. Pure: no game
|
||||
/// state, no Godot, so the whole stat model is testable as plain data.
|
||||
/// <para/>
|
||||
/// Stats are resolved lazily and memoised, in whatever order formulas ask for them, so
|
||||
/// a derived stat may read any other stat regardless of definition order. A formula
|
||||
/// that reads itself through a chain (<c>a</c> needs <c>b</c> needs <c>a</c>) gets 0
|
||||
/// for the looping read and the cycle is logged under <c>STATS</c> once per sheet.
|
||||
/// <para/>
|
||||
/// The set of stats on a sheet is the union of the definitions, the character's own
|
||||
/// entries, the archetype's entries and the traits' stats: a mod may give a character
|
||||
/// a stat nobody defined, and it still works in formulas and conditions (undefined
|
||||
/// stats are simply not clamped and not shown by the sheet UI).
|
||||
/// </summary>
|
||||
public class StatCalculator : IFormulaScope
|
||||
{
|
||||
private readonly StatInputs inputs;
|
||||
private readonly string owner;
|
||||
private readonly Dictionary<string, double> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, double> baseValues = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> computing = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, List<TraitInfo>> traitsByStat = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> universe = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool cycleLogged;
|
||||
|
||||
private StatCalculator(string owner, StatInputs inputs)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.inputs = inputs;
|
||||
foreach (var id in inputs.Definitions.Keys)
|
||||
universe.Add(id);
|
||||
foreach (var id in inputs.Own.Keys)
|
||||
universe.Add(id);
|
||||
if (inputs.Archetype?.Stats != null)
|
||||
{
|
||||
foreach (var id in inputs.Archetype.Stats.Keys)
|
||||
universe.Add(id);
|
||||
}
|
||||
foreach (var trait in inputs.Traits.Where(t => !string.IsNullOrWhiteSpace(t?.Stat)))
|
||||
{
|
||||
universe.Add(trait.Stat);
|
||||
if (!traitsByStat.TryGetValue(trait.Stat, out var list))
|
||||
traitsByStat[trait.Stat] = list = new List<TraitInfo>();
|
||||
list.Add(trait);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Computes the sheet of <paramref name="characterId"/>.</summary>
|
||||
public static StatSheet Calculate(string characterId, StatInputs inputs)
|
||||
{
|
||||
var calc = new StatCalculator(characterId, inputs ?? new StatInputs());
|
||||
foreach (var stat in calc.universe.ToList())
|
||||
calc.Value(stat);
|
||||
return new StatSheet(characterId, calc.inputs.Level, calc.inputs.Archetype?.ID, calc.values, calc.baseValues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string name, out double value)
|
||||
{
|
||||
if (string.Equals(name, "level", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = inputs.Level;
|
||||
return true;
|
||||
}
|
||||
if (name == null || !universe.Contains(name))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
value = Value(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Random Random => Random.Shared;
|
||||
|
||||
private double Value(string stat)
|
||||
{
|
||||
if (values.TryGetValue(stat, out var done))
|
||||
return done;
|
||||
if (!computing.Add(stat))
|
||||
{
|
||||
if (!cycleLogged)
|
||||
GameLogger.Log("STATS", $"{owner}: stat '{stat}' depends on itself through its formulas; the looping read counts as 0", LogLevel.Error);
|
||||
cycleLogged = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
inputs.Definitions.TryGetValue(stat, out var def);
|
||||
var fallback = def?.Default ?? 0;
|
||||
var raw = Formula.Evaluate(SourceOf(stat, def), this, fallback);
|
||||
if (inputs.Bonus.TryGetValue(stat, out var bonus))
|
||||
raw += bonus;
|
||||
baseValues[stat] = raw;
|
||||
|
||||
var add = 0d;
|
||||
var percent = 0d;
|
||||
if (traitsByStat.TryGetValue(stat, out var traits))
|
||||
{
|
||||
foreach (var trait in traits)
|
||||
{
|
||||
add += trait.Add + Formula.Evaluate(trait.Formula, this);
|
||||
percent += trait.Percent;
|
||||
}
|
||||
}
|
||||
|
||||
var result = (raw + add) * (1 + percent / 100);
|
||||
if (def?.Min != null)
|
||||
result = Math.Max(result, def.Min.Value);
|
||||
if (def?.Max != null)
|
||||
result = Math.Min(result, def.Max.Value);
|
||||
|
||||
computing.Remove(stat);
|
||||
values[stat] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private string SourceOf(string stat, StatInfo def)
|
||||
{
|
||||
if (inputs.Own.TryGetValue(stat, out var own))
|
||||
return own;
|
||||
if (inputs.Archetype?.Stats != null)
|
||||
{
|
||||
foreach (var pair in inputs.Archetype.Stats)
|
||||
{
|
||||
if (string.Equals(pair.Key, stat, StringComparison.OrdinalIgnoreCase))
|
||||
return pair.Value;
|
||||
}
|
||||
}
|
||||
return def?.Formula;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Everything a stat sheet is computed from, gathered by <see cref="StatsService"/>
|
||||
/// from data (definitions, template, archetype), the saved state (bonuses, level) and
|
||||
/// the trait sources (equipment, statuses, …). Kept as a plain bag so
|
||||
/// <see cref="StatCalculator"/> stays a pure function that tests feed directly.
|
||||
/// </summary>
|
||||
public class StatInputs
|
||||
{
|
||||
/// <summary>The character's level (<c>level</c> in formulas).</summary>
|
||||
public int Level { get; set; } = 1;
|
||||
|
||||
/// <summary>Stat definitions by id.</summary>
|
||||
public IReadOnlyDictionary<string, StatInfo> Definitions { get; set; } = new Dictionary<string, StatInfo>();
|
||||
|
||||
/// <summary>The character's own <c>Stats</c> entries (value or formula per stat id).</summary>
|
||||
public IReadOnlyDictionary<string, string> Own { get; set; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>The character's archetype, or null.</summary>
|
||||
public ArchetypeInfo Archetype { get; set; }
|
||||
|
||||
/// <summary>Permanent bonuses per stat id (potions, story choices) from the stats state.</summary>
|
||||
public IReadOnlyDictionary<string, double> Bonus { get; set; } = new Dictionary<string, double>();
|
||||
|
||||
/// <summary>Every trait that applies to the character now, from all sources.</summary>
|
||||
public IReadOnlyList<TraitInfo> Traits { get; set; } = new List<TraitInfo>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// The computed stats of one character at one moment: every stat's final value and
|
||||
/// the value before traits (so a sheet can show "12 (+3)"). A sheet is immutable;
|
||||
/// <see cref="StatsService"/> computes a new one when something it depends on changes.
|
||||
/// It is also a formula scope (<c>level</c> and stat ids), which is how conditions,
|
||||
/// checks and battle formulas read a character.
|
||||
/// </summary>
|
||||
public class StatSheet : IFormulaScope
|
||||
{
|
||||
private readonly Dictionary<string, double> values;
|
||||
private readonly Dictionary<string, double> baseValues;
|
||||
|
||||
/// <summary>Creates a sheet; dictionaries are copied with case-insensitive keys.</summary>
|
||||
public StatSheet(string characterId, int level, string archetypeId, IDictionary<string, double> values, IDictionary<string, double> baseValues)
|
||||
{
|
||||
CharacterId = characterId;
|
||||
Level = level;
|
||||
ArchetypeId = archetypeId;
|
||||
this.values = new Dictionary<string, double>(values, StringComparer.OrdinalIgnoreCase);
|
||||
this.baseValues = new Dictionary<string, double>(baseValues, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Whose sheet it is (a character id, or the free player id of a game without a party).</summary>
|
||||
public string CharacterId { get; }
|
||||
|
||||
/// <summary>The level the sheet was computed for.</summary>
|
||||
public int Level { get; }
|
||||
|
||||
/// <summary>The archetype in effect, or null.</summary>
|
||||
public string ArchetypeId { get; }
|
||||
|
||||
/// <summary>Final values by stat id.</summary>
|
||||
public IReadOnlyDictionary<string, double> Values => values;
|
||||
|
||||
/// <summary>Whether the sheet has the stat.</summary>
|
||||
public bool Has(string stat) => stat != null && values.ContainsKey(stat);
|
||||
|
||||
/// <summary>The final value (0 for an unknown stat).</summary>
|
||||
public double Get(string stat) => stat != null && values.TryGetValue(stat, out var v) ? v : 0;
|
||||
|
||||
/// <summary>The value before traits (own, archetype or formula value plus permanent bonuses).</summary>
|
||||
public double GetBase(string stat) => stat != null && baseValues.TryGetValue(stat, out var v) ? v : 0;
|
||||
|
||||
/// <summary>The final value rounded half away from zero: what battles and checks use.</summary>
|
||||
public int Rounded(string stat) => (int)Math.Round(Get(stat), MidpointRounding.AwayFromZero);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string name, out double value)
|
||||
{
|
||||
if (string.Equals(name, "level", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = Level;
|
||||
return true;
|
||||
}
|
||||
return values.TryGetValue(name ?? string.Empty, out value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Random Random => Random.Shared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// What one character's stats gained during play, saved with the game: permanent
|
||||
/// bonuses, the current amount of pool stats, a changed archetype, and the level and
|
||||
/// exp of a character outside the party roster (a novel's protagonist usually is not
|
||||
/// in the battle roster, but still grows). Party members keep their level and exp in
|
||||
/// the core roster, so battles and the stats agree on one number.
|
||||
/// </summary>
|
||||
public class CharacterStatState
|
||||
{
|
||||
/// <summary>Archetype chosen during play; null keeps the template's.</summary>
|
||||
public string Archetype { get; set; }
|
||||
|
||||
/// <summary>Permanent bonus per stat id.</summary>
|
||||
public Dictionary<string, double> Bonus { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Current amount per pool stat id; a missing entry means full.</summary>
|
||||
public Dictionary<string, double> Pools { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Level of a character outside the roster (0 = take the template's).</summary>
|
||||
public int Level { get; set; }
|
||||
|
||||
/// <summary>Exp of a character outside the roster.</summary>
|
||||
public int Exp { get; set; }
|
||||
|
||||
/// <summary>Action ids already taught by archetypes (each is taught once, even after a class change and back).</summary>
|
||||
public List<string> Learned { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
/// <summary>
|
||||
/// The running game's <see cref="StatStateData"/>. Replaced on a new game and on a
|
||||
/// load (<see cref="StatsReset"/>, <see cref="StatsSaveable"/>); ids are stored as
|
||||
/// written, lookups ignore case.
|
||||
/// </summary>
|
||||
public class StatState
|
||||
{
|
||||
/// <summary>Shared instance.</summary>
|
||||
public static StatState Instance { get; } = new();
|
||||
|
||||
/// <summary>The saved data.</summary>
|
||||
public StatStateData Data { get; set; } = new();
|
||||
|
||||
/// <summary>The state of <paramref name="characterId"/>, or null when it gained nothing yet.</summary>
|
||||
public CharacterStatState Find(string characterId)
|
||||
{
|
||||
if (characterId == null)
|
||||
return null;
|
||||
if (Data.Characters.TryGetValue(characterId, out var state))
|
||||
return state;
|
||||
foreach (var pair in Data.Characters)
|
||||
{
|
||||
if (string.Equals(pair.Key, characterId, System.StringComparison.OrdinalIgnoreCase))
|
||||
return pair.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The state of <paramref name="characterId"/>, created when missing.</summary>
|
||||
public CharacterStatState Of(string characterId)
|
||||
{
|
||||
var state = Find(characterId);
|
||||
if (state != null)
|
||||
return state;
|
||||
state = new CharacterStatState();
|
||||
Data.Characters[characterId] = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>Back to a new game's empty state.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
Data = new StatStateData();
|
||||
StatsService.ClearCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>The saved stats of a game: per character id (the stats slot's one blob).</summary>
|
||||
public class StatStateData
|
||||
{
|
||||
/// <summary>Character id → what it gained during play.</summary>
|
||||
public Dictionary<string, CharacterStatState> Characters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Bootstrap;
|
||||
|
||||
/// <summary>A new game (or the reset before a load) starts with no gained stats.</summary>
|
||||
public class StatsReset : IResettable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Id => "stats";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetForNewGame() => StatState.Instance.Reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
/// <summary>Saves what characters gained (<see cref="StatStateData"/>) in the stats slot.</summary>
|
||||
public class StatsSaveable : IModSaveable
|
||||
{
|
||||
private const string BlobKey = "Stats";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnSave(ModSlot slot)
|
||||
{
|
||||
slot.Data[BlobKey] = new DataBlob(1, StatState.Instance.Data);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool OnLoad(ModSlot slot, out string errorMessage)
|
||||
{
|
||||
errorMessage = null;
|
||||
StatState.Instance.Reset();
|
||||
if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob))
|
||||
StatState.Instance.Data = blob.ReadJson<StatStateData>() ?? new StatStateData();
|
||||
StatsService.ClearCache();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cthangover.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// Stats of a character may have changed (a bonus, an archetype, a level, equipment, a
|
||||
/// trait source). UI redraws on it; <see cref="CharacterId"/> is null when anyone's
|
||||
/// stats may have changed (data reload, a trait source added or removed).
|
||||
/// </summary>
|
||||
[EventKey("stats_changed")]
|
||||
public class StatsChangedEvent : ModEvent
|
||||
{
|
||||
/// <summary>The character, or null for everyone.</summary>
|
||||
public string CharacterId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Scenes.Parsing;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Wires the stats into the engine: the <c>stat()</c>/<c>level()</c>/<c>archetype()</c>
|
||||
/// conditions, the new-game reset and the roster sync on scene entry and dialog end.
|
||||
/// <para/>
|
||||
/// The condition parser goes first in the registry; the kernel removes it with this
|
||||
/// mod's assembly on unload.
|
||||
/// </summary>
|
||||
public class StatsModInitializer : IModInitializer
|
||||
{
|
||||
private static bool registered;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
if (registered)
|
||||
return;
|
||||
registered = true;
|
||||
LeafParserRegistry.RegisterFirst(new StatLeafParser());
|
||||
ModEventBus.Subscribe<SceneEnteredEvent>(StatSync.OnSceneEntered);
|
||||
ModEventBus.Subscribe<DialogEndedEvent>(StatSync.OnDialogEnded);
|
||||
GameLogger.Log("STATS", "stats: conditions stat()/level()/archetype() and the roster sync registered");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
AppServices.Current.RegisterResettable(new StatsReset());
|
||||
StatsService.ClearCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// The stats of every character in one place: computes and caches sheets, keeps what
|
||||
/// characters gained during play (<see cref="StatState"/>) and tells everyone when
|
||||
/// stats change. Everything else — scenario actions, conditions, checks, the sheet
|
||||
/// panel, equipment, battle formulas — goes through this facade, so there is one
|
||||
/// answer to "how strong is the hero now" in a novel, a tile RPG or any other game.
|
||||
/// <para/>
|
||||
/// <b>Who.</b> Every call takes a "who": a character id, or <c>player</c>. The player
|
||||
/// is the game's setting <c>stats.player</c>, else the first member of the battle set,
|
||||
/// else the free id <c>player</c> — a protagonist with a sheet but without a party
|
||||
/// card, which is what a visual novel needs.
|
||||
/// <para/>
|
||||
/// <b>Where numbers live.</b> Party members keep level and exp in the core roster
|
||||
/// (battles award exp there); others keep them in the stats state. Bound stats
|
||||
/// (<see cref="StatInfo.Binds"/>) are written into the roster's battle attributes by
|
||||
/// <see cref="StatSync"/>, so every battle core sees them without knowing this mod.
|
||||
/// </summary>
|
||||
public static class StatsService
|
||||
{
|
||||
/// <summary>The free id of a protagonist without a party card.</summary>
|
||||
public const string FreePlayerId = "player";
|
||||
|
||||
private static readonly Dictionary<string, StatSheet> Sheets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static object snapshot;
|
||||
private static bool notifying;
|
||||
|
||||
/// <summary>Stat definitions by id, sorted for display (order, then id).</summary>
|
||||
public static IReadOnlyList<StatInfo> Definitions =>
|
||||
Entities.Collect<StatInfo>().Values.Where(s => !string.IsNullOrWhiteSpace(s.ID))
|
||||
.OrderBy(s => s.Order).ThenBy(s => s.ID, StringComparer.Ordinal).ToList();
|
||||
|
||||
/// <summary>The definition of <paramref name="stat"/>, or null.</summary>
|
||||
public static StatInfo Definition(string stat)
|
||||
=> Find(Entities.Collect<StatInfo>(), stat);
|
||||
|
||||
/// <summary>The archetype <paramref name="id"/>, or null.</summary>
|
||||
public static ArchetypeInfo Archetype(string id)
|
||||
=> string.IsNullOrWhiteSpace(id) ? null : Find(Entities.Collect<ArchetypeInfo>(), id);
|
||||
|
||||
/// <summary>The character template <paramref name="id"/>, or null.</summary>
|
||||
public static CharacterInfo Template(string id)
|
||||
=> string.IsNullOrWhiteSpace(id) ? null : Find(Entities.Collect<CharacterInfo>(), id);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a "who": <c>player</c> (or empty) → the player id; anything else is a
|
||||
/// character id taken as written.
|
||||
/// </summary>
|
||||
public static string Resolve(string who)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(who) && !string.Equals(who.Trim(), FreePlayerId, StringComparison.OrdinalIgnoreCase))
|
||||
return who.Trim();
|
||||
var setting = ActiveGame.GetSetting("stats.player");
|
||||
if (!string.IsNullOrWhiteSpace(setting))
|
||||
return setting.Trim();
|
||||
var roster = Roster;
|
||||
var first = roster?.Characters.Keys.FirstOrDefault(id => roster.BattleSet.Contains(id));
|
||||
return first ?? FreePlayerId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The characters a scenario argument names: <c>party</c> → every roster member
|
||||
/// (the player alone when there is no roster), anything else → <see cref="Resolve"/>.
|
||||
/// </summary>
|
||||
public static List<string> Targets(string who)
|
||||
{
|
||||
if (string.Equals(who?.Trim(), "party", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var members = Roster?.Characters.Keys.ToList() ?? new List<string>();
|
||||
return members.Count > 0 ? members : new List<string> { Resolve(null) };
|
||||
}
|
||||
return new List<string> { Resolve(who) };
|
||||
}
|
||||
|
||||
/// <summary>The party roster of the running game, or null outside a game.</summary>
|
||||
public static CharacterData Roster => GameData.Instance?.Runtime?.CharacterData;
|
||||
|
||||
/// <summary>The roster entry of <paramref name="characterId"/>, or null for a character outside the party.</summary>
|
||||
public static CharacterInfoData RosterEntry(string characterId)
|
||||
{
|
||||
var roster = Roster;
|
||||
if (roster == null || characterId == null)
|
||||
return null;
|
||||
return roster.Characters.TryGetValue(characterId, out var entry) ? entry : null;
|
||||
}
|
||||
|
||||
/// <summary>The archetype id in effect: chosen during play, else the template's <c>"Archetype"</c>.</summary>
|
||||
public static string ArchetypeOf(string who)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
var chosen = StatState.Instance.Find(id)?.Archetype;
|
||||
return !string.IsNullOrWhiteSpace(chosen) ? chosen : ExtraData.Text(Template(id)?.ExtraProperties, "Archetype");
|
||||
}
|
||||
|
||||
/// <summary>Level: the roster's for party members, else the stats state's, else the template's, at least 1.</summary>
|
||||
public static int LevelOf(string who)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
var entry = RosterEntry(id);
|
||||
if (entry != null)
|
||||
return Math.Max(1, entry.Level);
|
||||
var state = StatState.Instance.Find(id);
|
||||
if (state != null && state.Level > 0)
|
||||
return state.Level;
|
||||
return Math.Max(1, Template(id)?.Level ?? 1);
|
||||
}
|
||||
|
||||
/// <summary>Exp: the roster's for party members, else the stats state's.</summary>
|
||||
public static int ExpOf(string who)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
var entry = RosterEntry(id);
|
||||
return entry?.Exp ?? StatState.Instance.Find(id)?.Exp ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="who"/> takes part in the stats: it has an archetype, its
|
||||
/// template carries <c>Stats</c> or <c>Traits</c>, it gained bonuses during play, or a
|
||||
/// trait source gives it something (worn gear). Only such characters get their battle
|
||||
/// attributes from the sheet, so loading a stats pack next to a game whose characters
|
||||
/// never heard of stats changes none of their numbers.
|
||||
/// </summary>
|
||||
public static bool Participates(string who)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
if (!string.IsNullOrWhiteSpace(ArchetypeOf(id)))
|
||||
return true;
|
||||
var extra = Template(id)?.ExtraProperties;
|
||||
if (ExtraData.Find(extra, "Stats") != null || ExtraData.Find(extra, "Traits") != null)
|
||||
return true;
|
||||
if (StatState.Instance.Find(id)?.Bonus.Count > 0)
|
||||
return true;
|
||||
return TraitSources.Collect(id).Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>The current sheet of <paramref name="who"/> (cached until something changes).</summary>
|
||||
public static StatSheet Sheet(string who)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
DropCacheOnNewData();
|
||||
if (Sheets.TryGetValue(id, out var cached))
|
||||
return cached;
|
||||
var sheet = StatCalculator.Calculate(id, Inputs(id));
|
||||
Sheets[id] = sheet;
|
||||
return sheet;
|
||||
}
|
||||
|
||||
/// <summary>The inputs the sheet of <paramref name="characterId"/> is computed from (tools show them).</summary>
|
||||
public static StatInputs Inputs(string characterId)
|
||||
{
|
||||
var template = Template(characterId);
|
||||
var archetype = Archetype(ArchetypeOf(characterId));
|
||||
var traits = new List<TraitInfo>();
|
||||
if (archetype?.Traits != null)
|
||||
traits.AddRange(archetype.Traits.Where(t => t != null));
|
||||
traits.AddRange(ExtraData.Traits(template?.ExtraProperties, characterId));
|
||||
traits.AddRange(TraitSources.Collect(characterId));
|
||||
|
||||
return new StatInputs
|
||||
{
|
||||
Level = LevelOf(characterId),
|
||||
Definitions = Entities.Collect<StatInfo>(),
|
||||
Own = ExtraData.Stats(template?.ExtraProperties, characterId),
|
||||
Archetype = archetype,
|
||||
Bonus = StatState.Instance.Find(characterId)?.Bonus ?? new Dictionary<string, double>(),
|
||||
Traits = traits,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A permanent change of a stat (a strength potion, a story choice); negative values lower it.</summary>
|
||||
public static void AddBonus(string who, string stat, double value)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
if (string.IsNullOrWhiteSpace(stat) || value == 0)
|
||||
return;
|
||||
var bonus = StatState.Instance.Of(id).Bonus;
|
||||
var key = stat.Trim().ToLowerInvariant();
|
||||
bonus[key] = (bonus.TryGetValue(key, out var old) ? old : 0) + value;
|
||||
Changed(id);
|
||||
}
|
||||
|
||||
/// <summary>Changes the archetype of <paramref name="who"/> (null or empty returns to the template's).</summary>
|
||||
public static void SetArchetype(string who, string archetype)
|
||||
{
|
||||
var id = Resolve(who);
|
||||
StatState.Instance.Of(id).Archetype = string.IsNullOrWhiteSpace(archetype) ? null : archetype.Trim();
|
||||
Changed(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tells everyone that stats of <paramref name="characterId"/> (null: of anyone) may
|
||||
/// have changed: drops cached sheets, writes bound stats into battle attributes,
|
||||
/// publishes <see cref="StatsChangedEvent"/> and asks conditions to re-evaluate.
|
||||
/// Re-entrant calls (a listener changing stats again) only drop the cache.
|
||||
/// </summary>
|
||||
public static void Changed(string characterId)
|
||||
{
|
||||
if (characterId == null)
|
||||
Sheets.Clear();
|
||||
else
|
||||
Sheets.Remove(characterId);
|
||||
if (notifying)
|
||||
return;
|
||||
notifying = true;
|
||||
try
|
||||
{
|
||||
StatSync.SyncRoster();
|
||||
ModEventBus.Publish(new StatsChangedEvent { CharacterId = characterId });
|
||||
ConditionStateMonitor.MarkStateChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
notifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the cached sheet of <paramref name="characterId"/> without notifying anyone.</summary>
|
||||
public static void Forget(string characterId)
|
||||
{
|
||||
if (characterId != null)
|
||||
Sheets.Remove(characterId);
|
||||
}
|
||||
|
||||
/// <summary>Drops every cached sheet (a new game, a load, a mod reload).</summary>
|
||||
public static void ClearCache()
|
||||
{
|
||||
Sheets.Clear();
|
||||
snapshot = null;
|
||||
}
|
||||
|
||||
private static void DropCacheOnNewData()
|
||||
{
|
||||
// The entity resolver hands out the same dictionary until data changes (a reload,
|
||||
// an editor save), so its identity tells when every sheet is stale.
|
||||
var current = Entities.Collect<StatInfo>();
|
||||
if (ReferenceEquals(current, snapshot))
|
||||
return;
|
||||
snapshot = current;
|
||||
Sheets.Clear();
|
||||
}
|
||||
|
||||
private static T Find<T>(Dictionary<string, T> all, string id) where T : class
|
||||
{
|
||||
if (all == null || id == null)
|
||||
return null;
|
||||
if (all.TryGetValue(id, out var hit))
|
||||
return hit;
|
||||
foreach (var pair in all)
|
||||
{
|
||||
if (string.Equals(pair.Key, id, StringComparison.OrdinalIgnoreCase))
|
||||
return pair.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Characters;
|
||||
using Attribute = Cthangover.Core.Characters.Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// Writes bound stats (<see cref="StatInfo.Binds"/>) into a character's battle
|
||||
/// attributes. This is the whole integration with battles: the cores keep reading
|
||||
/// <c>Attributes.Attack</c> and <c>Attributes.Health</c> as they always did, and the
|
||||
/// numbers simply come from the stats now.
|
||||
/// <para/>
|
||||
/// Values are not reset: when max health grows from 30 to 36 (a level, a new armour)
|
||||
/// the current health grows by 6 too, so a wounded hero stays wounded; when the
|
||||
/// maximum drops (armour taken off) the current value is only capped; a fallen hero
|
||||
/// (health 0) is never revived by a sync. An attribute whose maximum was never set (0)
|
||||
/// is filled. Unbound attributes are left untouched, so a game may bind only some.
|
||||
/// </summary>
|
||||
public static class StatBindings
|
||||
{
|
||||
/// <summary>Attribute names a stat may bind to.</summary>
|
||||
public static readonly string[] Names = { "health", "attack", "defence", "point" };
|
||||
|
||||
/// <summary>Applies the bound stats of <paramref name="sheet"/>; returns how many attributes changed.</summary>
|
||||
public static int Apply(StatSheet sheet, CharacterAttributes attributes)
|
||||
=> Apply(sheet, attributes, StatsService.Definitions);
|
||||
|
||||
/// <summary>Applies the stats of <paramref name="definitions"/> that bind an attribute.</summary>
|
||||
public static int Apply(StatSheet sheet, CharacterAttributes attributes, IEnumerable<StatInfo> definitions)
|
||||
{
|
||||
if (sheet == null || attributes == null || definitions == null)
|
||||
return 0;
|
||||
var changed = 0;
|
||||
foreach (var def in definitions.Where(d => d != null && !string.IsNullOrWhiteSpace(d.Binds)))
|
||||
{
|
||||
var attribute = Find(attributes, def.Binds);
|
||||
if (attribute == null || !sheet.Has(def.ID))
|
||||
continue;
|
||||
if (Move(attribute, Math.Max(0, sheet.Rounded(def.ID)), ReferenceEquals(attribute, attributes.Health)))
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>The attribute named <paramref name="name"/> (any case), or null for an unknown name.</summary>
|
||||
public static Attribute Find(CharacterAttributes attributes, string name)
|
||||
{
|
||||
return name?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"health" or "hp" => attributes.Health,
|
||||
"attack" or "atk" => attributes.Attack,
|
||||
"defence" or "defense" or "def" => attributes.Defence,
|
||||
"point" or "points" or "ap" => attributes.Point,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Move(Attribute attribute, int max, bool isHealth)
|
||||
{
|
||||
var oldMax = attribute.BaseValue;
|
||||
if (oldMax == max && attribute.Value <= max)
|
||||
return false;
|
||||
int value;
|
||||
if (oldMax <= 0)
|
||||
value = max;
|
||||
else if (isHealth && attribute.Value <= 0)
|
||||
value = attribute.Value;
|
||||
else if (max > oldMax)
|
||||
value = attribute.Value + (max - oldMax);
|
||||
else
|
||||
value = Math.Min(attribute.Value, max);
|
||||
attribute.BaseValue = max;
|
||||
attribute.Value = Math.Min(value, max);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the party roster in step with the stats: applies level-ups from exp, teaches
|
||||
/// the archetype's actions (<see cref="StatLearnings"/>) and writes bound stats into
|
||||
/// each member's battle attributes. It runs whenever stats change and
|
||||
/// at the moments the roster may have changed behind the stats' back: entering a scene
|
||||
/// (back from a battle that awarded exp, after loading a save) and the end of a dialog
|
||||
/// (a scenario ran <c>party.add</c>). The battle bridge also runs it as a battle starts.
|
||||
/// </summary>
|
||||
public static class StatSync
|
||||
{
|
||||
private static bool syncing;
|
||||
|
||||
/// <summary>Applies level-ups and bound stats to every roster member.</summary>
|
||||
public static void SyncRoster()
|
||||
{
|
||||
var roster = StatsService.Roster;
|
||||
if (roster == null || syncing)
|
||||
return;
|
||||
syncing = true;
|
||||
try
|
||||
{
|
||||
foreach (var pair in roster.Characters.ToList())
|
||||
{
|
||||
if (StatProgression.ApplyLevelUps(pair.Key) > 0)
|
||||
StatsService.Forget(pair.Key);
|
||||
StatLearnings.Apply(pair.Key);
|
||||
if (StatsService.Participates(pair.Key))
|
||||
StatBindings.Apply(StatsService.Sheet(pair.Key), pair.Value?.Attributes);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scene entered: the roster may hold exp from a battle or come from a save.</summary>
|
||||
public static void OnSceneEntered(SceneEnteredEvent evt) => StatsService.Changed(null);
|
||||
|
||||
/// <summary>A dialog ended: a scenario may have added a party member or changed a level.</summary>
|
||||
public static void OnDialogEnded(DialogEndedEvent evt) => SyncRoster();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// A mechanic that changes characters' stats while something holds: equipment while
|
||||
/// an item is worn, statuses while they last, a buff of a place, a companion's aura.
|
||||
/// Implementations in any mod assembly are found and registered automatically
|
||||
/// (<see cref="TraitSourceConsumer"/>) and removed with their mod. A source calls
|
||||
/// <see cref="StatsService.Changed"/> when what it returns changes, so sheets, battle
|
||||
/// attributes and conditions follow at once.
|
||||
/// </summary>
|
||||
public interface ITraitSource
|
||||
{
|
||||
/// <summary>Stable id, for logs and for the sheet's "where from" hints.</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>The traits this source gives <paramref name="characterId"/> now (empty when none).</summary>
|
||||
IEnumerable<TraitInfo> TraitsOf(string characterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Mods.Consumers;
|
||||
|
||||
/// <summary>Registers every <see cref="ITraitSource"/> of a loaded mod assembly and drops them when it unloads.</summary>
|
||||
public class TraitSourceConsumer : ModTypeConsumer<ITraitSource>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string Id => "stats.trait_sources";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Add(ITraitSource instance, string modId) => TraitSources.Add(instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Remove(ITraitSource instance, string modId) => TraitSources.Remove(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// The registered <see cref="ITraitSource"/>s. Filled by <see cref="TraitSourceConsumer"/>
|
||||
/// per mod assembly, so a source leaves with its mod on reload; a throwing source is
|
||||
/// logged and skipped, it never breaks the sheet of everyone else.
|
||||
/// </summary>
|
||||
public static class TraitSources
|
||||
{
|
||||
private static readonly List<ITraitSource> Sources = new();
|
||||
|
||||
/// <summary>The registered sources, in registration order.</summary>
|
||||
public static IReadOnlyList<ITraitSource> All => Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source (ignored when already present). Only the cached sheets are dropped:
|
||||
/// sources arrive while mods register, before any game state exists, so this must
|
||||
/// not reach the roster (the next scene entry syncs it).
|
||||
/// </summary>
|
||||
public static void Add(ITraitSource source)
|
||||
{
|
||||
if (source != null && !Sources.Contains(source))
|
||||
Sources.Add(source);
|
||||
StatsService.ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>Removes a source (cached sheets are dropped, see <see cref="Add"/>).</summary>
|
||||
public static void Remove(ITraitSource source)
|
||||
{
|
||||
if (Sources.Remove(source))
|
||||
StatsService.ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>Every trait all sources give <paramref name="characterId"/>.</summary>
|
||||
public static List<TraitInfo> Collect(string characterId)
|
||||
{
|
||||
var result = new List<TraitInfo>();
|
||||
foreach (var source in Sources.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
var traits = source.TraitsOf(characterId);
|
||||
if (traits != null)
|
||||
result.AddRange(traits.Where(t => t != null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GameLogger.Log("STATS", $"trait source '{source.Id}' failed for '{characterId}': {ex.Message}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// A block of the character sheet contributed by another mod: equipment shows the
|
||||
/// slots, a reputation mod could show factions, a 3D game the implants. The sheet
|
||||
/// owns the layout (a titled block below the stats) and asks each section to fill its
|
||||
/// container for the selected character; sections in any mod assembly are found
|
||||
/// automatically (<see cref="SheetSectionConsumer"/>). The sheet rebuilds the sections
|
||||
/// whenever <see cref="Cthangover.Core.Events.StatsChangedEvent"/> arrives, so a section
|
||||
/// that changes stats (taking an item off) needs no refresh logic of its own.
|
||||
/// </summary>
|
||||
public interface ISheetSection
|
||||
{
|
||||
/// <summary>Stable id (logs, tests).</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Order among sections (lower first).</summary>
|
||||
int Order { get; }
|
||||
|
||||
/// <summary>Locale key of the block title.</summary>
|
||||
string TitleKey { get; }
|
||||
|
||||
/// <summary>Whether the block makes sense for <paramref name="characterId"/> (false hides it).</summary>
|
||||
bool AppliesTo(string characterId);
|
||||
|
||||
/// <summary>Adds the block's rows for <paramref name="characterId"/> to the empty <paramref name="container"/>.</summary>
|
||||
void Build(VBoxContainer container, string characterId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using Cthangover.Core.Mods.Consumers;
|
||||
|
||||
/// <summary>Registers every <see cref="ISheetSection"/> of a loaded mod assembly and drops them when it unloads.</summary>
|
||||
public class SheetSectionConsumer : ModTypeConsumer<ISheetSection>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string Id => "stats.sheet_sections";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Add(ISheetSection instance, string modId) => SheetSections.Add(instance);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Remove(ISheetSection instance, string modId) => SheetSections.Remove(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
/// <summary>The registered character sheet sections, filled per mod assembly by <see cref="SheetSectionConsumer"/>.</summary>
|
||||
public static class SheetSections
|
||||
{
|
||||
private static readonly List<ISheetSection> Sections = new();
|
||||
|
||||
/// <summary>The sections in display order.</summary>
|
||||
public static IReadOnlyList<ISheetSection> All => Sections.OrderBy(s => s.Order).ThenBy(s => s.Id).ToList();
|
||||
|
||||
/// <summary>Adds a section.</summary>
|
||||
public static void Add(ISheetSection section)
|
||||
{
|
||||
if (section != null && !Sections.Contains(section))
|
||||
Sections.Add(section);
|
||||
}
|
||||
|
||||
/// <summary>Removes a section.</summary>
|
||||
public static void Remove(ISheetSection section) => Sections.Remove(section);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
namespace Cthangover.Mods.Stats
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.UI.Theming;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Interface.Base.Lists;
|
||||
using Cthangover.Mods.Interface.Theming;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// The character sheet (toolbar panel "sheet"): the party on the left, the selected
|
||||
/// member's portrait, archetype, level and exp, every visible stat grouped by its
|
||||
/// <see cref="StatInfo.Group"/> with the part that traits add shown next to the value,
|
||||
/// and below them the blocks other mods contribute (<see cref="ISheetSection"/>).
|
||||
/// <para/>
|
||||
/// The sheet only reads <see cref="StatsService"/>, so it shows whatever stats the
|
||||
/// game's data defines — a novel's courage and trust as well as an RPG's attributes —
|
||||
/// and redraws on <see cref="StatsChangedEvent"/> while it is open. A game without a
|
||||
/// party shows the player's own sheet.
|
||||
/// </summary>
|
||||
public partial class StatSheetPanel : Widget
|
||||
{
|
||||
private ColorRect backdrop;
|
||||
private Panel window;
|
||||
private Panel rule;
|
||||
private Label title;
|
||||
private Button closeButton;
|
||||
private Label partyLabel;
|
||||
private VBoxContainer partyList;
|
||||
private TextureRect portrait;
|
||||
private Label nameLabel;
|
||||
private Label archetypeLabel;
|
||||
private Label levelLabel;
|
||||
private ProgressBar expBar;
|
||||
private Label archetypeHint;
|
||||
private VBoxContainer statsBox;
|
||||
private VBoxContainer sectionsBox;
|
||||
private Label emptyLabel;
|
||||
private string selected;
|
||||
private bool subscribed;
|
||||
private bool renderQueued;
|
||||
|
||||
/// <summary>The character shown (a roster id or the player id).</summary>
|
||||
public string SelectedId => selected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void _Ready()
|
||||
{
|
||||
base._Ready();
|
||||
ResolveNodes();
|
||||
ApplyStyles();
|
||||
if (closeButton != null)
|
||||
closeButton.Pressed += Hide;
|
||||
if (Visible)
|
||||
{
|
||||
EnsureConstructed();
|
||||
ShowConstruct();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shows <paramref name="characterId"/> (tests and other panels open the sheet on someone).</summary>
|
||||
public void Select(string characterId)
|
||||
{
|
||||
selected = characterId;
|
||||
Render();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ShowConstruct()
|
||||
{
|
||||
if (!subscribed)
|
||||
{
|
||||
ModEventBus.Subscribe<StatsChangedEvent>(OnStatsChanged);
|
||||
subscribed = true;
|
||||
}
|
||||
var party = Party();
|
||||
if (selected == null || !party.Contains(selected))
|
||||
selected = party.FirstOrDefault();
|
||||
Render();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void HideDestruct() => Unsubscribe();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnExitTree() => Unsubscribe();
|
||||
|
||||
/// <summary>The characters the sheet lists: battle set first, then the rest of the roster; the player alone without a roster.</summary>
|
||||
public static List<string> Party()
|
||||
{
|
||||
var roster = StatsService.Roster;
|
||||
var ids = roster?.Characters.Keys.OrderBy(id => roster.BattleSet.Contains(id) ? 0 : 1).ToList() ?? new List<string>();
|
||||
if (ids.Count == 0)
|
||||
ids.Add(StatsService.Resolve(null));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>Redraws everything for the selected character.</summary>
|
||||
public void Render()
|
||||
{
|
||||
renderQueued = false;
|
||||
if (statsBox == null)
|
||||
return;
|
||||
RenderParty();
|
||||
if (selected == null)
|
||||
return;
|
||||
RenderHero(selected);
|
||||
RenderStats(selected);
|
||||
RenderSections(selected);
|
||||
}
|
||||
|
||||
private void OnStatsChanged(StatsChangedEvent evt)
|
||||
{
|
||||
// A section's button may have caused the change: redraw after its signal returns.
|
||||
if (!Visible || renderQueued)
|
||||
return;
|
||||
renderQueued = true;
|
||||
Callable.From(Render).CallDeferred();
|
||||
}
|
||||
|
||||
private void Unsubscribe()
|
||||
{
|
||||
if (!subscribed)
|
||||
return;
|
||||
ModEventBus.Unsubscribe<StatsChangedEvent>(OnStatsChanged);
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
private void RenderParty()
|
||||
{
|
||||
Clear(partyList);
|
||||
foreach (var id in Party())
|
||||
{
|
||||
var button = UiKit.Button($"{NameOf(id)} · {string.Format(Tr("stats/sheet/level_short"), StatsService.LevelOf(id))}");
|
||||
button.ToggleMode = true;
|
||||
button.ButtonPressed = id == selected;
|
||||
button.Alignment = HorizontalAlignment.Left;
|
||||
button.Name = "Member_" + id.Replace('/', '_');
|
||||
var memberId = id;
|
||||
button.Pressed += () => Select(memberId);
|
||||
partyList.AddChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderHero(string id)
|
||||
{
|
||||
var template = StatsService.Template(id);
|
||||
portrait.Texture = string.IsNullOrEmpty(template?.Image) ? null : TextureUtils.LoadFromModGroup("characters", template.Image);
|
||||
nameLabel.Text = NameOf(id);
|
||||
|
||||
var archetype = StatsService.Archetype(StatsService.ArchetypeOf(id));
|
||||
archetypeLabel.Text = archetype == null ? Tr("stats/sheet/no_archetype") : Tr(archetype.Name);
|
||||
archetypeHint.Text = archetype == null || string.IsNullOrEmpty(archetype.Description) ? string.Empty : Tr(archetype.Description);
|
||||
archetypeHint.Visible = archetypeHint.Text.Length > 0;
|
||||
|
||||
var level = StatsService.LevelOf(id);
|
||||
var exp = StatsService.ExpOf(id);
|
||||
if (StatProgression.Levels(id) && level < StatProgression.MaxLevel(id))
|
||||
{
|
||||
var from = StatProgression.ExpFor(id, level);
|
||||
var to = StatProgression.ExpFor(id, level + 1);
|
||||
levelLabel.Text = string.Format(Tr("stats/sheet/level_exp"), level, exp, to);
|
||||
expBar.MinValue = from;
|
||||
expBar.MaxValue = Math.Max(from + 1, to);
|
||||
expBar.Value = Math.Clamp(exp, from, to);
|
||||
expBar.Visible = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
levelLabel.Text = string.Format(Tr("stats/sheet/level"), level);
|
||||
expBar.Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderStats(string id)
|
||||
{
|
||||
Clear(statsBox);
|
||||
var sheet = StatsService.Sheet(id);
|
||||
var shown = StatsService.Definitions.Where(d => !d.Hidden && sheet.Has(d.ID)).ToList();
|
||||
emptyLabel.Visible = shown.Count == 0;
|
||||
emptyLabel.Text = Tr("stats/sheet/empty");
|
||||
foreach (var group in shown.GroupBy(d => d.Group ?? string.Empty))
|
||||
{
|
||||
if (group.Key.Length > 0)
|
||||
{
|
||||
var key = "stats/group/" + group.Key;
|
||||
var caption = Tr(key);
|
||||
statsBox.AddChild(UiKit.SectionTitle(caption == key ? group.Key : caption, UiTokens.FontBody));
|
||||
}
|
||||
var grid = new GridContainer { Columns = 2, MouseFilter = MouseFilterEnum.Ignore };
|
||||
grid.AddThemeConstantOverride("h_separation", UiTokens.Space6);
|
||||
grid.AddThemeConstantOverride("v_separation", UiTokens.Space2);
|
||||
foreach (var def in group)
|
||||
grid.AddChild(StatRow(id, def, sheet));
|
||||
statsBox.AddChild(grid);
|
||||
}
|
||||
}
|
||||
|
||||
private Control StatRow(string id, StatInfo def, StatSheet sheet)
|
||||
{
|
||||
var row = UiKit.Row(UiTokens.Space3);
|
||||
row.Name = "Stat_" + def.ID;
|
||||
row.TooltipText = string.IsNullOrEmpty(def.Description) ? string.Empty : Tr(def.Description);
|
||||
row.MouseFilter = MouseFilterEnum.Pass;
|
||||
var name = UiKit.Label(Tr(def.Name), UiTokens.FontBody, UiTokens.Body);
|
||||
name.CustomMinimumSize = new Vector2(170, 0);
|
||||
row.AddChild(name);
|
||||
row.AddChild(UiKit.Label(ValueText(id, def, sheet), UiTokens.FontBodyLarge, UiTokens.InkBright));
|
||||
var delta = sheet.Get(def.ID) - sheet.GetBase(def.ID);
|
||||
if (Math.Abs(delta) >= 0.5)
|
||||
{
|
||||
var sign = delta > 0 ? "+" : "−";
|
||||
var text = sign + Math.Abs(Math.Round(delta, MidpointRounding.AwayFromZero)).ToString(CultureInfo.InvariantCulture);
|
||||
row.AddChild(UiKit.Label(text, UiTokens.FontSmall, delta > 0 ? UiTokens.SuccessText : UiTokens.DangerText));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string ValueText(string id, StatInfo def, StatSheet sheet)
|
||||
{
|
||||
var max = sheet.Rounded(def.ID);
|
||||
if (def.Pool)
|
||||
return $"{(int)Math.Round(StatPools.Current(id, def.ID))} / {max}";
|
||||
var health = StatsService.RosterEntry(id)?.Attributes?.Health;
|
||||
if (health != null && string.Equals(def.Binds, "health", StringComparison.OrdinalIgnoreCase))
|
||||
return $"{health.Value} / {health.BaseValue}";
|
||||
return max.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private void RenderSections(string id)
|
||||
{
|
||||
Clear(sectionsBox);
|
||||
foreach (var section in SheetSections.All)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!section.AppliesTo(id))
|
||||
continue;
|
||||
var block = UiKit.Column(UiTokens.Space2);
|
||||
block.Name = "Section_" + section.Id;
|
||||
block.AddChild(UiKit.SectionTitle(Tr(section.TitleKey), UiTokens.FontBody));
|
||||
var content = UiKit.Column(UiTokens.Space2);
|
||||
content.Name = "Content";
|
||||
block.AddChild(content);
|
||||
sectionsBox.AddChild(block);
|
||||
section.Build(content, id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
GameLogger.Log("STATS", $"sheet section '{section.Id}' failed: {ex.Message}", LogLevel.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveNodes()
|
||||
{
|
||||
const string root = "Window/Margin/Root";
|
||||
const string main = root + "/Body/Scroll/Main";
|
||||
backdrop = GetNodeOrNull<ColorRect>("Bg");
|
||||
window = GetNodeOrNull<Panel>("Window");
|
||||
rule = GetNodeOrNull<Panel>(root + "/Rule");
|
||||
title = GetNodeOrNull<Label>(root + "/Header/Title");
|
||||
closeButton = GetNodeOrNull<Button>(root + "/Header/CloseButton");
|
||||
partyLabel = GetNodeOrNull<Label>(root + "/Body/Party/PartyLabel");
|
||||
partyList = GetNodeOrNull<VBoxContainer>(root + "/Body/Party/PartyList");
|
||||
portrait = GetNodeOrNull<TextureRect>(main + "/Hero/Portrait");
|
||||
nameLabel = GetNodeOrNull<Label>(main + "/Hero/Info/Name");
|
||||
archetypeLabel = GetNodeOrNull<Label>(main + "/Hero/Info/Archetype");
|
||||
levelLabel = GetNodeOrNull<Label>(main + "/Hero/Info/Level");
|
||||
expBar = GetNodeOrNull<ProgressBar>(main + "/Hero/Info/ExpBar");
|
||||
archetypeHint = GetNodeOrNull<Label>(main + "/Hero/Info/ArchetypeHint");
|
||||
statsBox = GetNodeOrNull<VBoxContainer>(main + "/Stats");
|
||||
sectionsBox = GetNodeOrNull<VBoxContainer>(main + "/Sections");
|
||||
emptyLabel = GetNodeOrNull<Label>(main + "/Empty");
|
||||
}
|
||||
|
||||
private void ApplyStyles()
|
||||
{
|
||||
if (backdrop != null)
|
||||
UiKit.ApplyPanelBackdrop(backdrop);
|
||||
window?.AddThemeStyleboxOverride("panel", UiKit.Frame(UiTokens.RadiusLg, UiTokens.Surface0, UiTokens.Line));
|
||||
rule?.AddThemeStyleboxOverride("panel", UiKit.Frame(0, UiTokens.Line));
|
||||
Style(title, UiTokens.FontTitle, UiTokens.InkBright);
|
||||
if (title != null)
|
||||
{
|
||||
title.Uppercase = true;
|
||||
title.Text = Tr("stats/sheet/title");
|
||||
}
|
||||
Style(partyLabel, UiTokens.FontTiny, UiTokens.Faint);
|
||||
if (partyLabel != null)
|
||||
{
|
||||
partyLabel.Uppercase = true;
|
||||
partyLabel.Text = Tr("stats/sheet/party");
|
||||
}
|
||||
Style(nameLabel, UiTokens.FontH3, UiTokens.InkBright);
|
||||
Style(archetypeLabel, UiTokens.FontBodyLarge, UiTokens.AccentBright);
|
||||
Style(levelLabel, UiTokens.FontBody, UiTokens.Body);
|
||||
Style(archetypeHint, UiTokens.FontBodySmall, UiTokens.Muted);
|
||||
Style(emptyLabel, UiTokens.FontBodyLarge, UiTokens.Faint);
|
||||
if (portrait != null)
|
||||
portrait.TextureFilter = CanvasItem.TextureFilterEnum.Nearest;
|
||||
if (expBar != null)
|
||||
{
|
||||
expBar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Well, UiTokens.LineSoft));
|
||||
expBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusXs, UiTokens.Accent));
|
||||
}
|
||||
if (closeButton != null)
|
||||
{
|
||||
closeButton.TooltipText = Tr("stats/sheet/close");
|
||||
CthangoverTheme.ApplyIcon(closeButton);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NameOf(string id)
|
||||
{
|
||||
var key = StatsService.Template(id)?.Name;
|
||||
return string.IsNullOrEmpty(key) ? id : Tr(key);
|
||||
}
|
||||
|
||||
private static void Style(Label label, int size, Color color)
|
||||
{
|
||||
if (label == null)
|
||||
return;
|
||||
label.AddThemeFontSizeOverride("font_size", size);
|
||||
label.AddThemeColorOverride("font_color", color);
|
||||
}
|
||||
|
||||
private static void Clear(Node box)
|
||||
{
|
||||
if (box == null)
|
||||
return;
|
||||
foreach (var child in box.GetChildren())
|
||||
{
|
||||
box.RemoveChild(child);
|
||||
child.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Tr(string key) => TranslationServer.Translate(key).ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Stats keys arrive as JSON extension data of foreign entities; they must read the way
|
||||
/// authors write them: any key case, numbers or formula texts, broken parts skipped.
|
||||
/// </summary>
|
||||
public class ExtraDataTests
|
||||
{
|
||||
private static Dictionary<string, object> Extra(string json)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
result[prop.Name] = prop.Value.Clone();
|
||||
return result;
|
||||
}
|
||||
|
||||
[ModTest("extra/stats_numbers_and_formulas")]
|
||||
static void StatsNumbersAndFormulas(ModTestContext ctx)
|
||||
{
|
||||
var stats = ExtraData.Stats(Extra("{\"stats\": {\"STR\": 8, \"max_hp\": \"20 + vit * 4\", \"bad\": [1]}}"), "test");
|
||||
|
||||
ModAssert.Equal(2, stats.Count, "the array entry is skipped");
|
||||
ModAssert.Equal("8", stats["str"], "number, key lowercased");
|
||||
ModAssert.Equal("20 + vit * 4", stats["MAX_HP"], "formula text, lookup ignores case");
|
||||
}
|
||||
|
||||
[ModTest("extra/traits_and_text")]
|
||||
static void TraitsAndText(ModTestContext ctx)
|
||||
{
|
||||
var extra = Extra("{\"Archetype\": \"mage\", \"traits\": [{\"stat\": \"mag\", \"add\": 3}, {\"Stat\": \"mp\", \"Percent\": 10, \"Formula\": \"level\"}]}");
|
||||
|
||||
var traits = ExtraData.Traits(extra, "test");
|
||||
|
||||
ModAssert.Equal("mage", ExtraData.Text(extra, "archetype"), "text in any case");
|
||||
ModAssert.Equal(2, traits.Count, "two traits");
|
||||
ModAssert.Equal("mag", traits[0].Stat, "first stat");
|
||||
ModAssert.CloseTo(3, traits[0].Add, what: "first add");
|
||||
ModAssert.CloseTo(10, traits[1].Percent, what: "second percent");
|
||||
ModAssert.Equal("level", traits[1].Formula, "second formula");
|
||||
}
|
||||
|
||||
[ModTest("extra/missing_or_malformed_reads_empty")]
|
||||
static void MissingOrMalformed(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.Equal(0, ExtraData.Traits(null, "test").Count, "null extension data");
|
||||
ModAssert.Equal(0, ExtraData.Traits(Extra("{\"Traits\": 5}"), "test").Count, "a number is not a list");
|
||||
ModAssert.Equal(0, ExtraData.Stats(Extra("{\"Stats\": \"x\"}"), "test").Count, "a string is not an object");
|
||||
ModAssert.Null(ExtraData.Text(Extra("{\"Archetype\": {}}"), "Archetype"), "an object is not a text");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>The formula language: what authors write in data must compute what a spreadsheet would.</summary>
|
||||
public class FormulaTests
|
||||
{
|
||||
private static double Eval(string text, Dictionary<string, double> vars = null)
|
||||
=> Formula.Evaluate(text, new TestScope(vars));
|
||||
|
||||
[ModTest("formula/precedence_and_parentheses")]
|
||||
static void PrecedenceAndParentheses(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.CloseTo(14, Eval("2 + 3 * 4"), what: "* before +");
|
||||
ModAssert.CloseTo(20, Eval("(2 + 3) * 4"), what: "parentheses");
|
||||
ModAssert.CloseTo(1, Eval("10 % 3"), what: "remainder");
|
||||
ModAssert.CloseTo(-6, Eval("-2 * 3"), what: "unary minus");
|
||||
ModAssert.CloseTo(512, Eval("2 ^ 3 ^ 2"), what: "power is right-associative");
|
||||
ModAssert.CloseTo(2.5, Eval("5 / 2"), what: "real division");
|
||||
}
|
||||
|
||||
[ModTest("formula/variables_with_dots_and_level")]
|
||||
static void VariablesWithDotsAndLevel(ModTestContext ctx)
|
||||
{
|
||||
var vars = new Dictionary<string, double> { ["a.atk"] = 12, ["b.def"] = 5, ["level"] = 3, ["str"] = 8 };
|
||||
ModAssert.CloseTo(19, Eval("a.atk * 2 - b.def", vars), what: "dotted names");
|
||||
ModAssert.CloseTo(14, Eval("STR + Level * 2", vars), what: "names ignore case");
|
||||
ModAssert.CloseTo(0, Eval("unknown + 0", vars), what: "unknown name reads as 0");
|
||||
}
|
||||
|
||||
[ModTest("formula/comparisons_logic_and_conditional")]
|
||||
static void ComparisonsLogicAndConditional(ModTestContext ctx)
|
||||
{
|
||||
var vars = new Dictionary<string, double> { ["hp"] = 0, ["luk"] = 7 };
|
||||
ModAssert.CloseTo(1, Eval("luk >= 7", vars), what: ">=");
|
||||
ModAssert.CloseTo(0, Eval("luk != 7", vars), what: "!=");
|
||||
ModAssert.CloseTo(1, Eval("!hp", vars), what: "! of zero");
|
||||
ModAssert.CloseTo(0, Eval("hp > 0 && 100 / hp", vars), what: "&& short-circuits");
|
||||
ModAssert.CloseTo(30, Eval("luk > 5 ? 30 : 10", vars), what: "conditional true");
|
||||
ModAssert.CloseTo(10, Eval("luk > 9 ? 30 : luk > 8 ? 20 : 10", vars), what: "nested conditional");
|
||||
}
|
||||
|
||||
[ModTest("formula/functions")]
|
||||
static void Functions(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.CloseTo(1, Eval("max(1, 3 - 10)"), what: "max");
|
||||
ModAssert.CloseTo(2, Eval("min(9, 2, 5)"), what: "n-ary min");
|
||||
ModAssert.CloseTo(3, Eval("round(2.5)"), what: "round half away from zero");
|
||||
ModAssert.CloseTo(2, Eval("floor(2.9)"), what: "floor");
|
||||
ModAssert.CloseTo(10, Eval("clamp(42, 10, 0)"), what: "clamp with swapped bounds");
|
||||
ModAssert.CloseTo(4, Eval("if(1, 4, 5)"), what: "if");
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var roll = Eval("rand(3, 5)");
|
||||
ModAssert.True(roll >= 3 && roll <= 5 && roll == System.Math.Floor(roll), $"rand(3,5) gave {roll}");
|
||||
}
|
||||
ModAssert.CloseTo(1, Eval("chance(100)"), what: "chance 100");
|
||||
ModAssert.CloseTo(0, Eval("chance(0)"), what: "chance 0");
|
||||
}
|
||||
|
||||
[ModTest("formula/forgiving_arithmetic")]
|
||||
static void ForgivingArithmetic(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.CloseTo(0, Eval("5 / 0"), what: "division by zero");
|
||||
ModAssert.CloseTo(0, Eval("5 % 0"), what: "remainder by zero");
|
||||
ModAssert.CloseTo(42, Formula.Evaluate("42", null), what: "plain number without a scope");
|
||||
ModAssert.CloseTo(7, Formula.Evaluate("", null, 7), what: "empty text gives the fallback");
|
||||
ModAssert.CloseTo(9, Formula.Evaluate("2 +* 3", null, 9), what: "broken text gives the fallback");
|
||||
}
|
||||
|
||||
[ModTest("formula/syntax_errors_are_reported")]
|
||||
static void SyntaxErrorsAreReported(ModTestContext ctx)
|
||||
{
|
||||
foreach (var broken in new[] { "2 +", "(1 + 2", "foo(1)", "max()", "rand(1)", "1 2", "a ? b", "#" })
|
||||
{
|
||||
ModAssert.False(Formula.TryParse(broken, out _, out var error), $"'{broken}' must not parse");
|
||||
ModAssert.NotNull(error, $"'{broken}' has a message");
|
||||
}
|
||||
ModAssert.True(Formula.TryParse(" max(1, a.atk) ", out var node, out _), "spaces around are fine");
|
||||
ModAssert.Equal(FormulaNodeKind.Call.ToString(), node.Kind.ToString(), "call node");
|
||||
}
|
||||
|
||||
[ModTest("formula/variables_lists_reads_once")]
|
||||
static void VariablesListsReadsOnce(ModTestContext ctx)
|
||||
{
|
||||
var node = Formula.Parse("a.atk * 2 + max(level, a.atk) - b.def");
|
||||
var names = node.Variables();
|
||||
ModAssert.Equal(3, names.Count, "three distinct names");
|
||||
ModAssert.Equal("a.atk", names[0], "first use first");
|
||||
ModAssert.Equal("level", names[1], "then level");
|
||||
ModAssert.Equal("b.def", names[2], "then b.def");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// The bridge to battles: an attribute's maximum follows the stat, its current value
|
||||
/// moves by the same amount, and a fallen character is never revived by a sync.
|
||||
/// </summary>
|
||||
public class StatBindingsTests
|
||||
{
|
||||
private static readonly StatInfo[] Defs =
|
||||
{
|
||||
new() { ID = "max_hp", Binds = "health" },
|
||||
new() { ID = "atk", Binds = "attack" },
|
||||
new() { ID = "luk" },
|
||||
};
|
||||
|
||||
private static StatSheet Sheet(double hp, double atk = 0)
|
||||
{
|
||||
var values = new Dictionary<string, double> { ["max_hp"] = hp, ["atk"] = atk, ["luk"] = 99 };
|
||||
return new StatSheet("hero", 1, null, values, values);
|
||||
}
|
||||
|
||||
[ModTest("bindings/find_by_name_and_alias")]
|
||||
static void FindByNameAndAlias(ModTestContext ctx)
|
||||
{
|
||||
var attributes = new CharacterAttributes();
|
||||
ModAssert.True(ReferenceEquals(attributes.Health, StatBindings.Find(attributes, "Health")), "health");
|
||||
ModAssert.True(ReferenceEquals(attributes.Attack, StatBindings.Find(attributes, "atk")), "atk alias");
|
||||
ModAssert.True(ReferenceEquals(attributes.Defence, StatBindings.Find(attributes, "defense")), "defense spelling");
|
||||
ModAssert.True(ReferenceEquals(attributes.Point, StatBindings.Find(attributes, "point")), "point");
|
||||
ModAssert.Null(StatBindings.Find(attributes, "mana"), "unknown name");
|
||||
}
|
||||
|
||||
[ModTest("bindings/loaded_stats_bind_known_attributes")]
|
||||
static void LoadedStatsBindKnownAttributes(ModTestContext ctx)
|
||||
{
|
||||
foreach (var def in StatsService.Definitions)
|
||||
{
|
||||
if (def.Binds != null)
|
||||
ModAssert.NotNull(StatBindings.Find(new CharacterAttributes(), def.Binds), $"stat '{def.ID}' binds '{def.Binds}'");
|
||||
}
|
||||
}
|
||||
|
||||
[ModTest("bindings/current_moves_with_max")]
|
||||
static void CurrentMovesWithMax(ModTestContext ctx)
|
||||
{
|
||||
var attributes = new CharacterAttributes();
|
||||
attributes.Health.Init(30);
|
||||
attributes.Health.Value = 20;
|
||||
attributes.Attack.Init(6);
|
||||
|
||||
var changed = StatBindings.Apply(Sheet(36, 9), attributes, Defs);
|
||||
|
||||
ModAssert.Equal(2, changed, "health and attack changed, luk binds nothing");
|
||||
ModAssert.Equal(36, attributes.Health.BaseValue, "max follows the stat");
|
||||
ModAssert.Equal(26, attributes.Health.Value, "a wounded hero gains the same 6");
|
||||
ModAssert.Equal(9, attributes.Attack.Value, "attack follows");
|
||||
ModAssert.Equal(0, StatBindings.Apply(Sheet(36, 9), attributes, Defs), "nothing changes twice");
|
||||
|
||||
StatBindings.Apply(Sheet(10, 9), attributes, Defs);
|
||||
ModAssert.Equal(10, attributes.Health.Value, "current capped by a lower max");
|
||||
|
||||
StatBindings.Apply(Sheet(4, 9), attributes, Defs);
|
||||
ModAssert.Equal(4, attributes.Health.Value, "still alive at the new max");
|
||||
}
|
||||
|
||||
[ModTest("bindings/fallen_stay_fallen_unset_is_filled")]
|
||||
static void FallenStayFallen(ModTestContext ctx)
|
||||
{
|
||||
var attributes = new CharacterAttributes();
|
||||
attributes.Health.Init(30);
|
||||
attributes.Health.Value = 0;
|
||||
StatBindings.Apply(Sheet(50), attributes, Defs);
|
||||
ModAssert.Equal(0, attributes.Health.Value, "a fallen hero stays fallen");
|
||||
ModAssert.Equal(50, attributes.Health.BaseValue, "but the max still follows");
|
||||
|
||||
var fresh = new CharacterAttributes();
|
||||
StatBindings.Apply(Sheet(44.6), fresh, Defs);
|
||||
ModAssert.Equal(45, fresh.Health.Value, "an unset attribute is filled, rounded");
|
||||
ModAssert.Equal(45, fresh.Health.BaseValue, "with its max");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// How a sheet is computed: own value over archetype over formula over default, then
|
||||
/// bonuses and traits, then the bounds — and derived stats reading other stats.
|
||||
/// </summary>
|
||||
public class StatCalculatorTests
|
||||
{
|
||||
private static Dictionary<string, StatInfo> Defs(params StatInfo[] stats)
|
||||
{
|
||||
var defs = new Dictionary<string, StatInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in stats)
|
||||
defs[s.ID] = s;
|
||||
return defs;
|
||||
}
|
||||
|
||||
[ModTest("calculator/precedence_own_archetype_formula_default")]
|
||||
static void Precedence(ModTestContext ctx)
|
||||
{
|
||||
var inputs = new StatInputs
|
||||
{
|
||||
Level = 3,
|
||||
Definitions = Defs(
|
||||
new StatInfo { ID = "str", Default = 5 },
|
||||
new StatInfo { ID = "agi", Default = 5, Formula = "4 + level" },
|
||||
new StatInfo { ID = "int", Default = 5, Formula = "2 + level" },
|
||||
new StatInfo { ID = "luk", Default = 5 }),
|
||||
Own = new Dictionary<string, string> { ["str"] = "12" },
|
||||
Archetype = new ArchetypeInfo { ID = "rogue", Stats = new Dictionary<string, string> { ["agi"] = "6 + level * 2", ["str"] = "1" } },
|
||||
};
|
||||
|
||||
var sheet = StatCalculator.Calculate("hero", inputs);
|
||||
|
||||
ModAssert.CloseTo(12, sheet.Get("str"), what: "own value beats the archetype");
|
||||
ModAssert.CloseTo(12, sheet.Get("agi"), what: "archetype beats the stat formula");
|
||||
ModAssert.CloseTo(5, sheet.Get("int"), what: "stat formula beats the default");
|
||||
ModAssert.CloseTo(5, sheet.Get("luk"), what: "default");
|
||||
ModAssert.Equal("rogue", sheet.ArchetypeId, "archetype id");
|
||||
ModAssert.Equal(3, sheet.Level, "level");
|
||||
}
|
||||
|
||||
[ModTest("calculator/derived_stats_read_others_in_any_order")]
|
||||
static void DerivedStats(ModTestContext ctx)
|
||||
{
|
||||
var inputs = new StatInputs
|
||||
{
|
||||
Level = 2,
|
||||
Definitions = Defs(
|
||||
new StatInfo { ID = "max_hp", Formula = "30 + vit * 6 + level * 4" },
|
||||
new StatInfo { ID = "atk", Formula = "str * 2" },
|
||||
new StatInfo { ID = "vit", Default = 3 },
|
||||
new StatInfo { ID = "str", Default = 4 }),
|
||||
};
|
||||
|
||||
var sheet = StatCalculator.Calculate("hero", inputs);
|
||||
|
||||
ModAssert.CloseTo(56, sheet.Get("max_hp"), what: "30 + 18 + 8");
|
||||
ModAssert.CloseTo(8, sheet.Get("atk"), what: "atk from str");
|
||||
}
|
||||
|
||||
[ModTest("calculator/bonus_traits_and_bounds")]
|
||||
static void BonusTraitsAndBounds(ModTestContext ctx)
|
||||
{
|
||||
var inputs = new StatInputs
|
||||
{
|
||||
Level = 4,
|
||||
Definitions = Defs(
|
||||
new StatInfo { ID = "atk", Default = 10 },
|
||||
new StatInfo { ID = "luk", Default = 5, Max = 9 },
|
||||
new StatInfo { ID = "def", Default = 2, Min = 0 }),
|
||||
Bonus = new Dictionary<string, double> { ["atk"] = 2 },
|
||||
Traits = new List<TraitInfo>
|
||||
{
|
||||
new() { Stat = "atk", Add = 3 },
|
||||
new() { Stat = "atk", Percent = 50 },
|
||||
new() { Stat = "atk", Formula = "level" },
|
||||
new() { Stat = "luk", Add = 10 },
|
||||
new() { Stat = "def", Add = -8 },
|
||||
},
|
||||
};
|
||||
|
||||
var sheet = StatCalculator.Calculate("hero", inputs);
|
||||
|
||||
ModAssert.CloseTo(12, sheet.GetBase("atk"), what: "base = default + bonus");
|
||||
ModAssert.CloseTo(28.5, sheet.Get("atk"), what: "(12 + 3 + 4) * 1.5");
|
||||
ModAssert.Equal(29, sheet.Rounded("atk"), "rounded half away from zero");
|
||||
ModAssert.CloseTo(9, sheet.Get("luk"), what: "clamped to Max");
|
||||
ModAssert.CloseTo(0, sheet.Get("def"), what: "clamped to Min");
|
||||
}
|
||||
|
||||
[ModTest("calculator/undefined_stats_still_count")]
|
||||
static void UndefinedStatsStillCount(ModTestContext ctx)
|
||||
{
|
||||
var inputs = new StatInputs
|
||||
{
|
||||
Own = new Dictionary<string, string> { ["courage"] = "3" },
|
||||
Traits = new List<TraitInfo> { new() { Stat = "trust", Add = 2 } },
|
||||
};
|
||||
|
||||
var sheet = StatCalculator.Calculate("player", inputs);
|
||||
|
||||
ModAssert.True(sheet.Has("courage") && sheet.Has("trust"), "own and trait stats are on the sheet");
|
||||
ModAssert.CloseTo(3, sheet.Get("courage"), what: "own undefined stat");
|
||||
ModAssert.CloseTo(2, sheet.Get("trust"), what: "trait-only stat");
|
||||
ModAssert.CloseTo(0, sheet.Get("nothing"), what: "unknown stat reads 0");
|
||||
}
|
||||
|
||||
[ModTest("calculator/cycle_reads_zero_instead_of_hanging")]
|
||||
static void CycleReadsZero(ModTestContext ctx)
|
||||
{
|
||||
var inputs = new StatInputs
|
||||
{
|
||||
Definitions = Defs(
|
||||
new StatInfo { ID = "a", Formula = "b + 1" },
|
||||
new StatInfo { ID = "b", Formula = "a + 1" }),
|
||||
};
|
||||
|
||||
var sheet = StatCalculator.Calculate("loop", inputs);
|
||||
|
||||
ModAssert.True(sheet.Has("a") && sheet.Has("b"), "both computed");
|
||||
ModAssert.True(sheet.Get("a") <= 2 && sheet.Get("b") <= 2, "the looping read counted as 0");
|
||||
}
|
||||
|
||||
[ModTest("calculator/sheet_is_a_formula_scope")]
|
||||
static void SheetIsAScope(ModTestContext ctx)
|
||||
{
|
||||
var sheet = StatCalculator.Calculate("hero", new StatInputs
|
||||
{
|
||||
Level = 5,
|
||||
Definitions = Defs(new StatInfo { ID = "str", Default = 7 }),
|
||||
});
|
||||
|
||||
ModAssert.CloseTo(17, Formulas.Formula.Evaluate("str + level * 2", sheet), what: "sheet answers stats and level");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Scenes;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The stats join the shared condition language: the forms authors write parse into
|
||||
/// stats conditions, mix with flags and logic, and broken forms are syntax errors.
|
||||
/// </summary>
|
||||
public class StatLeafParserTests
|
||||
{
|
||||
[ModTest("conditions/forms_parse_into_stat_conditions")]
|
||||
static void FormsParse(ModTestContext ctx)
|
||||
{
|
||||
var two = ConditionParser.Parse("stat(rpg_hero, str) >= 5") as StatCompareCondition;
|
||||
ModAssert.NotNull(two, "stat(who, stat)");
|
||||
ModAssert.Equal("rpg_hero", two.Who, "who");
|
||||
ModAssert.Equal("str", two.Stat, "stat");
|
||||
ModAssert.Equal(">=", two.Op, "op");
|
||||
ModAssert.Equal(5, two.Value, "value");
|
||||
|
||||
var one = ConditionParser.Parse("stat(cha) < 3") as StatCompareCondition;
|
||||
ModAssert.NotNull(one, "stat(stat)");
|
||||
ModAssert.Null(one.Who, "the player");
|
||||
|
||||
var quoted = ConditionParser.Parse("stat('village/slime', atk) == 2") as StatCompareCondition;
|
||||
ModAssert.Equal("village/slime", quoted?.Who, "quoted id with a slash");
|
||||
|
||||
var level = ConditionParser.Parse("level() > 1") as StatCompareCondition;
|
||||
ModAssert.NotNull(level, "level()");
|
||||
ModAssert.Null(level.Stat, "level compares no stat");
|
||||
|
||||
var archetype = ConditionParser.Parse("archetype(rpg_hero, mage)") as ArchetypeCondition;
|
||||
ModAssert.Equal("mage", archetype?.Archetype, "archetype(who, id)");
|
||||
}
|
||||
|
||||
[ModTest("conditions/mix_with_flags_and_logic")]
|
||||
static void MixWithFlags(ModTestContext ctx)
|
||||
{
|
||||
var node = ConditionParser.Parse("door == \"open\" && (stat(str) >= 10 || !archetype(rogue))");
|
||||
ModAssert.NotNull(node, "parsed");
|
||||
}
|
||||
|
||||
[ModTest("conditions/broken_forms_are_errors")]
|
||||
static void BrokenForms(ModTestContext ctx)
|
||||
{
|
||||
foreach (var broken in new[] { "stat(a, b, c) > 1", "stat(str)", "level(a, b) > 1", "stat(str) >= x", "archetype()" })
|
||||
{
|
||||
var threw = false;
|
||||
try
|
||||
{
|
||||
ConditionParser.Parse(broken);
|
||||
}
|
||||
catch (System.FormatException)
|
||||
{
|
||||
threw = true;
|
||||
}
|
||||
ModAssert.True(threw, $"'{broken}' is a syntax error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System.Text.Json;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>The saved stats survive a JSON round trip in every field.</summary>
|
||||
public class StatStateDataTests
|
||||
{
|
||||
[ModTest("state/json_round_trip")]
|
||||
static void JsonRoundTrip(ModTestContext ctx)
|
||||
{
|
||||
var original = new StatStateData();
|
||||
var hero = new CharacterStatState { Archetype = "warrior", Level = 3, Exp = 61 };
|
||||
hero.Bonus["str"] = 2;
|
||||
hero.Bonus["cha"] = -1.5;
|
||||
hero.Pools["mp"] = 4;
|
||||
original.Characters["rpg_hero"] = hero;
|
||||
|
||||
var restored = JsonSerializer.Deserialize<StatStateData>(JsonSerializer.Serialize(original));
|
||||
|
||||
var back = restored.Characters["rpg_hero"];
|
||||
ModAssert.Equal("warrior", back.Archetype, "archetype");
|
||||
ModAssert.Equal(3, back.Level, "level");
|
||||
ModAssert.Equal(61, back.Exp, "exp");
|
||||
ModAssert.CloseTo(2, back.Bonus["str"], what: "bonus str");
|
||||
ModAssert.CloseTo(-1.5, back.Bonus["cha"], what: "negative bonus");
|
||||
ModAssert.CloseTo(4, back.Pools["mp"], what: "pool");
|
||||
}
|
||||
|
||||
[ModTest("state/of_creates_find_ignores_case")]
|
||||
static void OfCreatesFindIgnoresCase(ModTestContext ctx)
|
||||
{
|
||||
var state = new StatState();
|
||||
ModAssert.Null(state.Find("Hero"), "nothing yet");
|
||||
var created = state.Of("Hero");
|
||||
ModAssert.True(ReferenceEquals(created, state.Of("hero")), "the same entry in any case");
|
||||
ModAssert.True(ReferenceEquals(created, state.Find("HERO")), "find ignores case");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// The facade's "who" rules and the opt-in of the battle bridge: a character with no
|
||||
/// stats data of any kind is left alone, one that gains a bonus takes part.
|
||||
/// </summary>
|
||||
public class StatsServiceTests
|
||||
{
|
||||
private const string Nobody = "test_nobody_has_stats";
|
||||
|
||||
[ModTest("service/who_resolves")]
|
||||
static void WhoResolves(ModTestContext ctx)
|
||||
{
|
||||
ModAssert.Equal(Nobody, StatsService.Resolve(" " + Nobody + " "), "an id is taken as written, trimmed");
|
||||
ModAssert.NotNull(StatsService.Resolve("player"), "player always resolves to someone");
|
||||
ModAssert.Equal(StatsService.Resolve(null), StatsService.Resolve("PLAYER"), "player in any case is the default");
|
||||
ModAssert.True(StatsService.Targets("party").Count >= 1, "party is the roster, or the player alone");
|
||||
ModAssert.Equal(1, StatsService.Targets(Nobody).Count, "an id is one target");
|
||||
}
|
||||
|
||||
[ModTest("service/participation_is_opt_in")]
|
||||
static void ParticipationIsOptIn(ModTestContext ctx)
|
||||
{
|
||||
var saved = StatState.Instance.Data;
|
||||
try
|
||||
{
|
||||
StatState.Instance.Data = new StatStateData();
|
||||
ModAssert.False(StatsService.Participates(Nobody), "no archetype, no stats data, no bonus, no gear: not touched");
|
||||
StatsService.AddBonus(Nobody, "str", 1);
|
||||
ModAssert.True(StatsService.Participates(Nobody), "a bonus gained during play opts in");
|
||||
ModAssert.CloseTo(1, StatsService.Sheet(Nobody).GetBase("str") - (StatsService.Definition("str")?.Default ?? 0), what: "and shows on the sheet");
|
||||
StatsService.SetArchetype(Nobody, "some_archetype");
|
||||
ModAssert.Equal("some_archetype", StatsService.ArchetypeOf(Nobody), "a chosen archetype wins over the template's");
|
||||
}
|
||||
finally
|
||||
{
|
||||
StatState.Instance.Data = saved;
|
||||
StatsService.ClearCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Cthangover.Mods.Stats.Tests
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>A formula scope over a plain dictionary and a seeded generator, for the tests.</summary>
|
||||
public class TestScope : IFormulaScope
|
||||
{
|
||||
private readonly Dictionary<string, double> values;
|
||||
|
||||
public TestScope(Dictionary<string, double> values, int seed = 7)
|
||||
{
|
||||
this.values = new Dictionary<string, double>(values ?? new Dictionary<string, double>(), StringComparer.OrdinalIgnoreCase);
|
||||
Random = new Random(seed);
|
||||
}
|
||||
|
||||
public bool TryGet(string name, out double value) => values.TryGetValue(name, out value);
|
||||
|
||||
public Random Random { get; }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Reference in new issue
Block a user