nonebot-plugin-dnddicer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. nonebot_plugin_dnddicer/__init__.py +72 -0
  2. nonebot_plugin_dnddicer/character/__init__.py +25 -0
  3. nonebot_plugin_dnddicer/character/constants.py +89 -0
  4. nonebot_plugin_dnddicer/character/models.py +229 -0
  5. nonebot_plugin_dnddicer/character/services.py +510 -0
  6. nonebot_plugin_dnddicer/commands/__init__.py +36 -0
  7. nonebot_plugin_dnddicer/commands/base.py +253 -0
  8. nonebot_plugin_dnddicer/commands/battle.py +323 -0
  9. nonebot_plugin_dnddicer/commands/bot.py +69 -0
  10. nonebot_plugin_dnddicer/commands/character.py +281 -0
  11. nonebot_plugin_dnddicer/commands/dnd.py +106 -0
  12. nonebot_plugin_dnddicer/commands/group_config.py +56 -0
  13. nonebot_plugin_dnddicer/commands/help.py +62 -0
  14. nonebot_plugin_dnddicer/commands/hp.py +318 -0
  15. nonebot_plugin_dnddicer/commands/initiative.py +522 -0
  16. nonebot_plugin_dnddicer/commands/roll.py +156 -0
  17. nonebot_plugin_dnddicer/commands/roll_parse_args.py +206 -0
  18. nonebot_plugin_dnddicer/commands/text.py +214 -0
  19. nonebot_plugin_dnddicer/config.py +57 -0
  20. nonebot_plugin_dnddicer/data/__init__.py +29 -0
  21. nonebot_plugin_dnddicer/data/characters.py +99 -0
  22. nonebot_plugin_dnddicer/data/group_config.py +75 -0
  23. nonebot_plugin_dnddicer/data/initiative.py +78 -0
  24. nonebot_plugin_dnddicer/data/schema.py +71 -0
  25. nonebot_plugin_dnddicer/data/service_state.py +84 -0
  26. nonebot_plugin_dnddicer/engine/__init__.py +27 -0
  27. nonebot_plugin_dnddicer/engine/roll/__init__.py +18 -0
  28. nonebot_plugin_dnddicer/engine/roll/_string_utils.py +33 -0
  29. nonebot_plugin_dnddicer/engine/roll/ast_engine/__init__.py +69 -0
  30. nonebot_plugin_dnddicer/engine/roll/ast_engine/adapter.py +375 -0
  31. nonebot_plugin_dnddicer/engine/roll/ast_engine/ast_nodes.py +297 -0
  32. nonebot_plugin_dnddicer/engine/roll/ast_engine/errors.py +112 -0
  33. nonebot_plugin_dnddicer/engine/roll/ast_engine/evaluator.py +498 -0
  34. nonebot_plugin_dnddicer/engine/roll/ast_engine/limits.py +164 -0
  35. nonebot_plugin_dnddicer/engine/roll/ast_engine/parser.py +358 -0
  36. nonebot_plugin_dnddicer/engine/roll/ast_engine/preprocessor.py +91 -0
  37. nonebot_plugin_dnddicer/engine/roll/ast_engine/trace.py +405 -0
  38. nonebot_plugin_dnddicer/engine/roll/default_dice.py +136 -0
  39. nonebot_plugin_dnddicer/engine/roll/karma_runtime.py +40 -0
  40. nonebot_plugin_dnddicer/engine/roll/result.py +131 -0
  41. nonebot_plugin_dnddicer/engine/roll/roll_config.py +14 -0
  42. nonebot_plugin_dnddicer/engine/roll/roll_const.py +14 -0
  43. nonebot_plugin_dnddicer/engine/roll/roll_utils.py +161 -0
  44. nonebot_plugin_dnddicer/engine/roll/sequence_runtime.py +63 -0
  45. nonebot_plugin_dnddicer/initiative/__init__.py +7 -0
  46. nonebot_plugin_dnddicer/initiative/models.py +114 -0
  47. nonebot_plugin_dnddicer/platform/__init__.py +5 -0
  48. nonebot_plugin_dnddicer/platform/onebot_v11.py +89 -0
  49. nonebot_plugin_dnddicer/version.py +4 -0
  50. nonebot_plugin_dnddicer-0.1.0.dist-info/METADATA +177 -0
  51. nonebot_plugin_dnddicer-0.1.0.dist-info/RECORD +53 -0
  52. nonebot_plugin_dnddicer-0.1.0.dist-info/WHEEL +4 -0
  53. nonebot_plugin_dnddicer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,72 @@
