Equipment: slots, gear traits, archetype limits and stat bonuses, equip actions and schema

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:11 +02:00
commit f834d1e914
28 files changed
+1065

No files matched your search

+30
View File
@@ -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
View File
@@ -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
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = Equipment
mod_equipment_desc = Gear for any game with stats: slots as data (weapon, armour, accessory — or an outfit and jewellery in a novel); an item becomes gear by two keys of its own JSON (“Equip” — the type, “Traits” — what it gives while worn). What is worn feeds the stats, so battles, checks and conditions change at once. Put on through the inventory's usual target picker; actions equip.put and equip.remove; condition equipped(who, item).
equipment/verb_equip = Equip
equipment/not_gear = This cannot be worn.
equipment/no_slot = There is no slot for this.
equipment/wrong_archetype = Not for this hand: it is meant for another class.
equipment/sheet/title = Equipment
equipment/sheet/hint = To put something on, open the bag, pick it and press Equip.
equipment/sheet/take_off = Take off
equipment/schema/equip/put = Put on
equipment/schema/equip/put_desc = A class's starting gear, a gift worn at once
equipment/schema/field/item = Item
equipment/schema/field/who = Who
equipment/schema/field/slot = Slot
equipment/schema/field/give = Grant
equipment/schema/equip/give_desc = Grant it even when it is not in the bag
equipment/schema/equip/remove = Take off
equipment/schema/equip/remove_desc = The worn item goes back to the bag
equipment/schema/kind/equip_slot = Equipment slots
equipment/schema/kind/equip_slot_desc = Where a character wears things
equipment/schema/field/name = Name
equipment/schema/field/order = Order
equipment/schema/field/accepts = Accepts types
equipment/schema/field/accepts_desc = Equip types, comma separated; empty — the type is the slot id
equipment/schema/field/equip = Equip type
equipment/schema/field/equip_desc = sword, staff, armor… — which slot it fits; empty — not gear
equipment/schema/field/traits = Traits
equipment/schema/field/traits_desc = Stat modifiers: a flat amount, a percent or a formula
equipment/schema/field/stat = Stat
equipment/schema/field/add = Add
equipment/schema/field/percent = Percent
equipment/schema/field/formula = Formula
equipment/schema/field/archetypes = Archetypes only
equipment/schema/field/archetypes_desc = Empty — anyone may wear it
equipment/schema/field/stat_up = Raises for good
equipment/schema/field/stat_up_desc = For the action/stat_up action: stat → amount
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = Equipo
mod_equipment_desc = Equipo para cualquier juego con estadísticas: ranuras como datos (arma, armadura, accesorio — o atuendo y joyas en una novela); un objeto se vuelve equipo con dos claves de su propio JSON («Equip» — el tipo, «Traits» — lo que da mientras se lleva). Lo que se lleva alimenta las estadísticas, así que combates, tiradas y condiciones cambian al instante. Se equipa con el selector de objetivo habitual del inventario; acciones equip.put y equip.remove; condición equipped(quién, objeto).
equipment/verb_equip = Equipar
equipment/not_gear = Esto no se puede llevar.
equipment/no_slot = No hay ranura para esto.
equipment/wrong_archetype = No es para esta mano: es de otra clase.
equipment/sheet/title = Equipo
equipment/sheet/hint = Para ponerte algo, abre la bolsa, elígelo y pulsa Equipar.
equipment/sheet/take_off = Quitar
equipment/schema/equip/put = Equipar
equipment/schema/equip/put_desc = El equipo inicial de una clase, un regalo que se lleva al momento
equipment/schema/field/item = Objeto
equipment/schema/field/who = Quién
equipment/schema/field/slot = Ranura
equipment/schema/field/give = Conceder
equipment/schema/equip/give_desc = Concederlo aunque no esté en la bolsa
equipment/schema/equip/remove = Quitar
equipment/schema/equip/remove_desc = El objeto vuelve a la bolsa
equipment/schema/kind/equip_slot = Ranuras de equipo
equipment/schema/kind/equip_slot_desc = Dónde lleva las cosas un personaje
equipment/schema/field/name = Nombre
equipment/schema/field/order = Orden
equipment/schema/field/accepts = Acepta tipos
equipment/schema/field/accepts_desc = Tipos de equipo separados por comas; vacío: el tipo es el id de la ranura
equipment/schema/field/equip = Tipo de equipo
equipment/schema/field/equip_desc = sword, staff, armor…: en qué ranura encaja; vacío: no es equipo
equipment/schema/field/traits = Rasgos
equipment/schema/field/traits_desc = Modificadores de estadísticas: una suma, un porcentaje o una fórmula
equipment/schema/field/stat = Estadística
equipment/schema/field/add = Suma
equipment/schema/field/percent = Porcentaje
equipment/schema/field/formula = Fórmula
equipment/schema/field/archetypes = Solo arquetipos
equipment/schema/field/archetypes_desc = Vacío: cualquiera puede llevarlo
equipment/schema/field/stat_up = Sube para siempre
equipment/schema/field/stat_up_desc = Para la acción action/stat_up: estadística → cantidad
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = 装備
mod_equipment_desc = ステータスのあるどのゲームにも使える装備:スロットはデータ(武器、防具、アクセサリー——ノベルなら服装や装身具)。アイテムは自分の JSON の二つのキー(「Equip」——種類、「Traits」——装備中に与えるもの)だけで装備品になる。装備はステータスに反映されるので、バトル、判定、条件がすぐに変わる。インベントリのいつもの対象選択で装備。アクション equip.put と equip.remove、条件 equipped(誰, アイテム)。
equipment/verb_equip = 装備する
equipment/not_gear = これは装備できない。
equipment/no_slot = これを付ける場所がない。
equipment/wrong_archetype = 手に合わない:別の職業向けだ。
equipment/sheet/title = 装備
equipment/sheet/hint = 装備するには、かばんで選んで「装備する」を押す。
equipment/sheet/take_off = 外す
equipment/schema/equip/put = 装備する
equipment/schema/equip/put_desc = 職業の初期装備、すぐに身に着ける贈り物
equipment/schema/field/item = アイテム
equipment/schema/field/who = 誰
equipment/schema/field/slot = スロット
equipment/schema/field/give = 付与
equipment/schema/equip/give_desc = かばんになくても付与する
equipment/schema/equip/remove = 外す
equipment/schema/equip/remove_desc = 外した物はかばんに戻る
equipment/schema/kind/equip_slot = 装備スロット
equipment/schema/kind/equip_slot_desc = キャラクターが物を身に着ける場所
equipment/schema/field/name = 名前
equipment/schema/field/order = 順序
equipment/schema/field/accepts = 受け付ける種類
equipment/schema/field/accepts_desc = 装備の種類(カンマ区切り)。空ならスロット id が種類
equipment/schema/field/equip = 装備の種類
equipment/schema/field/equip_desc = sword、staff、armor…——どのスロットに入るか。空なら装備ではない
equipment/schema/field/traits = 特性
equipment/schema/field/traits_desc = ステータス修正:固定値、パーセント、または式
equipment/schema/field/stat = ステータス
equipment/schema/field/add = 加算
equipment/schema/field/percent = パーセント
equipment/schema/field/formula = 式
equipment/schema/field/archetypes = 対象アーキタイプ
equipment/schema/field/archetypes_desc = 空なら誰でも装備できる
equipment/schema/field/stat_up = 永続的に上げる
equipment/schema/field/stat_up_desc = action/stat_up 用:ステータス → 量
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = 장비
mod_equipment_desc = 능력치가 있는 모든 게임을 위한 장비: 데이터로 정의하는 슬롯(무기, 방어구, 장신구 — 노벨이라면 의상과 액세서리). 아이템은 자기 JSON의 키 두 개(“Equip” — 종류, “Traits” — 착용 중 주는 효과)만으로 장비가 됩니다. 착용한 것이 능력치에 반영되므로 전투, 판정, 조건이 바로 바뀝니다. 인벤토리의 평소 대상 선택으로 착용, 액션 equip.put과 equip.remove, 조건 equipped(누가, 아이템).
equipment/verb_equip = 장착
equipment/not_gear = 이것은 착용할 수 없습니다.
equipment/no_slot = 이것을 착용할 슬롯이 없습니다.
equipment/wrong_archetype = 손에 맞지 않습니다: 다른 직업용입니다.
equipment/sheet/title = 장비
equipment/sheet/hint = 착용하려면 가방에서 골라 ‘장착’을 누르세요.
equipment/sheet/take_off = 벗기
equipment/schema/equip/put = 착용
equipment/schema/equip/put_desc = 직업의 시작 장비, 바로 착용하는 선물
equipment/schema/field/item = 아이템
equipment/schema/field/who = 누구
equipment/schema/field/slot = 슬롯
equipment/schema/field/give = 지급
equipment/schema/equip/give_desc = 가방에 없어도 지급
equipment/schema/equip/remove = 벗기
equipment/schema/equip/remove_desc = 착용한 물건이 가방으로 돌아감
equipment/schema/kind/equip_slot = 장비 슬롯
equipment/schema/kind/equip_slot_desc = 캐릭터가 물건을 착용하는 곳
equipment/schema/field/name = 이름
equipment/schema/field/order = 순서
equipment/schema/field/accepts = 허용 종류
equipment/schema/field/accepts_desc = 장비 종류(쉼표로 구분). 비우면 슬롯 id가 종류
equipment/schema/field/equip = 장비 종류
equipment/schema/field/equip_desc = sword, staff, armor… — 어느 슬롯에 맞는지. 비우면 장비 아님
equipment/schema/field/traits = 특성
equipment/schema/field/traits_desc = 능력치 수정: 고정값, 퍼센트 또는 공식
equipment/schema/field/stat = 능력치
equipment/schema/field/add = 더하기
equipment/schema/field/percent = 퍼센트
equipment/schema/field/formula = 공식
equipment/schema/field/archetypes = 허용 원형
equipment/schema/field/archetypes_desc = 비우면 누구나 착용
equipment/schema/field/stat_up = 영구 상승
equipment/schema/field/stat_up_desc = action/stat_up 행동용: 능력치 → 양
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = Снаряжение
mod_equipment_desc = Снаряжение для любой игры со статами: слоты как данные (оружие, броня, аксессуар — или наряд и украшения в новелле), предмет становится снаряжением двумя ключами своего JSON («Equip» — тип, «Traits» — что он даёт, пока надет). Надетое питает статы, поэтому бой, проверки и условия меняются сразу. Надевается через обычный выбор цели в инвентаре; действия equip.put и equip.remove; условие equipped(кто, предмет).
equipment/verb_equip = Надеть
equipment/not_gear = Это нельзя надеть.
equipment/no_slot = Для этого нет подходящего места.
equipment/wrong_archetype = Не по руке: это для другого класса.
equipment/sheet/title = Снаряжение
equipment/sheet/hint = Надеть вещь — в сумке: выберите её и нажмите «Надеть».
equipment/sheet/take_off = Снять
equipment/schema/equip/put = Надеть
equipment/schema/equip/put_desc = Стартовое снаряжение класса, подарок, который надевают сразу
equipment/schema/field/item = Предмет
equipment/schema/field/who = Кто
equipment/schema/field/slot = Слот
equipment/schema/field/give = Выдать
equipment/schema/equip/give_desc = Выдать, даже если предмета нет в сумке
equipment/schema/equip/remove = Снять
equipment/schema/equip/remove_desc = Вещь из слота возвращается в сумку
equipment/schema/kind/equip_slot = Слоты снаряжения
equipment/schema/kind/equip_slot_desc = Куда персонаж надевает вещи
equipment/schema/field/name = Название
equipment/schema/field/order = Порядок
equipment/schema/field/accepts = Принимает типы
equipment/schema/field/accepts_desc = Типы снаряжения через запятую; пусто — тип равен id слота
equipment/schema/field/equip = Тип снаряжения
equipment/schema/field/equip_desc = sword, staff, armor… — в какой слот встаёт; пусто — не снаряжение
equipment/schema/field/traits = Черты
equipment/schema/field/traits_desc = Модификаторы статов: прибавка, процент или формула
equipment/schema/field/stat = Стат
equipment/schema/field/add = Прибавка
equipment/schema/field/percent = Процент
equipment/schema/field/formula = Формула
equipment/schema/field/archetypes = Только для архетипов
equipment/schema/field/archetypes_desc = Пусто — может надеть любой
equipment/schema/field/stat_up = Навсегда повышает
equipment/schema/field/stat_up_desc = Для действия action/stat_up: стат → прибавка
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = Trang bị
mod_equipment_desc = Trang bị cho mọi game có chỉ số: ô trang bị là dữ liệu (vũ khí, giáp, phụ kiện — hoặc trang phục và trang sức trong tiểu thuyết); vật phẩm thành trang bị nhờ hai khóa trong JSON của chính nó («Equip» — loại, «Traits» — tác dụng khi mặc). Đồ đang mặc nuôi chỉ số, nên chiến đấu, kiểm tra và điều kiện thay đổi ngay. Mặc qua bộ chọn mục tiêu quen thuộc của túi đồ; hành động equip.put và equip.remove; điều kiện equipped(ai, vật phẩm).
equipment/verb_equip = Trang bị
equipment/not_gear = Thứ này không mặc được.
equipment/no_slot = Không có ô nào hợp cho thứ này.
equipment/wrong_archetype = Không hợp tay: thứ này dành cho lớp khác.
equipment/sheet/title = Trang bị
equipment/sheet/hint = Muốn mặc đồ, mở túi, chọn món đó và bấm Trang bị.
equipment/sheet/take_off = Tháo
equipment/schema/equip/put = Mặc vào
equipment/schema/equip/put_desc = Trang bị khởi đầu của lớp, món quà mặc ngay
equipment/schema/field/item = Vật phẩm
equipment/schema/field/who = Ai
equipment/schema/field/slot = Ô
equipment/schema/field/give = Cấp luôn
equipment/schema/equip/give_desc = Cấp ngay cả khi không có trong túi
equipment/schema/equip/remove = Tháo ra
equipment/schema/equip/remove_desc = Món đồ quay lại túi
equipment/schema/kind/equip_slot = Ô trang bị
equipment/schema/kind/equip_slot_desc = Nơi nhân vật mặc đồ
equipment/schema/field/name = Tên
equipment/schema/field/order = Thứ tự
equipment/schema/field/accepts = Nhận loại
equipment/schema/field/accepts_desc = Các loại trang bị, cách nhau bằng dấu phẩy; để trống — loại là id của ô
equipment/schema/field/equip = Loại trang bị
equipment/schema/field/equip_desc = sword, staff, armor… — vừa ô nào; để trống — không phải trang bị
equipment/schema/field/traits = Đặc tính
equipment/schema/field/traits_desc = Bộ điều chỉnh chỉ số: số cộng, phần trăm hoặc công thức
equipment/schema/field/stat = Chỉ số
equipment/schema/field/add = Cộng
equipment/schema/field/percent = Phần trăm
equipment/schema/field/formula = Công thức
equipment/schema/field/archetypes = Chỉ cho nguyên mẫu
equipment/schema/field/archetypes_desc = Để trống — ai cũng mặc được
equipment/schema/field/stat_up = Tăng vĩnh viễn
equipment/schema/field/stat_up_desc = Cho hành động action/stat_up: chỉ số → lượng
+36
View File
@@ -0,0 +1,36 @@
mod_equipment_name = 装备
mod_equipment_desc = 适用于任何有属性的游戏的装备:栏位即数据(武器、护甲、饰品——或小说里的服装和首饰);物品只需自身 JSON 中的两个键就成为装备(“Equip”——类型,“Traits”——穿戴时提供什么)。穿戴的物品会影响属性,因此战斗、检定和条件立即随之改变。通过背包常用的目标选择来穿戴;动作 equip.put 和 equip.remove;条件 equipped(谁, 物品)。
equipment/verb_equip = 装备
equipment/not_gear = 这个无法穿戴。
equipment/no_slot = 没有可放它的栏位。
equipment/wrong_archetype = 不合手:这是给别的职业用的。
equipment/sheet/title = 装备
equipment/sheet/hint = 要穿戴物品,打开背包,选中它并点击“装备”。
equipment/sheet/take_off = 卸下
equipment/schema/equip/put = 穿戴
equipment/schema/equip/put_desc = 职业的初始装备、立刻穿上的礼物
equipment/schema/field/item = 物品
equipment/schema/field/who = 谁
equipment/schema/field/slot = 栏位
equipment/schema/field/give = 直接给予
equipment/schema/equip/give_desc = 即使背包里没有也给予
equipment/schema/equip/remove = 卸下
equipment/schema/equip/remove_desc = 卸下的物品回到背包
equipment/schema/kind/equip_slot = 装备栏位
equipment/schema/kind/equip_slot_desc = 角色穿戴物品的位置
equipment/schema/field/name = 名称
equipment/schema/field/order = 顺序
equipment/schema/field/accepts = 接受类型
equipment/schema/field/accepts_desc = 装备类型,逗号分隔;留空——类型即栏位 id
equipment/schema/field/equip = 装备类型
equipment/schema/field/equip_desc = sword、staff、armor…——放进哪个栏位;留空——不是装备
equipment/schema/field/traits = 特性
equipment/schema/field/traits_desc = 属性修正:固定值、百分比或公式
equipment/schema/field/stat = 属性
equipment/schema/field/add = 加值
equipment/schema/field/percent = 百分比
equipment/schema/field/formula = 公式
equipment/schema/field/archetypes = 仅限原型
equipment/schema/field/archetypes_desc = 留空——任何人都能穿戴
equipment/schema/field/stat_up = 永久提升
equipment/schema/field/stat_up_desc = 用于 action/stat_up 动作:属性 → 数量
+15
View File
@@ -0,0 +1,15 @@
{
"id": "equipment",
"name": "Equipment",
"version": "1.0.0",
"author": "ct",
"description": "Gear for any game with stats: slots as data (\"Kind\": \"equip_slot\": weapon, body, accessory — or outfit and jewellery in a novel), items become gear by two keys of their own JSON (\"Equip\": the equip type, \"Traits\": what they do while worn), optional \"Archetypes\" limits. Worn items feed the stats as a trait source, so battle attributes, checks and conditions follow at once. The item action action/equip uses the inventory's own target picker; scenario actions equip.put and equip.remove; condition equipped(who, item); the Equipment block of the character sheet.",
"category": "gameplay",
"sources": [
"src/**/*.cs"
],
"test_sources": [
"tests/*.cs"
],
"depends": ["stats_base>=1", "items_base>=1", "characters_base>=1", "ui_base>=1"]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"Actions": [
{ "Id": "equip.put", "Name": "equipment/schema/equip/put", "Description": "equipment/schema/equip/put_desc", "Category": "items",
"Params": [
{ "Key": "item", "Type": "ref", "Kind": "item", "Required": true, "Name": "equipment/schema/field/item" },
{ "Key": "who", "Type": "who", "Name": "equipment/schema/field/who" },
{ "Key": "slot", "Type": "ref", "Kind": "equip_slot", "Name": "equipment/schema/field/slot" },
{ "Key": "give", "Type": "bool", "Name": "equipment/schema/field/give", "Description": "equipment/schema/equip/give_desc" } ] },
{ "Id": "equip.remove", "Name": "equipment/schema/equip/remove", "Description": "equipment/schema/equip/remove_desc", "Category": "items",
"Params": [
{ "Key": "slot", "Type": "ref", "Kind": "equip_slot", "Required": true, "Name": "equipment/schema/field/slot" },
{ "Key": "who", "Type": "who", "Name": "equipment/schema/field/who" } ] }
],
"Kinds": [
{ "Kind": "equip_slot", "Name": "equipment/schema/kind/equip_slot", "Description": "equipment/schema/kind/equip_slot_desc", "Folder": "equipment", "NewId": "new_slot", "Order": 5,
"Fields": [
{ "Key": "Name", "Type": "locale", "KeyPattern": "{mod}/slot/{id}", "Name": "equipment/schema/field/name" },
{ "Key": "Order", "Type": "int", "Name": "equipment/schema/field/order" },
{ "Key": "Accepts", "Type": "ref_list", "Name": "equipment/schema/field/accepts", "Description": "equipment/schema/field/accepts_desc" } ] },
{ "Kind": "item",
"Fields": [
{ "Key": "Equip", "Type": "text", "Name": "equipment/schema/field/equip", "Description": "equipment/schema/field/equip_desc" },
{ "Key": "Traits", "Type": "list", "Name": "equipment/schema/field/traits", "Description": "equipment/schema/field/traits_desc", "Of": [
{ "Key": "Stat", "Type": "ref", "Kind": "stat", "Required": true, "Name": "equipment/schema/field/stat" },
{ "Key": "Add", "Type": "number", "Name": "equipment/schema/field/add" },
{ "Key": "Percent", "Type": "number", "Name": "equipment/schema/field/percent" },
{ "Key": "Formula", "Type": "formula", "Name": "equipment/schema/field/formula" } ] },
{ "Key": "Archetypes", "Type": "ref_list", "Kind": "archetype", "Name": "equipment/schema/field/archetypes", "Description": "equipment/schema/field/archetypes_desc" },
{ "Key": "StatUp", "Type": "map", "Kind": "stat", "Name": "equipment/schema/field/stat_up", "Description": "equipment/schema/field/stat_up_desc" } ] }
]
}
+30
View File
@@ -0,0 +1,30 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
using Cthangover.Mods.Items;
/// <summary>
/// The item action <c>"ItemAction": "action/equip"</c>. With the item type
/// <c>TargetUsed</c> the inventory's own "apply to whom" picker chooses the party member,
/// and the inventory takes the item away once this returns true — so gear needs no
/// equipment screen of its own to be put on. The replaced item comes back to the bag.
/// </summary>
public class EquipItemAction : ITargetedItemAction
{
/// <inheritdoc />
public string ID => "action/equip";
/// <inheritdoc />
public bool UseAction(IItem item) => false;
/// <inheritdoc />
public bool UseOn(IItem item, CharacterInfoData target)
{
if (item == null || target == null)
return false;
// The inventory removes the applied item itself, so the action must not take it too.
return EquipmentService.Equip(target.ID, item.ID, null, fromInventory: false);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Actions;
/// <summary>
/// <c>action equip.put item=&lt;id&gt; [who=player|&lt;id&gt;] [slot=weapon] [give=1]</c>:
/// puts an item on a character from a scenario — the starting gear of a class, a gift
/// the hero wears at once. The item comes from the inventory; with <c>give=1</c> it is
/// granted instead (it need not be in the bag).
/// </summary>
public class EquipPutAction : IScenarioAction
{
/// <inheritdoc />
public string Name => "equip.put";
/// <inheritdoc />
public void Run(IActionContext ctx)
{
var item = ctx.GetParam("item");
if (string.IsNullOrWhiteSpace(item))
{
ctx.LogWarning("EQUIPMENT", "equip.put: missing 'item'");
return;
}
var slot = ctx.GetParam("slot");
var give = ctx.GetParam("give");
var fromInventory = string.IsNullOrWhiteSpace(give) || give.Trim() == "0";
if (!EquipmentService.Equip(ctx.GetParam("who"), item.Trim(), string.IsNullOrWhiteSpace(slot) ? null : slot.Trim(), fromInventory))
ctx.LogWarning("EQUIPMENT", $"equip.put: '{item}' was not put on (not gear, no fitting slot, not allowed or not in the inventory)");
}
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Actions;
/// <summary><c>action equip.remove slot=weapon [who=player|&lt;id&gt;]</c>: takes off what is worn there, back into the inventory.</summary>
public class EquipRemoveAction : IScenarioAction
{
/// <inheritdoc />
public string Name => "equip.remove";
/// <inheritdoc />
public void Run(IActionContext ctx)
{
var slot = ctx.GetParam("slot");
if (string.IsNullOrWhiteSpace(slot))
{
ctx.LogWarning("EQUIPMENT", "equip.remove: missing 'slot'");
return;
}
EquipmentService.Unequip(ctx.GetParam("who"), slot.Trim());
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Mods.Equipment
{
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
/// <summary>
/// A place a character wears something (<c>"Kind": "equip_slot"</c>): weapon, body,
/// accessory in an RPG; outfit and jewellery in a dating sim; a cyberware socket in a
/// shooter. Slots are data, so each game has its own set and a mod adds a slot
/// ("a second ring") without code.
/// <para/>
/// An item fits a slot when the item's <c>"Equip"</c> key names one of the slot's
/// <see cref="Accepts"/> types, or the slot id itself when the slot lists none.
/// </summary>
[EntityKind("equip_slot")]
public class EquipSlotInfo : IIdentifiable
{
/// <summary>Slot id (<c>weapon</c>, <c>body</c>, <c>ring_left</c>).</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Locale key of the slot's name.</summary>
public string Name { get; set; }
/// <summary>Display order in the sheet (lower first).</summary>
public int Order { get; set; }
/// <summary>Item equip types the slot takes; empty = items whose type is the slot id.</summary>
public List<string> Accepts { get; set; } = new();
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Cthangover.Core.Events
{
/// <summary>A character put something on or took it off (the stats already know; this is for UI and mods).</summary>
[EventKey("equipment_changed")]
public class EquipmentChangedEvent : ModEvent
{
/// <summary>The character.</summary>
public string CharacterId { get; init; }
}
}
+33
View File
@@ -0,0 +1,33 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Mods;
using Cthangover.Core.Scenes.Parsing;
using Cthangover.Core.Utils;
/// <summary>
/// Wires equipment in: the <c>equipped()</c> condition and the new-game reset. The trait
/// source, the item action and the scenario actions are found by their registries.
/// The kernel removes the condition parser with this mod's assembly on unload.
/// </summary>
public class EquipmentModInitializer : IModInitializer
{
private static bool registered;
/// <inheritdoc />
public void OnModLoaded(string modId)
{
if (registered)
return;
registered = true;
LeafParserRegistry.RegisterFirst(new EquippedLeafParser());
GameLogger.Log("EQUIPMENT", "equipment: condition equipped() registered");
}
/// <inheritdoc />
public void OnModResourcesReady()
{
AppServices.Current.RegisterResettable(new EquipmentReset());
}
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Bootstrap;
/// <summary>A new game (or the reset before a load) starts with nobody wearing anything.</summary>
public class EquipmentReset : IResettable
{
/// <inheritdoc />
public string Id => "equipment";
/// <inheritdoc />
public void ResetForNewGame() => EquipmentService.Reset();
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Mods.Stats;
/// <summary>Saves what everyone wears in the equipment slot.</summary>
public class EquipmentSaveable : IModSaveable
{
private const string BlobKey = "Worn";
/// <inheritdoc />
public void OnSave(ModSlot slot)
{
slot.Data[BlobKey] = new DataBlob(1, EquipmentService.Data);
}
/// <inheritdoc />
public bool OnLoad(ModSlot slot, out string errorMessage)
{
errorMessage = null;
EquipmentService.Data = new EquipmentStateData();
if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob))
EquipmentService.Data = blob.ReadJson<EquipmentStateData>() ?? new EquipmentStateData();
StatsService.ClearCache();
return true;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
namespace Cthangover.Mods.Equipment
{
using System;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Events;
using Cthangover.Core.Items;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Settings;
using Cthangover.Mods.Stats;
/// <summary>
/// Who wears what. An item is equipment when its JSON has an <c>"Equip"</c> key (its
/// equip type); what it does while worn is its <c>"Traits"</c> list, read by the stats
/// through <see cref="EquipmentTraitSource"/>. Neither key needs the items mod to know
/// about equipment, so any game's items — and items of other mods, through patches —
/// become gear by data alone. An optional <c>"Archetypes"</c> list limits who may wear
/// an item (a staff for mages only).
/// <para/>
/// Equipping takes the item from the inventory and returns the one it replaces; taking
/// off returns the item. Every change tells the stats (sheets, battle attributes and
/// conditions follow) and publishes <see cref="EquipmentChangedEvent"/>.
/// </summary>
public static class EquipmentService
{
/// <summary>The running game's worn items (replaced on a new game and a load).</summary>
public static EquipmentStateData Data { get; set; } = new();
/// <summary>Slots of the loaded mods in display order.</summary>
public static IReadOnlyList<EquipSlotInfo> Slots =>
Entities.Collect<EquipSlotInfo>().Values.Where(s => !string.IsNullOrWhiteSpace(s.ID))
.OrderBy(s => s.Order).ThenBy(s => s.ID, StringComparer.Ordinal).ToList();
/// <summary>The slot <paramref name="id"/>, or null.</summary>
public static EquipSlotInfo Slot(string id)
=> Slots.FirstOrDefault(s => string.Equals(s.ID, id, StringComparison.OrdinalIgnoreCase));
/// <summary>The item definition <paramref name="itemId"/>, or null.</summary>
public static ItemInfo Item(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
return null;
var all = Entities.Collect<ItemInfo>();
if (all.TryGetValue(itemId, out var info))
return info;
return all.Values.FirstOrDefault(i => string.Equals(i.ID, itemId, StringComparison.OrdinalIgnoreCase));
}
/// <summary>The equip type of an item (its <c>"Equip"</c> key), or null for an item that is not gear.</summary>
public static string EquipType(string itemId) => ExtraData.Text(Item(itemId)?.ExtraProperties, "Equip");
/// <summary>Whether the item can be worn at all.</summary>
public static bool IsEquipment(string itemId) => !string.IsNullOrWhiteSpace(EquipType(itemId));
/// <summary>Whether <paramref name="slot"/> takes items of <paramref name="equipType"/>.</summary>
public static bool Accepts(EquipSlotInfo slot, string equipType)
{
if (slot == null || string.IsNullOrWhiteSpace(equipType))
return false;
return slot.Accepts == null || slot.Accepts.Count == 0
? string.Equals(slot.ID, equipType, StringComparison.OrdinalIgnoreCase)
: slot.Accepts.Any(a => string.Equals(a, equipType, StringComparison.OrdinalIgnoreCase));
}
/// <summary>The slots an item fits, in display order.</summary>
public static List<EquipSlotInfo> SlotsFor(string itemId)
{
var type = EquipType(itemId);
return Slots.Where(s => Accepts(s, type)).ToList();
}
/// <summary>Slot id → item id of what <paramref name="who"/> wears (empty when nothing).</summary>
public static IReadOnlyDictionary<string, string> Worn(string who)
{
var id = StatsService.Resolve(who);
return Find(id) ?? new Dictionary<string, string>();
}
/// <summary>The item in a slot of <paramref name="who"/>, or null.</summary>
public static string ItemIn(string who, string slot)
=> slot != null && Worn(who).TryGetValue(slot, out var item) ? item : null;
/// <summary>Whether <paramref name="who"/> wears <paramref name="itemId"/> in any slot.</summary>
public static bool Wears(string who, string itemId)
=> Worn(who).Values.Any(i => string.Equals(i, itemId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Whether <paramref name="who"/> may wear the item (in <paramref name="slot"/>, or in
/// any slot that fits); <paramref name="reasonKey"/> is the locale key of why not.
/// </summary>
public static bool CanEquip(string who, string itemId, string slot, out string reasonKey)
{
reasonKey = null;
if (!IsEquipment(itemId))
{
reasonKey = "equipment/not_gear";
return false;
}
var fits = slot == null ? SlotsFor(itemId).Count > 0 : Accepts(Slot(slot), EquipType(itemId));
if (!fits)
{
reasonKey = "equipment/no_slot";
return false;
}
var allowed = ExtraData.Read<List<string>>(Item(itemId)?.ExtraProperties, "Archetypes", itemId);
if (allowed is { Count: > 0 } && !allowed.Any(a => string.Equals(a, StatsService.ArchetypeOf(who), StringComparison.OrdinalIgnoreCase)))
{
reasonKey = "equipment/wrong_archetype";
return false;
}
return true;
}
/// <summary>
/// Puts the item on <paramref name="who"/>. Without a slot it takes the first fitting
/// empty slot, else the first fitting one. With <paramref name="fromInventory"/> the
/// item must be in the inventory and one is taken from it; the replaced item goes back
/// to the inventory either way. False (nothing changed) when the item cannot be worn.
/// </summary>
public static bool Equip(string who, string itemId, string slot = null, bool fromInventory = true)
{
var id = StatsService.Resolve(who);
if (!CanEquip(id, itemId, slot, out _))
return false;
var target = slot != null ? Slot(slot) : PickSlot(id, itemId);
if (target == null)
return false;
var inventory = Inventory;
if (fromInventory && (inventory == null || !inventory.Remove(itemId, 1)))
return false;
var worn = Of(id);
if (worn.TryGetValue(target.ID, out var previous) && !string.IsNullOrEmpty(previous))
inventory?.Add(previous, 1);
worn[target.ID] = Item(itemId)?.ID ?? itemId;
Changed(id);
return true;
}
/// <summary>Takes off what <paramref name="who"/> wears in <paramref name="slot"/>, back into the inventory.</summary>
public static bool Unequip(string who, string slot, bool toInventory = true)
{
var id = StatsService.Resolve(who);
var worn = Find(id);
if (worn == null || slot == null || !worn.TryGetValue(slot, out var item))
return false;
worn.Remove(slot);
if (toInventory && !string.IsNullOrEmpty(item))
Inventory?.Add(item, 1);
Changed(id);
return true;
}
/// <summary>Back to a new game: nobody wears anything.</summary>
public static void Reset()
{
Data = new EquipmentStateData();
StatsService.Changed(null);
}
private static IInventory Inventory => GameData.Instance?.Runtime?.Inventory;
private static EquipSlotInfo PickSlot(string who, string itemId)
{
var fitting = SlotsFor(itemId);
var worn = Find(who);
return fitting.FirstOrDefault(s => worn == null || !worn.ContainsKey(s.ID)) ?? fitting.FirstOrDefault();
}
private static Dictionary<string, string> Find(string who)
{
if (who == null)
return null;
if (Data.Characters.TryGetValue(who, out var worn))
return worn;
return Data.Characters.FirstOrDefault(p => string.Equals(p.Key, who, StringComparison.OrdinalIgnoreCase)).Value;
}
private static Dictionary<string, string> Of(string who)
{
var worn = Find(who);
if (worn != null)
return worn;
worn = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Data.Characters[who] = worn;
return worn;
}
private static void Changed(string who)
{
StatsService.Changed(who);
ModEventBus.Publish(new EquipmentChangedEvent { CharacterId = who });
}
}
}
+87
View File
@@ -0,0 +1,87 @@
namespace Cthangover.Mods.Equipment
{
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Stats;
using Godot;
/// <summary>
/// The Equipment block of the character sheet: one row per slot with what is worn
/// there (picture, name, what it gives) and a button to take it off. Putting gear on
/// stays in the bag, where every item already has its own action, so the sheet does
/// not need an item picker of its own.
/// </summary>
public class EquipmentSheetSection : ISheetSection
{
/// <inheritdoc />
public string Id => "equipment";
/// <inheritdoc />
public int Order => 10;
/// <inheritdoc />
public string TitleKey => "equipment/sheet/title";
/// <inheritdoc />
public bool AppliesTo(string characterId) => EquipmentService.Slots.Count > 0;
/// <inheritdoc />
public void Build(VBoxContainer container, string characterId)
{
foreach (var slot in EquipmentService.Slots)
container.AddChild(SlotRow(characterId, slot));
container.AddChild(UiKit.Hint(Tr("equipment/sheet/hint")));
}
private static Control SlotRow(string characterId, EquipSlotInfo slot)
{
var row = UiKit.Row(UiTokens.Space3);
row.Name = "Slot_" + slot.ID;
var name = UiKit.Label(Tr(slot.Name ?? slot.ID), UiTokens.FontBody, UiTokens.Body);
name.CustomMinimumSize = new Vector2(170, 0);
row.AddChild(name);
var itemId = EquipmentService.ItemIn(characterId, slot.ID);
var item = EquipmentService.Item(itemId);
if (item == null)
{
row.AddChild(UiKit.Label("—", UiTokens.FontBody, UiTokens.Faint));
return row;
}
var icon = string.IsNullOrEmpty(item.Sprite) ? null : TextureUtils.LoadFromModGroup("items", item.Sprite);
if (icon != null)
{
var picture = UiKit.Icon(icon, 32);
picture.TextureFilter = CanvasItem.TextureFilterEnum.Nearest;
row.AddChild(picture);
}
var label = UiKit.Label(Tr(item.Name), UiTokens.FontBodyLarge, UiTokens.InkBright);
label.TooltipText = string.IsNullOrEmpty(item.Description) ? string.Empty : Tr(item.Description);
label.MouseFilter = Control.MouseFilterEnum.Pass;
row.AddChild(label);
var traits = ExtraData.Traits(item.ExtraProperties, item.ID).Where(t => t.Add != 0 || t.Percent != 0).ToList();
if (traits.Count > 0)
row.AddChild(UiKit.Label(string.Join(" ", traits.Select(TraitText)), UiTokens.FontSmall, UiTokens.SuccessText));
var off = UiKit.Button(Tr("equipment/sheet/take_off"));
off.Name = "TakeOff";
off.Pressed += () => EquipmentService.Unequip(characterId, slot.ID);
row.AddChild(off);
return row;
}
private static string TraitText(TraitInfo trait)
{
var def = StatsService.Definition(trait.Stat);
var stat = def == null ? trait.Stat : Tr(string.IsNullOrEmpty(def.Short) ? def.Name : def.Short);
return trait.Percent != 0
? $"{stat} {(trait.Percent > 0 ? "+" : "")}{trait.Percent:0.#}%"
: $"{stat} {(trait.Add > 0 ? "+" : "")}{trait.Add:0.#}";
}
private static string Tr(string key) => TranslationServer.Translate(key).ToString();
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cthangover.Mods.Equipment
{
using System;
using System.Collections.Generic;
/// <summary>What everyone wears: character id → slot id → item id (the equipment slot's one blob).</summary>
public class EquipmentStateData
{
/// <summary>Worn items per character and slot.</summary>
public Dictionary<string, Dictionary<string, string>> Characters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Equipment
{
using System.Collections.Generic;
using Cthangover.Mods.Stats;
/// <summary>The traits of everything a character wears: each worn item's <c>"Traits"</c> list.</summary>
public class EquipmentTraitSource : ITraitSource
{
/// <inheritdoc />
public string Id => "equipment";
/// <inheritdoc />
public IEnumerable<TraitInfo> TraitsOf(string characterId)
{
foreach (var item in EquipmentService.Worn(characterId).Values)
{
foreach (var trait in ExtraData.Traits(EquipmentService.Item(item)?.ExtraProperties, item))
yield return trait;
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Scenes;
/// <summary><c>equipped(who, item)</c>: true while the character wears the item in any slot.</summary>
public class EquippedCondition : ConditionNode
{
/// <summary>Creates the condition.</summary>
public EquippedCondition(string who, string item)
{
Who = who;
Item = item;
}
/// <summary>Who must wear it (a character id or <c>player</c>).</summary>
public string Who { get; }
/// <summary>The item id.</summary>
public string Item { get; }
/// <inheritdoc />
public override bool Evaluate(EvaluationContext ctx) => EquipmentService.Wears(Who, Item);
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace Cthangover.Mods.Equipment
{
using System;
using System.Collections.Generic;
using Cthangover.Core.Scenes;
using Cthangover.Core.Scenes.Parsing;
/// <summary>
/// <c>equipped(who, item)</c> or <c>equipped(item)</c> (the player) in any condition: a
/// door that opens for the one carrying the old key, a guard who salutes the uniform.
/// Ids with a slash are quoted: <c>equipped('rpg_plus/iron_sword')</c>.
/// </summary>
public class EquippedLeafParser : ILeafParser
{
/// <inheritdoc />
public bool CanParse(TokenPeeker peeker)
=> !peeker.AtEnd && peeker.Peek.Type == ConditionParser.TokenType.Ident && peeker.Peek.Value == "equipped"
&& peeker.PeekAhead(1).Type == ConditionParser.TokenType.LParen;
/// <inheritdoc />
public ICondition Parse(TokenPeeker peeker, Func<ICondition> parseSubExpr)
{
peeker.Advance();
peeker.Expect(ConditionParser.TokenType.LParen, "expected '('");
var args = new List<string>();
while (peeker.Check(ConditionParser.TokenType.Ident) || peeker.Check(ConditionParser.TokenType.String))
{
args.Add(peeker.Advance().Value);
if (!peeker.Check(ConditionParser.TokenType.Comma))
break;
peeker.Advance();
}
peeker.Expect(ConditionParser.TokenType.RParen, "expected ')' after equipped(who, item)");
if (args.Count is < 1 or > 2)
throw new FormatException("Condition parse error: equipped(who, item) takes one or two arguments");
return new EquippedCondition(args.Count == 2 ? args[0] : null, args[^1]);
}
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace Cthangover.Mods.Equipment
{
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
using Cthangover.Mods.Items;
using Cthangover.Mods.Stats;
/// <summary>
/// The item action <c>"ItemAction": "action/stat_up"</c>: a tonic, a seed, a book that
/// permanently raises stats of the party member it is applied to, by the item's own
/// <c>"StatUp"</c> key (<c>{"str": 1}</c>, negative lowers). It lives here because this
/// mod is where items meet the stats; it uses the inventory's target picker like gear.
/// </summary>
public class StatUpItemAction : ITargetedItemAction
{
/// <inheritdoc />
public string ID => "action/stat_up";
/// <inheritdoc />
public bool UseAction(IItem item) => false;
/// <inheritdoc />
public bool UseOn(IItem item, CharacterInfoData target)
{
if (item == null || target == null)
return false;
var ups = ExtraData.Read<System.Collections.Generic.Dictionary<string, double>>(EquipmentService.Item(item.ID)?.ExtraProperties, "StatUp", item.ID);
if (ups == null || ups.Count == 0)
return false;
foreach (var pair in ups)
StatsService.AddBonus(target.ID, pair.Key, pair.Value);
return true;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace Cthangover.Mods.Equipment.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
/// <summary>
/// The data rules of equipment that need no game: which slot takes which item type,
/// the saved state's round trip, the condition syntax. The equip flow with real items,
/// the inventory and battle attributes is covered by the rpg_plus game test.
/// </summary>
public class EquipmentServiceTests
{
[ModTest("slots/accepts_by_type_or_by_slot_id")]
static void AcceptsByTypeOrSlotId(ModTestContext ctx)
{
var hand = new EquipSlotInfo { ID = "weapon", Accepts = new List<string> { "sword", "Staff" } };
var body = new EquipSlotInfo { ID = "body" };
ModAssert.True(EquipmentService.Accepts(hand, "sword"), "listed type");
ModAssert.True(EquipmentService.Accepts(hand, "staff"), "listed type in any case");
ModAssert.False(EquipmentService.Accepts(hand, "weapon"), "a slot with a list takes only the list");
ModAssert.True(EquipmentService.Accepts(body, "BODY"), "a slot without a list takes its own id");
ModAssert.False(EquipmentService.Accepts(body, null), "no type is no gear");
ModAssert.False(EquipmentService.Accepts(null, "sword"), "no slot");
}
[ModTest("state/json_round_trip")]
static void JsonRoundTrip(ModTestContext ctx)
{
var original = new EquipmentStateData();
original.Characters["rpg_hero"] = new Dictionary<string, string> { ["weapon"] = "rpg_plus/iron_sword", ["body"] = "rpg_plus/leather_armor" };
var restored = JsonSerializer.Deserialize<EquipmentStateData>(JsonSerializer.Serialize(original));
ModAssert.Equal(2, restored.Characters["rpg_hero"].Count, "two slots");
ModAssert.Equal("rpg_plus/iron_sword", restored.Characters["rpg_hero"]["weapon"], "weapon");
ModAssert.Equal("rpg_plus/leather_armor", restored.Characters["rpg_hero"]["body"], "body");
}
[ModTest("conditions/equipped_parses")]
static void EquippedParses(ModTestContext ctx)
{
var two = ConditionParser.Parse("equipped(rpg_hero, 'rpg_plus/iron_sword')") as EquippedCondition;
ModAssert.Equal("rpg_hero", two?.Who, "who");
ModAssert.Equal("rpg_plus/iron_sword", two?.Item, "quoted item");
var one = ConditionParser.Parse("!equipped(lucky_charm)") ;
ModAssert.NotNull(one, "negated one-argument form");
var threw = false;
try
{
ConditionParser.Parse("equipped()");
}
catch (System.FormatException)
{
threw = true;
}
ModAssert.True(threw, "no argument is an error");
}
[ModTest("worn/nobody_wears_anything_after_reset")]
static void NobodyWearsAfterReset(ModTestContext ctx)
{
var saved = EquipmentService.Data;
try
{
EquipmentService.Data = new EquipmentStateData();
EquipmentService.Data.Characters["tester"] = new Dictionary<string, string> { ["weapon"] = "x" };
ModAssert.True(EquipmentService.Wears("tester", "X"), "wears, item id in any case");
ModAssert.Equal("x", EquipmentService.ItemIn("TESTER", "weapon"), "lookup by id in any case");
EquipmentService.Reset();
ModAssert.Equal(0, EquipmentService.Worn("tester").Count, "reset empties");
}
finally
{
EquipmentService.Data = saved;
}
}
}
}