commit f834d1e91455d84251643088cadf8263d92d34b7 Author: iisaev Date: Sat Oct 24 10:01:11 2026 +0200 Equipment: slots, gear traits, archetype limits and stat bonuses, equip actions and schema Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..f655552 --- /dev/null +++ b/locale/en.properties @@ -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 diff --git a/locale/es.properties b/locale/es.properties new file mode 100644 index 0000000..d3eb6bc --- /dev/null +++ b/locale/es.properties @@ -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 diff --git a/locale/ja.properties b/locale/ja.properties new file mode 100644 index 0000000..58a05c4 --- /dev/null +++ b/locale/ja.properties @@ -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 用:ステータス → 量 diff --git a/locale/ko.properties b/locale/ko.properties new file mode 100644 index 0000000..64a3f46 --- /dev/null +++ b/locale/ko.properties @@ -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 행동용: 능력치 → 양 diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..a086f3d --- /dev/null +++ b/locale/ru.properties @@ -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: стат → прибавка diff --git a/locale/vi.properties b/locale/vi.properties new file mode 100644 index 0000000..b02eee6 --- /dev/null +++ b/locale/vi.properties @@ -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 diff --git a/locale/zh.properties b/locale/zh.properties new file mode 100644 index 0000000..d2606e8 --- /dev/null +++ b/locale/zh.properties @@ -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 动作:属性 → 数量 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3399e8f --- /dev/null +++ b/manifest.json @@ -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"] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..3e5aad0 Binary files /dev/null and b/mod.jpg differ diff --git a/schemas/equipment.json b/schemas/equipment.json new file mode 100644 index 0000000..40e1520 --- /dev/null +++ b/schemas/equipment.json @@ -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" } ] } + ] +} diff --git a/src/EquipItemAction.cs b/src/EquipItemAction.cs new file mode 100644 index 0000000..23570a5 --- /dev/null +++ b/src/EquipItemAction.cs @@ -0,0 +1,30 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Items; + using Cthangover.Core.Settings; + using Cthangover.Mods.Items; + + /// + /// The item action "ItemAction": "action/equip". With the item type + /// TargetUsed 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. + /// + public class EquipItemAction : ITargetedItemAction + { + /// + public string ID => "action/equip"; + + /// + public bool UseAction(IItem item) => false; + + /// + 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); + } + } +} diff --git a/src/EquipPutAction.cs b/src/EquipPutAction.cs new file mode 100644 index 0000000..aacefdf --- /dev/null +++ b/src/EquipPutAction.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Actions; + + /// + /// action equip.put item=<id> [who=player|<id>] [slot=weapon] [give=1]: + /// 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 give=1 it is + /// granted instead (it need not be in the bag). + /// + public class EquipPutAction : IScenarioAction + { + /// + public string Name => "equip.put"; + + /// + 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)"); + } + } +} diff --git a/src/EquipRemoveAction.cs b/src/EquipRemoveAction.cs new file mode 100644 index 0000000..6fa9032 --- /dev/null +++ b/src/EquipRemoveAction.cs @@ -0,0 +1,23 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Actions; + + /// action equip.remove slot=weapon [who=player|<id>]: takes off what is worn there, back into the inventory. + public class EquipRemoveAction : IScenarioAction + { + /// + public string Name => "equip.remove"; + + /// + 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()); + } + } +} diff --git a/src/EquipSlotInfo.cs b/src/EquipSlotInfo.cs new file mode 100644 index 0000000..207ec75 --- /dev/null +++ b/src/EquipSlotInfo.cs @@ -0,0 +1,32 @@ +namespace Cthangover.Mods.Equipment +{ + using System.Collections.Generic; + using System.Text.Json.Serialization; + using Cthangover.Core.Factories; + + /// + /// A place a character wears something ("Kind": "equip_slot"): 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. + /// + /// An item fits a slot when the item's "Equip" key names one of the slot's + /// types, or the slot id itself when the slot lists none. + /// + [EntityKind("equip_slot")] + public class EquipSlotInfo : IIdentifiable + { + /// Slot id (weapon, body, ring_left). + [JsonPropertyName("Id")] + public string ID { get; set; } + + /// Locale key of the slot's name. + public string Name { get; set; } + + /// Display order in the sheet (lower first). + public int Order { get; set; } + + /// Item equip types the slot takes; empty = items whose type is the slot id. + public List Accepts { get; set; } = new(); + } +} diff --git a/src/EquipmentChangedEvent.cs b/src/EquipmentChangedEvent.cs new file mode 100644 index 0000000..1d34e70 --- /dev/null +++ b/src/EquipmentChangedEvent.cs @@ -0,0 +1,10 @@ +namespace Cthangover.Core.Events +{ + /// A character put something on or took it off (the stats already know; this is for UI and mods). + [EventKey("equipment_changed")] + public class EquipmentChangedEvent : ModEvent + { + /// The character. + public string CharacterId { get; init; } + } +} diff --git a/src/EquipmentModInitializer.cs b/src/EquipmentModInitializer.cs new file mode 100644 index 0000000..cf56f5a --- /dev/null +++ b/src/EquipmentModInitializer.cs @@ -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; + + /// + /// Wires equipment in: the equipped() 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. + /// + public class EquipmentModInitializer : IModInitializer + { + private static bool registered; + + /// + public void OnModLoaded(string modId) + { + if (registered) + return; + registered = true; + LeafParserRegistry.RegisterFirst(new EquippedLeafParser()); + GameLogger.Log("EQUIPMENT", "equipment: condition equipped() registered"); + } + + /// + public void OnModResourcesReady() + { + AppServices.Current.RegisterResettable(new EquipmentReset()); + } + } +} diff --git a/src/EquipmentReset.cs b/src/EquipmentReset.cs new file mode 100644 index 0000000..e88908b --- /dev/null +++ b/src/EquipmentReset.cs @@ -0,0 +1,14 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Bootstrap; + + /// A new game (or the reset before a load) starts with nobody wearing anything. + public class EquipmentReset : IResettable + { + /// + public string Id => "equipment"; + + /// + public void ResetForNewGame() => EquipmentService.Reset(); + } +} diff --git a/src/EquipmentSaveable.cs b/src/EquipmentSaveable.cs new file mode 100644 index 0000000..578da01 --- /dev/null +++ b/src/EquipmentSaveable.cs @@ -0,0 +1,29 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Mods; + using Cthangover.Core.Settings; + using Cthangover.Mods.Stats; + + /// Saves what everyone wears in the equipment slot. + public class EquipmentSaveable : IModSaveable + { + private const string BlobKey = "Worn"; + + /// + public void OnSave(ModSlot slot) + { + slot.Data[BlobKey] = new DataBlob(1, EquipmentService.Data); + } + + /// + 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() ?? new EquipmentStateData(); + StatsService.ClearCache(); + return true; + } + } +} diff --git a/src/EquipmentService.cs b/src/EquipmentService.cs new file mode 100644 index 0000000..f9564dc --- /dev/null +++ b/src/EquipmentService.cs @@ -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; + + /// + /// Who wears what. An item is equipment when its JSON has an "Equip" key (its + /// equip type); what it does while worn is its "Traits" list, read by the stats + /// through . 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 "Archetypes" list limits who may wear + /// an item (a staff for mages only). + /// + /// 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 . + /// + public static class EquipmentService + { + /// The running game's worn items (replaced on a new game and a load). + public static EquipmentStateData Data { get; set; } = new(); + + /// Slots of the loaded mods in display order. + public static IReadOnlyList Slots => + Entities.Collect().Values.Where(s => !string.IsNullOrWhiteSpace(s.ID)) + .OrderBy(s => s.Order).ThenBy(s => s.ID, StringComparer.Ordinal).ToList(); + + /// The slot , or null. + public static EquipSlotInfo Slot(string id) + => Slots.FirstOrDefault(s => string.Equals(s.ID, id, StringComparison.OrdinalIgnoreCase)); + + /// The item definition , or null. + public static ItemInfo Item(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + return null; + var all = Entities.Collect(); + if (all.TryGetValue(itemId, out var info)) + return info; + return all.Values.FirstOrDefault(i => string.Equals(i.ID, itemId, StringComparison.OrdinalIgnoreCase)); + } + + /// The equip type of an item (its "Equip" key), or null for an item that is not gear. + public static string EquipType(string itemId) => ExtraData.Text(Item(itemId)?.ExtraProperties, "Equip"); + + /// Whether the item can be worn at all. + public static bool IsEquipment(string itemId) => !string.IsNullOrWhiteSpace(EquipType(itemId)); + + /// Whether takes items of . + 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)); + } + + /// The slots an item fits, in display order. + public static List SlotsFor(string itemId) + { + var type = EquipType(itemId); + return Slots.Where(s => Accepts(s, type)).ToList(); + } + + /// Slot id → item id of what wears (empty when nothing). + public static IReadOnlyDictionary Worn(string who) + { + var id = StatsService.Resolve(who); + return Find(id) ?? new Dictionary(); + } + + /// The item in a slot of , or null. + public static string ItemIn(string who, string slot) + => slot != null && Worn(who).TryGetValue(slot, out var item) ? item : null; + + /// Whether wears in any slot. + public static bool Wears(string who, string itemId) + => Worn(who).Values.Any(i => string.Equals(i, itemId, StringComparison.OrdinalIgnoreCase)); + + /// + /// Whether may wear the item (in , or in + /// any slot that fits); is the locale key of why not. + /// + 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>(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; + } + + /// + /// Puts the item on . Without a slot it takes the first fitting + /// empty slot, else the first fitting one. With 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. + /// + 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; + } + + /// Takes off what wears in , back into the inventory. + 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; + } + + /// Back to a new game: nobody wears anything. + 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 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 Of(string who) + { + var worn = Find(who); + if (worn != null) + return worn; + worn = new Dictionary(StringComparer.OrdinalIgnoreCase); + Data.Characters[who] = worn; + return worn; + } + + private static void Changed(string who) + { + StatsService.Changed(who); + ModEventBus.Publish(new EquipmentChangedEvent { CharacterId = who }); + } + } +} diff --git a/src/EquipmentSheetSection.cs b/src/EquipmentSheetSection.cs new file mode 100644 index 0000000..1776cc6 --- /dev/null +++ b/src/EquipmentSheetSection.cs @@ -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; + + /// + /// 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. + /// + public class EquipmentSheetSection : ISheetSection + { + /// + public string Id => "equipment"; + + /// + public int Order => 10; + + /// + public string TitleKey => "equipment/sheet/title"; + + /// + public bool AppliesTo(string characterId) => EquipmentService.Slots.Count > 0; + + /// + 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(); + } +} diff --git a/src/EquipmentStateData.cs b/src/EquipmentStateData.cs new file mode 100644 index 0000000..7527ec9 --- /dev/null +++ b/src/EquipmentStateData.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Mods.Equipment +{ + using System; + using System.Collections.Generic; + + /// What everyone wears: character id → slot id → item id (the equipment slot's one blob). + public class EquipmentStateData + { + /// Worn items per character and slot. + public Dictionary> Characters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/EquipmentTraitSource.cs b/src/EquipmentTraitSource.cs new file mode 100644 index 0000000..64f0da6 --- /dev/null +++ b/src/EquipmentTraitSource.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Mods.Equipment +{ + using System.Collections.Generic; + using Cthangover.Mods.Stats; + + /// The traits of everything a character wears: each worn item's "Traits" list. + public class EquipmentTraitSource : ITraitSource + { + /// + public string Id => "equipment"; + + /// + public IEnumerable 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; + } + } + } +} diff --git a/src/EquippedCondition.cs b/src/EquippedCondition.cs new file mode 100644 index 0000000..93775c7 --- /dev/null +++ b/src/EquippedCondition.cs @@ -0,0 +1,24 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Scenes; + + /// equipped(who, item): true while the character wears the item in any slot. + public class EquippedCondition : ConditionNode + { + /// Creates the condition. + public EquippedCondition(string who, string item) + { + Who = who; + Item = item; + } + + /// Who must wear it (a character id or player). + public string Who { get; } + + /// The item id. + public string Item { get; } + + /// + public override bool Evaluate(EvaluationContext ctx) => EquipmentService.Wears(Who, Item); + } +} diff --git a/src/EquippedLeafParser.cs b/src/EquippedLeafParser.cs new file mode 100644 index 0000000..b4a8f06 --- /dev/null +++ b/src/EquippedLeafParser.cs @@ -0,0 +1,39 @@ +namespace Cthangover.Mods.Equipment +{ + using System; + using System.Collections.Generic; + using Cthangover.Core.Scenes; + using Cthangover.Core.Scenes.Parsing; + + /// + /// equipped(who, item) or equipped(item) (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: equipped('rpg_plus/iron_sword'). + /// + public class EquippedLeafParser : ILeafParser + { + /// + public bool CanParse(TokenPeeker peeker) + => !peeker.AtEnd && peeker.Peek.Type == ConditionParser.TokenType.Ident && peeker.Peek.Value == "equipped" + && peeker.PeekAhead(1).Type == ConditionParser.TokenType.LParen; + + /// + public ICondition Parse(TokenPeeker peeker, Func parseSubExpr) + { + peeker.Advance(); + peeker.Expect(ConditionParser.TokenType.LParen, "expected '('"); + var args = new List(); + 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]); + } + } +} diff --git a/src/StatUpItemAction.cs b/src/StatUpItemAction.cs new file mode 100644 index 0000000..d59865b --- /dev/null +++ b/src/StatUpItemAction.cs @@ -0,0 +1,35 @@ +namespace Cthangover.Mods.Equipment +{ + using Cthangover.Core.Items; + using Cthangover.Core.Settings; + using Cthangover.Mods.Items; + using Cthangover.Mods.Stats; + + /// + /// The item action "ItemAction": "action/stat_up": a tonic, a seed, a book that + /// permanently raises stats of the party member it is applied to, by the item's own + /// "StatUp" key ({"str": 1}, negative lowers). It lives here because this + /// mod is where items meet the stats; it uses the inventory's target picker like gear. + /// + public class StatUpItemAction : ITargetedItemAction + { + /// + public string ID => "action/stat_up"; + + /// + public bool UseAction(IItem item) => false; + + /// + public bool UseOn(IItem item, CharacterInfoData target) + { + if (item == null || target == null) + return false; + var ups = ExtraData.Read>(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; + } + } +} diff --git a/tests/EquipmentServiceTests.cs b/tests/EquipmentServiceTests.cs new file mode 100644 index 0000000..3a82a9e --- /dev/null +++ b/tests/EquipmentServiceTests.cs @@ -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; + + /// + /// 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. + /// + 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 { "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 { ["weapon"] = "rpg_plus/iron_sword", ["body"] = "rpg_plus/leather_armor" }; + + var restored = JsonSerializer.Deserialize(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 { ["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; + } + } + } +}