1
+ """DNDDicer(屠龙骰):专精 DND5e / DND5r 的 NoneBot2 骰娘插件(OneBot V11)。
2
+
3
+ 项目定位与决策依据:
4
+ - 掷骰引擎移植自 nonebot-dicepp 的 ast_engine(MIT,Copyright (c) 2022 pear-studio,
5
+ 移植落地时引擎文件头保留版权声明与 MIT 许可全文);
6
+ - 业务层全部自研;范围为 DND5e/5r(不做 COC/d100 体系、不做 .mode);
7
+ - 商店合规:零配置可加载、localstore 存储、__plugin_meta__ 完整、全程异步。
8
+ """
9
+
10
+ from nonebot import require
11
+
12
+
13
+ def _nonebot_initialized() -> bool:
14
+ """判断 NoneBot 是否已完成初始化(require 的前提)。
15
+
16
+ 本包可能被两种方式导入:
17
+ - NoneBot 加载流程(NoneBot load / NoneBug 夹具):已初始化,require 必须执行;
18
+ - 直接导入(例如引擎随迁单测在 pytest 收集阶段 import engine 子模块):
19
+ 尚未初始化,此时略过 require 即可(localstore 真正缺失时,在已初始化
20
+ 场景下 require 会正常抛错,不会被此判断掩盖)。
21
+ """
22
+ try:
23
+ from nonebot import get_driver
24
+
25
+ get_driver()
26
+ return True
27
+ except ValueError:
28
+ return False
29
+
30
+
31
+ # 商店合规:依赖其他插件必须先 require() 再 import()
32
+ # (本地数据存储统一走 nonebot-plugin-localstore)
33
+ if _nonebot_initialized():
34
+ require("nonebot_plugin_localstore")
35
+
36
+ from nonebot.plugin import PluginMetadata # noqa: E402
37
+
38
+ from .config import Config # noqa: E402
39
+ from .version import __version__ # noqa: E402
40
+
41
+ # 插件元数据必须位于 __init__.py 最外层(NoneFlow 商店自动检查要求)
42
+ __plugin_meta__ = PluginMetadata(
43
+ # 基本信息
44
+ name="屠龙骰",
45
+ description=(
46
+ "专精 DND5e/5r 跑团的骰娘:掷骰表达式(d20/优势劣势/爆炸骰等)、"
47
+ "角色卡与检定/豁免/攻击、属性生成、HP 管理、先攻列表、战斗轮(.br/.ed)、"
48
+ "群配置、牌堆与规则查询(规划中)。命令手感对齐 nonebot-dicepp。"
49
+ ),
50
+ usage=(
51
+ "发送 .r 2d20kh1+4 掷骰(.rh 为暗骰);.帮助 查看全部指令。"
52
+ "详细指令表随功能落地逐步补充,见 README「用法」节。"
53
+ ),
54
+ # 发布额外信息
55
+ type="application",
56
+ homepage="https://github.com/H-Elden/nonebot-plugin-dnddicer",
57
+ config=Config,
58
+ # 仅支持 OneBot V11 适配器(~ 代表前缀 nonebot.adapters.)
59
+ supported_adapters={"~onebot.v11"},
60
+ extra={"version": __version__},
61
+ )
62
+
63
+ # 子模块导入策略:
64
+ # - engine(掷骰引擎)无初始化副作用,随包导入以尽早暴露导入错误(引擎单测在
65
+ # pytest 收集阶段直接 import 本包时也需要可用);
66
+ # - commands(命令注册:顶层创建 on_message matcher 并读取插件配置)与 data
67
+ # (localstore 存储)必须在 NoneBot 初始化后的加载流程中导入,否则略过——
68
+ # 这正是 NoneBot 加载本插件时的场景(NoneFlow/宿主加载),matcher 照常注册。
69
+ if _nonebot_initialized():
70
+ from . import commands # noqa: E402,F401
71
+ from . import data # noqa: E402,F401
72
+ from . import engine # noqa: E402,F401
@@ -0,0 +1,25 @@
1
+ """DND5e 角色卡/检定业务包(自研,语义对齐 nonebot-dicepp character/dnd5e)。
2
+
3
+ - ``constants``:六属性/18 技能/豁免/攻击词汇表与统一检定条目索引;
4
+ - ``models``:AbilityInfo / HPInfo / DNDCharacter(每人在每群一张卡);
5
+ - ``services``:模板解析(CharacterService.parse)、属性初始化与检定
6
+ (AbilityService.initialize / perform_check)。
7
+ """
8
+
9
+ from .models import AbilityInfo, DNDCharacter, HPInfo # noqa: F401
10
+ from .services import ( # noqa: F401
11
+ AbilityService,
12
+ CharacterService,
13
+ gen_template_char,
14
+ parse_template_to_dict,
15
+ )
16
+
17
+ __all__ = [
18
+ "AbilityInfo",
19
+ "HPInfo",
20
+ "DNDCharacter",
21
+ "AbilityService",
22
+ "CharacterService",
23
+ "gen_template_char",
24
+ "parse_template_to_dict",
25
+ ]
@@ -0,0 +1,89 @@
1
+ """DND5e 角色卡/检定词汇表与索引常量。
2
+
3
+ 词汇与索引结构与 nonebot-dicepp(module/character + core/data/models/character.py)
4
+ 保持一致——六属性 / 18 技能(DND5e 官方技能表,其中「先攻」归入敏捷技能组)/
5
+ 六豁免 / 六攻击,全部汇入统一的「检定条目表」,并附技能→属性、同义词映射;
6
+ 这样规则别名与手感可与 DicePP 对齐,且新增条目只改此处(声明式建模,T3 友好)。
7
+ """
8
+
9
+ # ── 六属性 ──────────────────────────────────────────────────────────────
10
+ ABILITY_LIST = ["力量", "敏捷", "体质", "智力", "感知", "魅力"]
11
+ ABILITY_NUM = len(ABILITY_LIST)
12
+
13
+ # ── 技能(DND5e 18 技能;DicePP 将「先攻」纳入敏捷组技能)────────────────
14
+ SKILL_LIST = [
15
+ # 力量
16
+ "运动",
17
+ # 敏捷
18
+ "体操", "巧手", "隐匿", "先攻",
19
+ # 智力
20
+ "奥秘", "历史", "调查", "自然", "宗教",
21
+ # 感知
22
+ "驯兽", "洞悉", "医药", "察觉", "求生",
23
+ # 魅力
24
+ "欺瞒", "威吓", "表演", "游说",
25
+ ]
26
+ SKILL_NUM = len(SKILL_LIST)
27
+
28
+ #: 技能 → 关联属性
29
+ SKILL_PARENT_DICT = {
30
+ "运动": "力量",
31
+ "体操": "敏捷", "巧手": "敏捷", "隐匿": "敏捷", "先攻": "敏捷",
32
+ "奥秘": "智力", "历史": "智力", "调查": "智力", "自然": "智力", "宗教": "智力",
33
+ "驯兽": "感知", "洞悉": "感知", "医药": "感知", "察觉": "感知", "求生": "感知",
34
+ "欺瞒": "魅力", "威吓": "魅力", "表演": "魅力", "游说": "魅力",
35
+ }
36
+
37
+ #: 技能/属性常见中文别名(与 DicePP 对齐,避免玩家用词差异)
38
+ SKILL_SYNONYM_DICT = {
39
+ "特技": "体操", "妙手": "巧手",
40
+ "潜行": "隐匿", "隐蔽": "隐匿",
41
+ "隐秘": "隐匿", "躲藏": "隐匿",
42
+ "驯养": "驯兽", "驯服": "驯兽",
43
+ "医疗": "医药", "医术": "医药",
44
+ "观察": "察觉", "生存": "求生",
45
+ "欺骗": "欺瞒", "欺诈": "欺瞒",
46
+ "哄骗": "欺瞒", "唬骗": "欺瞒",
47
+ "威胁": "威吓", "说服": "游说",
48
+ }
49
+
50
+ # ── 豁免与攻击(按属性派生)──────────────────────────────────────────────
51
+ SAVING_LIST = ["力量豁免", "敏捷豁免", "体质豁免", "智力豁免", "感知豁免", "魅力豁免"]
52
+ SAVING_PARENT_DICT = {
53
+ "力量豁免": "力量", "敏捷豁免": "敏捷", "体质豁免": "体质",
54
+ "智力豁免": "智力", "感知豁免": "感知", "魅力豁免": "魅力",
55
+ }
56
+
57
+ ATTACK_LIST = ["力量攻击", "敏捷攻击", "体质攻击", "智力攻击", "感知攻击", "魅力攻击"]
58
+ ATTACK_PARENT_DICT = {
59
+ "力量攻击": "力量", "敏捷攻击": "敏捷", "体质攻击": "体质",
60
+ "智力攻击": "智力", "感知攻击": "感知", "魅力攻击": "魅力",
61
+ }
62
+
63
+ # ── 统一检定条目表(属性 + 技能 + 豁免 + 攻击)───────────────────────────
64
+ CHECK_ITEM_LIST = ABILITY_LIST + SKILL_LIST + SAVING_LIST + ATTACK_LIST
65
+ CHECK_ITEM_INDEX_DICT = {name: i for i, name in enumerate(CHECK_ITEM_LIST)}
66
+
67
+ #: 全局附加加值键:作用于所有豁免 / 所有攻击
68
+ SAVING_ALL_KEY = "豁免"
69
+ ATTACK_ALL_KEY = "攻击"
70
+ EXT_ITEM_LIST = CHECK_ITEM_LIST + [SAVING_ALL_KEY, ATTACK_ALL_KEY]
71
+ EXT_ITEM_INDEX_DICT = {name: i for i, name in enumerate(EXT_ITEM_LIST)}
72
+
73
+ # ── 角色卡关键字($xxx$ 模板段落)────────────────────────────────────────
74
+ CHAR_INFO_KEY_NAME = "$姓名$"
75
+ CHAR_INFO_KEY_LEVEL = "$等级$"
76
+ CHAR_INFO_KEY_HP = "$生命值$"
77
+ CHAR_INFO_KEY_HP_DICE = "$生命骰$"
78
+ CHAR_INFO_KEY_ABILITY = "$属性$"
79
+ CHAR_INFO_KEY_PROF = "$熟练$"
80
+ CHAR_INFO_KEY_EXT = "$额外加值$"
81
+ CHAR_INFO_KEY_LIST = [
82
+ CHAR_INFO_KEY_NAME,
83
+ CHAR_INFO_KEY_LEVEL,
84
+ CHAR_INFO_KEY_HP,
85
+ CHAR_INFO_KEY_HP_DICE,
86
+ CHAR_INFO_KEY_ABILITY,
87
+ CHAR_INFO_KEY_PROF,
88
+ CHAR_INFO_KEY_EXT,
89
+ ]
@@ -0,0 +1,229 @@
1
+ """DND5e 角色数据模型(Pydantic,纯数据与基础展示方法)。
2
+
3
+ 字段与计算语义对齐 nonebot-dicepp(core/data/models/character.py,参考实现);
4
+ 存储/检定等复杂业务见 data/characters.py 与 character/services.py。
5
+
6
+ 一期范围:角色卡记录/查看/状态与检定所需字段;生命骰消耗、伤害/治疗、长休等
7
+ 行为逻辑在 T1「HP 管理」模块落地时补充(本模型字段已预留)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import List
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from .constants import (
17
+ ABILITY_NUM,
18
+ CHECK_ITEM_INDEX_DICT,
19
+ CHECK_ITEM_LIST,
20
+ CHAR_INFO_KEY_ABILITY,
21
+ CHAR_INFO_KEY_EXT,
22
+ CHAR_INFO_KEY_HP,
23
+ CHAR_INFO_KEY_HP_DICE,
24
+ CHAR_INFO_KEY_LEVEL,
25
+ CHAR_INFO_KEY_NAME,
26
+ CHAR_INFO_KEY_PROF,
27
+ EXT_ITEM_INDEX_DICT,
28
+ EXT_ITEM_LIST,
29
+ )
30
+
31
+
32
+ class HPInfo(BaseModel):
33
+ """生命值信息(字段兼容 DicePP;行为逻辑 T1 补全)。"""
34
+
35
+ is_init: bool = False
36
+ is_alive: bool = True
37
+ hp_cur: int = 0 # 当前生命值
38
+ hp_max: int = 0 # 最大生命值
39
+ hp_temp: int = 0 # 临时生命值
40
+ hp_dice_type: int = 0 # 生命骰面数
41
+ hp_dice_num: int = 0 # 当前生命骰数量
42
+ hp_dice_max: int = 0 # 生命骰最大数量
43
+
44
+ def initialize(
45
+ self,
46
+ hp_cur: int,
47
+ hp_max: int = 0,
48
+ hp_temp: int = 0,
49
+ hp_dice_type: int = 0,
50
+ hp_dice_num: int = 0,
51
+ hp_dice_max: int = 0,
52
+ ) -> None:
53
+ """按模板字符串解析结果初始化 HP。"""
54
+ assert 0 <= hp_cur <= hp_max, f"无效的生命值信息: {hp_cur}/{hp_max}"
55
+ assert hp_temp >= 0, f"无效的临时生命值信息: {hp_temp}"
56
+ assert 0 <= hp_dice_type <= 100 and 0 <= hp_dice_max <= 1000, \
57
+ f"无效的生命骰信息: {hp_dice_max}颗{hp_dice_type}面骰"
58
+ self.is_init = True
59
+ self.is_alive = True
60
+ self.hp_cur = hp_cur
61
+ self.hp_max = hp_max
62
+ self.hp_temp = hp_temp
63
+ self.hp_dice_type = hp_dice_type
64
+ self.hp_dice_num = hp_dice_num
65
+ self.hp_dice_max = hp_dice_max
66
+
67
+ def is_record_normal(self) -> bool:
68
+ """当前是否正常记录生命值(拥有 HP 值,而非单纯记录受损)。"""
69
+ return self.hp_cur > 0 or (self.hp_cur == 0 and not self.is_alive)
70
+
71
+ def is_record_damage(self) -> bool:
72
+ """当前是否是记录受损生命值的情况。"""
73
+ return not self.is_record_normal()
74
+
75
+ def take_damage(self, value: int) -> None:
76
+ """受到伤害:临时 HP 先吸收,溢出扣当前 HP;降至 0 昏迷。"""
77
+ if self.hp_temp > 0:
78
+ if self.hp_temp >= value:
79
+ self.hp_temp -= value
80
+ return
81
+ else:
82
+ value -= self.hp_temp
83
+ self.hp_temp = 0
84
+ if self.is_alive:
85
+ if self.hp_cur > 0:
86
+ if self.hp_cur > value:
87
+ self.hp_cur -= value
88
+ else:
89
+ self.hp_cur = 0
90
+ self.is_alive = False
91
+ else:
92
+ self.hp_cur -= value
93
+
94
+ def heal(self, value: int) -> None:
95
+ """治疗:不超过最大 HP;受损模式下向 0 恢复。"""
96
+ if self.is_record_normal():
97
+ if self.hp_max == 0:
98
+ self.hp_cur += value
99
+ else:
100
+ self.hp_cur = min(self.hp_max, self.hp_cur + value)
101
+ else:
102
+ self.hp_cur = min(0, self.hp_cur + value)
103
+ self.is_alive = True
104
+
105
+ def long_rest(self) -> str:
106
+ """长休:恢复 HP 至上限、清除临时 HP、回复一半生命骰(至少 1)。"""
107
+ info = ""
108
+ if self.hp_max != 0:
109
+ info = f"生命值回复至上限({self.hp_max})"
110
+ self.hp_cur = self.hp_max
111
+ if self.hp_temp != 0:
112
+ info += f" {self.hp_temp}点临时生命值失效"
113
+ self.hp_temp = 0
114
+ if self.hp_dice_max != 0 and self.hp_dice_type != 0:
115
+ prev_num = self.hp_dice_num
116
+ self.hp_dice_num = int(max(1, min(
117
+ self.hp_dice_max,
118
+ self.hp_dice_num + self.hp_dice_max // 2
119
+ )))
120
+ info += f"\n回复{self.hp_dice_num - prev_num}个生命骰, "
121
+ info += f"当前拥有{self.hp_dice_num}/{self.hp_dice_max}个D{self.hp_dice_type}生命骰"
122
+ return info.strip()
123
+
124
+ def get_info(self) -> str:
125
+ """HP 摘要,如 ``HP:5/10 (4)`` 或 ``损失HP:3``。"""
126
+ temp_info = f" ({self.hp_temp})" if self.hp_temp != 0 else ""
127
+ if self.is_record_normal():
128
+ max_info = f"/{self.hp_max}" if self.hp_max != 0 else ""
129
+ info = f"HP:{self.hp_cur}{max_info}{temp_info}"
130
+ if not self.is_alive:
131
+ info += " 昏迷"
132
+ else:
133
+ info = f"损失HP:{-self.hp_cur}{temp_info}"
134
+ return info
135
+
136
+ def get_char_info(self) -> str:
137
+ """角色卡段落,如 ``$生命值$ 5/10 (4)``。"""
138
+ if not self.is_init:
139
+ return ""
140
+ info = f"{CHAR_INFO_KEY_HP} {self.hp_cur}"
141
+ if self.hp_max > 0:
142
+ info += f"/{self.hp_max}"
143
+ if self.hp_temp > 0:
144
+ info += f" ({self.hp_temp})"
145
+ if self.hp_dice_type > 0:
146
+ info += f"\n{CHAR_INFO_KEY_HP_DICE} {self.hp_dice_num}/{self.hp_dice_max} D{self.hp_dice_type}"
147
+ return info
148
+
149
+
150
+ class AbilityInfo(BaseModel):
151
+ """属性与检定信息。
152
+
153
+ - ``ability``:六属性原始值(力量~魅力);
154
+ - ``check_prof``:每个检定条目的熟练系数(0=未熟练,1=熟练,2=双倍熟练……);
155
+ - ``check_ext``:每个检定条目的额外加值表达式片段(可含全局豁免/攻击键);
156
+ - ``check_adv``:每个条目自带优劣势(1 优势 / -1 劣势 / 0 无)。
157
+ """
158
+
159
+ is_init: bool = False
160
+ version: int = 1
161
+ level: int = 0
162
+ ability: List[int] = Field(default_factory=lambda: [0] * ABILITY_NUM)
163
+ check_prof: List[int] = Field(default_factory=lambda: [0] * len(CHECK_ITEM_LIST))
164
+ check_ext: List[str] = Field(default_factory=lambda: [""] * len(EXT_ITEM_LIST))
165
+ check_adv: List[int] = Field(default_factory=lambda: [0] * len(EXT_ITEM_LIST))
166
+
167
+ def get_prof_bonus(self) -> int:
168
+ """熟练加值:2 + (等级-1)//4(5e 规则)。"""
169
+ return 2 + (self.level - 1) // 4
170
+
171
+ def get_modifier(self, ability_index: int) -> int:
172
+ """属性调整值:(值-10)//2(5e 向下取整)。"""
173
+ return (self.ability[ability_index] - 10) // 2
174
+
175
+ def get_char_info(self) -> str:
176
+ """角色卡属性段落(可直接复制再记录,用于自行保存多卡)。"""
177
+ info = f"{CHAR_INFO_KEY_LEVEL} {self.level}\n"
178
+ info += f"{CHAR_INFO_KEY_ABILITY} {'/'.join(str(v) for v in self.ability)}\n"
179
+
180
+ prof_parts = []
181
+ for index, scale in enumerate(self.check_prof):
182
+ if scale <= 0:
183
+ continue
184
+ name = CHECK_ITEM_LIST[index]
185
+ prof_parts.append(name if scale == 1 else f"{scale}*{name}")
186
+ if prof_parts:
187
+ info += f"{CHAR_INFO_KEY_PROF} {'/'.join(prof_parts)}\n"
188
+
189
+ adv_dict = {
190
+ EXT_ITEM_LIST[i]: flag
191
+ for i, flag in enumerate(self.check_adv) if flag != 0
192
+ }
193
+ ext_parts = []
194
+ for index, ext_str in enumerate(self.check_ext):
195
+ if not ext_str:
196
+ continue
197
+ name = EXT_ITEM_LIST[index]
198
+ prefix = ""
199
+ if name in adv_dict:
200
+ prefix = "优势" if adv_dict[name] > 0 else "劣势"
201
+ ext_parts.append(f"{name}:{prefix}{ext_str}")
202
+ if ext_parts:
203
+ info += f"{CHAR_INFO_KEY_EXT} {'/'.join(ext_parts)}\n"
204
+
205
+ return info.strip()
206
+
207
+
208
+ class DNDCharacter(BaseModel):
209
+ """DND5e 角色卡(每人在每群一张,键 = 群 + QQ)。"""
210
+
211
+ group_id: str
212
+ user_id: str
213
+ name: str = ""
214
+ hp_info: HPInfo = Field(default_factory=HPInfo)
215
+ ability_info: AbilityInfo = Field(default_factory=AbilityInfo)
216
+ is_init: bool = False
217
+
218
+ def get_char_info(self) -> str:
219
+ """完整角色卡文本($xxx$ 段落格式,可再次 .角色卡记录)。"""
220
+ parts = []
221
+ if self.name:
222
+ parts.append(f"{CHAR_INFO_KEY_NAME} {self.name}")
223
+ hp_part = self.hp_info.get_char_info()
224
+ if hp_part:
225
+ parts.append(hp_part)
226
+ ability_part = self.ability_info.get_char_info()
227
+ if ability_part:
228
+ parts.append(ability_part)
229
+ return "\n".join(parts)