23 Commits 7a4616bff2 ... 143f3cde25

Tác giả SHA1 Thông báo Ngày
  dzp 143f3cde25 fix(pkuxkx): 修复识别不到特技激活标志的问题 3 tuần trước cách đây
  dzp c29fdd9b82 feat(pkuxkx): char/skills 里的一些小重构,影响不大 1 ngày trước cách đây
  dzp 40627fa0b1 feat(pkuxkx): 修改食物和饮水字段,增加前缀「当前」,添加百分比字段,添加六个关于食物和饮水的 API 3 ngày trước cách đây
  dzp a1e74130da fix(pkuxkx): typo 1 tuần trước cách đây
  dzp 606eb41bdc feat(lib/alert): 因为 storage API 发生变化造成的适应性修改 1 ngày trước cách đây
  dzp eba1acd0a2 feat(lib/option): 因为 storage API 变化造成的适应性修改 1 ngày trước cách đây
  dzp 2ba16c877c feat(pkuxkx): pp-server 部分因为 storage API 变化造成的适应性修改 1 ngày trước cách đây
  dzp 0e3c7aaf22 feat(pkuxkx): chat 捕捉五绝名号部分因为 storage API 发生变化造成的适应性修改 1 ngày trước cách đây
  dzp ee3379681c feat(storage): 重构 storage 模块以支持角色私有数据读写,废弃 storage.[GS]etGlobal 3 ngày trước cách đây
  dzp 5965508b15 fix(event): 修复 event.ClassHandle 的 bug,可能导致某些回调事件无法正确注销,进而导致调用已销毁的别名 1 tuần trước cách đây
  dzp 647a9a7402 feat(xtintin): 增加两个新 API: str.FormatIf 和 str.FormatIfElse 1 tuần trước cách đây
  dzp 888c090086 feat(pkuxkx): 增加实用函数 map.Room.IsAt,代替广为流传的直接和 gMapRoom[name] 对比 1 tuần trước cách đây
  dzp 332cb45b8c feat(pkuxkx): 更美观的 skbrief 输出 1 tuần trước cách đây
  dzp 9b728dc368 feat(pkuxkx): 增加三个新的 API:char.CanLianxi/CanLingwu/CanXiulian 1 tuần trước cách đây
  dzp 7d8eae38a2 feat(pkuxkx): 增加一个 API:char.InFamily 来判断角色是否属于指定的门派 1 tuần trước cách đây
  dzp 05d4edc2ad feat(pkuxkx): 修改 HP 摘要,增加「泰」标志和当前选中的敌人 1 tuần trước cách đây
  dzp 69a4307033 feat(pkuxkx): char 模块增加几个实用 API,覆盖打坐,吐纳,提纯,中毒状态查询 1 tuần trước cách đây
  dzp 2cdb834c27 feat(pkuxkx): char[HP] 中增加真气百分比 1 tuần trước cách đây
  dzp 696cc08449 feat(pkuxkx): 新增美化字符 3 tuần trước cách đây
  dzp 4b8470d221 feat(pkuxkx): 新增北侠 GMCP.System 事件支持 3 tuần trước cách đây
  dzp b087ac0eb7 SQUASH: feat(xtintin): 增加一个通用的解析服务器表格的工具 re.ParseServerTable 3 tuần trước cách đây
  dzp f759c321cb chore: 忽略 AI 相关文件,美化 just help 输出 1 tuần trước cách đây
  dzp 5b012cfada feat(pkuxkx): 房间名天气新增『飞雪』、『异象』 3 tuần trước cách đây

+ 4 - 0
.gitignore

@@ -46,3 +46,7 @@ var/*
 var
 
 data/*
+
+CLAUDE.md
+AGENTS.md
+opencode.json

+ 1 - 1
Justfile

@@ -1,7 +1,7 @@
 # 默认行为,也就是本命令
 help:
     @echo '输入 just <recipe> 执行预定义的动作。'
-    @just --list --unsorted
+    @just-list 2>/dev/null || just --list --unsorted
 
 # 制作镜像
 build-image:

+ 7 - 4
mud/pkuxkx/plugins/basic/char/gmcp.tin

@@ -48,8 +48,8 @@ event.Define {char/nofight} {无参} {$MODULE} {角色脱离战斗};
         {经验}          {经验}          {combat_exp}        {经验}
         {潜能}          {潜能}          {potential}         {潜能}
         {真元}          {真元}          {vigour/yuan}       {真元}
-        {食物}          {食物}          {food}              {食物}
-        {饮水}          {饮水}          {water}             {饮水}
+        {食物}          {当前食物}      {food}              {当前食物}
+        {饮水}          {当前饮水}      {water}             {当前饮水}
         {战意}          {战意}          {fighter_spirit}    {战意}
     }
 
@@ -104,7 +104,6 @@ event.Define {char/nofight} {无参} {$MODULE} {角色脱离战斗};
                 speedo.Set {$name} {$value} {true} {10};
                 #local bakName {@str.Replace{$name;当前;上次}};
                 #local pctName {@str.Replace{$name;当前%*;&1百分比}};
-                #var char[HP][$bakName] {0};
                 #var char[HP][$bakName] {$char[HP][$name]};
                 #local needUpdate[$pctName] {true};
             };
@@ -137,8 +136,12 @@ event.Define {char/nofight} {无参} {$MODULE} {角色脱离战斗};
             #if { "$name" == "{经验|潜能}" } {
                 speedo.Set {$name} {$value} {true} {600};
             };
-            #elseif { "$name" == "{食物|饮水}" } {
+            #elseif { "$name" == "当前{食物|饮水}" } {
                 speedo.Set {$name} {$value} {true} {10};
+                #local bakName {@str.Replace{$name;当前;上次}};
+                #local pctName {@str.Replace{$name;当前%*;&1百分比}};
+                #var char[HP][$bakName] {$char[HP][$name]};
+                #local needUpdate[$pctName] {true};
             };
             #continue;
         };

+ 231 - 28
mud/pkuxkx/plugins/basic/char/hp.tin

@@ -32,11 +32,13 @@ event.Define {char/hpneili}  {无参}  {$MODULE} {已经获取到 hp -neili 命
     #var char[HP][上次内力]     {0};
     #var char[HP][当前内力]     {0};
     #var char[HP][最大内力]     {1};
+    #var char[HP][内力上限]     {1};
     #var char[HP][加力]         {0};
     #var char[HP][内力百分比]   {0};
     #var char[HP][上次精力]     {0};
     #var char[HP][当前精力]     {0};
     #var char[HP][最大精力]     {1};
+    #var char[HP][精力上限]     {1};
     #var char[HP][精力百分比]   {0};
     #var char[HP][上次气血]     {0};
     #var char[HP][当前气血]     {0};
@@ -55,11 +57,11 @@ event.Define {char/hpneili}  {无参}  {$MODULE} {已经获取到 hp -neili 命
     #var char[HP][最大真气]     {1};
     #var char[HP][真气减伤]     {0};
     #var char[HP][真元]         {0};
-    #var char[HP][食物]         {0};
-    #var char[HP][最大食物]     {1};
+    #var char[HP][当前食物]     {0};
+    #var char[HP][最大食物]     {200};
     #var char[HP][饥饿]         {};
-    #var char[HP][饮水]         {0};
-    #var char[HP][最大饮水]     {1};
+    #var char[HP][当前饮水]     {0};
+    #var char[HP][最大饮水]     {200};
     #var char[HP][口渴]         {};
     #var char[HP][气势]         {0};
     #var char[HP][状态]         {};
@@ -111,18 +113,18 @@ hp 有两种格式,非战斗状态:
         speedo.Set {当前真气} {$char[HP][当前真气]} {true} {10};
     };
     #action {^│【食物】%s%d%s/%s%d%s[%S]%!s│【潜能】%s%S%!s│$} {
-        #var char[HP][食物]     {%%2};
+        #var char[HP][当前食物] {%%2};
         #var char[HP][最大食物] {%%5};
         #var char[HP][饥饿]     {%%7};
         #var char[HP][潜能显示] {%%9};
-        speedo.Set {食物} {$char[HP][食物]} {true} {10};
+        speedo.Set {当前食物} {$char[HP][当前食物]} {true} {10};
     };
     #action {^│【饮水】%s%d%s/%s%d%s[%S]%!s│【经验】%s%S%!s│$} {
-        #var char[HP][饮水]     {%%2};
+        #var char[HP][当前饮水] {%%2};
         #var char[HP][最大饮水] {%%5};
         #var char[HP][口渴]     {%%7};
         #var char[HP][经验显示] {%%9};
-        speedo.Set {饮水} {$char[HP][饮水]} {true} {10};
+        speedo.Set {当前饮水} {$char[HP][当前饮水]} {true} {10};
     };
     #action {^│【气势】%s%d%s\%%s 【平衡】%s%d%s\%%!s│$} {
         #var char[HP][气势]     {%%2};
@@ -154,6 +156,9 @@ hp 有两种格式,非战斗状态:
         #math char[HP][精力百分比] { $char[HP][当前精力] * 50  / $char[HP][最大精力] };
         #math char[HP][气血健康度] { $char[HP][有效气血] * 100 / $char[HP][最大气血] };
         #math char[HP][精神健康度] { $char[HP][有效精神] * 100 / $char[HP][最大精神] };
+        #math char[HP][真气百分比] { $char[HP][当前真气] * 100 / $char[HP][最大真气] };
+        #math char[HP][食物百分比] { $char[HP][当前食物] * 100 / $char[HP][最大食物] };
+        #math char[HP][饮水百分比] { $char[HP][当前饮水] * 100 / $char[HP][最大饮水] };
         event.Emit char/hp;
         char.hp.parse.done;
     };
@@ -203,8 +208,6 @@ hp -neili 格式:
 #alias {char.hp.parse.hp-neili} {
     #class char-hp-parse-hp-neili open;
 
-    #var char[HP][内力上限] {1};
-
     #action {^│%S(%S) %s %S %s - %s %d/%s%d(%d{|\)}%s│{|ID=char/hp-neili}$} {
         #local name     {@str.Trim{%%1}};
         #local id       {@str.Trim{%%2}};
@@ -317,12 +320,14 @@ hp -neili 格式:
 #action {^#%1,%2,%3,%4,{0|1},{0|1}$} {
     #var char[HP][当前真气]  {@char.hp.expandBigNumber{%1}};
     #var char[HP][当前真元]  {@char.hp.expandBigNumber{%2}};
-    #var char[HP][食物]      {@char.hp.expandBigNumber{%3}};
-    #var char[HP][饮水]      {@char.hp.expandBigNumber{%4}};
+    #var char[HP][当前食物]  {@char.hp.expandBigNumber{%3}};
+    #var char[HP][当前饮水]  {@char.hp.expandBigNumber{%4}};
 
     speedo.Set {当前真气}   {$char[HP][当前真气]}   {true} {10};
-    speedo.Set {食物}       {$char[HP][食物]}       {true} {10};
-    speedo.Set {饮水}       {$char[HP][饮水]}       {true} {10};
+    speedo.Set {当前食物}   {$char[HP][当前食物]}   {true} {10};
+    speedo.Set {当前饮水}   {$char[HP][当前饮水]}   {true} {10};
+
+    #math char[HP][真气百分比] { $char[HP][当前真气] * 100 / $char[HP][最大真气] };
 
     #if { "%5" == "1" } {
         char.set-flag 战斗中 true false;
@@ -437,6 +442,9 @@ hp -neili 格式:
     #if { $char[HP][气血百分比] < 30 || $char[HP][精神百分比] < 70 } {
         #local safety {危险};
     };
+    #elseif { $char[HP][气血百分比] > 95 && $char[HP][精神百分比] > 98 } {
+        #local safety {康泰};
+    };
     #elseif { $char[HP][气血百分比] > 80 && $char[HP][精神百分比] > 95 } {
         #local safety {安全};
     };
@@ -450,6 +458,11 @@ hp -neili 格式:
         {背景} { {平常}{<099>} {战斗}{<B00A>} {战安}{<B040>} {战危}{<B600>} {战忙}{<B808>} {危急}{<BA00>} {正忙}{<B330>} }
     }}};
 
+    #local char.hp.fight.target {};
+    #if { @existsFunction{fight.target.Brief} } {
+        #local char.hp.fight.target {@fight.target.Brief{}};
+    };
+
     #switch {"$char[HP][战斗中]/$char[HP][忙]/$safety"} {
         #case {"false/false/%*"} {
             #local head    {$theme[背景][平常]$theme[前景][平常]};
@@ -461,27 +474,35 @@ hp -neili 格式:
         };
         #case {"true/false/"} {
             #local head    {$theme[背景][战斗]$theme[前景][战斗]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势])};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $char.hp.fight.target};
         };
         #case {"true/true/"} {
             #local head    {$theme[背景][战忙]$theme[前景][战忙]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战忙]忙<298>};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战忙]忙 $char.hp.fight.target<298>};
+        };
+        #case {"true/false/康泰"} {
+            #local head    {$theme[背景][战安]$theme[前景][战安]};
+            #local tail     { $theme[前景][战安]战($char[HP][气势]) $theme[前景][战安]泰 $char.hp.fight.target<298>};
+        };
+        #case {"true/true/康泰"} {
+            #local head    {$theme[背景][战忙]$theme[前景][战忙]};
+            #local tail     { $theme[前景][战安]战($char[HP][气势]) $theme[前景][战安]泰 $theme[前景][战忙]忙 $char.hp.fight.target<298>};
         };
         #case {"true/false/安全"} {
             #local head    {$theme[背景][战安]$theme[前景][战安]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战安]安<298>};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战安]安 $char.hp.fight.target<298>};
         };
         #case {"true/true/安全"} {
             #local head    {$theme[背景][战忙]$theme[前景][战忙]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战安]安 $theme[前景][战忙]忙<298>};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战安]安 $theme[前景][战忙]忙 $char.hp.fight.target<298>};
         };
         #case {"true/false/危险"} {
             #local head    {$theme[背景][战危]$theme[前景][战危]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战危]危<298>};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战危]危 $char.hp.fight.target<298>};
         };
         #case {"true/true/危险"} {
             #local head    {$theme[背景][危急]$theme[前景][危急]};
-            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战危]危 $theme[前景][战忙]忙<298>};
+            #local tail     { $theme[前景][战斗]战($char[HP][气势]) $theme[前景][战危]危 $theme[前景][战忙]忙 $char.hp.fight.target<298>};
         };
     };
 
@@ -497,7 +518,7 @@ hp -neili 格式:
     };
 
     #local label {气血};
-    #if { $char[HP][食物] <= 0 || $char[HP][饮水] <= 0 } {
+    #if { @char.MustEat{} || @char.MustDrink{} } {
         #local label {<118>气血};
     };
 
@@ -536,7 +557,7 @@ hp -neili 格式:
             #local 每秒精神变化 {};
         };
         #local label {精神};
-        #if { $char[HP][食物] <= 0 || $char[HP][饮水] <= 0 } {
+        #if { @char.MustEat{} || @char.MustDrink{} } {
             #local label {<118>精神};
         };
         #format 精神 { $labelColor$label<298>: %s%s/%s}
@@ -551,7 +572,7 @@ hp -neili 格式:
         #nop 气血治疗的数值展示为每分钟可以治疗的最大血量的倍数(以百分比形式);
         #local {气血治疗} {@math.Int{@math.Eval{$char[STATUS][气血治疗] * 100 * 60 / $char[HP][最大气血]}}};
         #local label {回气};
-        #if { !@char.IsBusy{} && $char[HP][食物] > 0 && $char[HP][饮水] > 0 && ($char[HP][气血百分比] < 100 || $char[HP][气血健康度] < 100) } {
+        #if { !@char.IsBusy{} && !@char.MustEat{} && !@char.MustDrink{} && ($char[HP][气血百分比] < 100 || $char[HP][气血健康度] < 100) } {
             #local label {<128><588>回气\e[25m};
         };
         #format {回气} { $labelColor$label: %s/%s} {@char.hp.colorit{${气血恢复}}} {@char.hp.colorit{${气血治疗}}};
@@ -564,7 +585,7 @@ hp -neili 格式:
         #nop 精神治疗的数值展示为每分钟可以治疗的最大精神的倍数(以百分比形式);
         #local {精神治疗} {@math.Int{@math.Eval{$char[STATUS][精神治疗] * 100 * 60 / $char[HP][最大精神]}}};
         #local label {回精};
-        #if { !@char.IsBusy{} && $char[HP][食物] > 0 && $char[HP][饮水] > 0 && ($char[HP][精神百分比] < 100 || $char[HP][精神健康度] < 100) } {
+        #if { !@char.IsBusy{} && !@char.MustEat{} && !@char.MustDrink{} && ($char[HP][精神百分比] < 100 || $char[HP][精神健康度] < 100) } {
             #local label {<128><588>回精\e[25m};
         };
         #format {回精} { $labelColor$label: %s/%s} {@char.hp.colorit{${精神恢复}}} {@char.hp.colorit{${精神治疗}}};
@@ -588,16 +609,16 @@ hp -neili 格式:
     #local 食水 {};
     #if { $width >= 110 } {
         #local label {食水};
-        #if { $char[HP][食物] <= 0 || $char[HP][饮水] <= 0 } {
+        #if { @char.MustEat{} || @char.MustDrink{} } {
             #local label {<118><588>食水\e[25m<288>};
         };
         #if { @char.InCombat{} } {
-            #format {食水} { $label: %s/%s} {$char[HP][食物]} {$char[HP][饮水]};
+            #format {食水} { $label: %s/%s} {$char[HP][当前食物]} {$char[HP][当前饮水]};
         };
         #else {
             #format {食水} { $label: %s/%s(%s/%s)}
-                    {$char[HP][食物]} {$char[HP][饮水]}
-                    {@speedo.GetSpeed{食物;60}} {@speedo.GetSpeed{饮水;60}};
+                    {$char[HP][当前食物]} {$char[HP][当前饮水]}
+                    {@speedo.GetSpeed{当前食物;60}} {@speedo.GetSpeed{当前饮水;60}};
         };
     };
 
@@ -763,6 +784,114 @@ event.Handle {option/changed} {opt/HPSummarize.Pause/pkuxkx} {$MODULE} {char.HPS
     };
 };
 
+///=== {
+// #@ char.hp.GetPercent <属性名称>
+//    返回指定属性的当前百分比。
+//    参数说明:
+//      - 属性名称: 支持 食物、饮水、气血、精神、内力、精力、真气。
+//    百分比由 PaoTin++ 根据 GMCP.Status 推送的当前值与最大值计算,是 PaoTin++ 的便利层。
+//    参数非法或数据未就绪时返回 -1。
+// };
+#func {char.hp.GetPercent} {
+    #local name {%1};
+
+    #if { "$name" != "{食物|饮水|气血|精神|内力|精力|真气}" } {
+        xtt.Usage char.hp.GetPercent;
+        #return -1;
+    };
+
+    #local percent {$char[HP][$name百分比]};
+    #if { "$percent" == "" } {
+        #return -1;
+    };
+
+    #return $percent;
+};
+
+///=== {
+// #@ char.MustEat
+//    如果角色已经饿到极点(食物百分比 ≤ 0),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前食物值保守估计。
+// };
+#func {char.MustEat} {
+    #if { @char.hp.GetPercent{食物} <= 0 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
+///=== {
+// #@ char.MustDrink
+//    如果角色已经渴到极点(饮水百分比 ≤ 0),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前饮水值保守估计。
+// };
+#func {char.MustDrink} {
+    #if { @char.hp.GetPercent{饮水} <= 0 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
+///=== {
+// #@ char.NeedEat
+//    如果角色饿了(食物百分比 ≤ 50),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前食物值保守估计。
+// };
+#func {char.NeedEat} {
+    #if { @char.hp.GetPercent{食物} <= 50 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
+///=== {
+// #@ char.NeedDrink
+//    如果角色渴了(饮水百分比 ≤ 50),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前饮水值保守估计。
+// };
+#func {char.NeedDrink} {
+    #if { @char.hp.GetPercent{饮水} <= 50 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
+///=== {
+// #@ char.CanEatMore
+//    如果角色还能继续进食(食物百分比 ≤ 95),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前食物值保守估计。
+// };
+#func {char.CanEatMore} {
+    #if { @char.hp.GetPercent{食物} <= 95 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
+///=== {
+// #@ char.CanDrinkMore
+//    如果角色还能继续饮水(饮水百分比 ≤ 95),则返回真,否则返回假。
+//    百分比数据未就绪时,按当前饮水值保守估计。
+// };
+#func {char.CanDrinkMore} {
+    #if { @char.hp.GetPercent{饮水} <= 95 } {
+        #return 1;
+    };
+    #else {
+        #return 0;
+    };
+};
+
 ///=== {
 // #@ char.InDazuo
 //    如果角色正在打坐,则返回真,否则返回假。无参数。
@@ -802,6 +931,80 @@ event.Handle {option/changed} {opt/HPSummarize.Pause/pkuxkx} {$MODULE} {char.HPS
     };
 };
 
+///=== {
+// #@ char.CanDazuo
+//    如果角色最大内力点数没有达到最大上限,则返回真,否则返回假。无参数。
+// };
+#func {char.CanDazuo} {
+    #local limit {$char[HP][内力上限]};
+
+    #if { $limit == 1 } {
+        warnLog 无法获得角色内力上限,疑似数据未更新。;
+        #return 0;
+    };
+
+    #if { $char[HP][最大内力] < $limit } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
+///=== {
+// #@ char.CanTuna
+//    如果角色最大精力点数没有达到最大上限,则返回真,否则返回假。无参数。
+// };
+#func {char.CanTuna} {
+    #local limit {$char[HP][精力上限]};
+
+    #if { $limit == 1 } {
+        warnLog 无法获得角色精力上限,疑似数据未更新。;
+        #return 0;
+    };
+
+    #if { $char[HP][最大精力] < $limit } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
+///=== {
+// #@ char.CanTichun
+//    如果角色拥有真气,并且真气不满,则返回真,否则返回假。无参数。
+// };
+#func {char.CanTichun} {
+    #if { $char[HP][最大真气] > 1 && $char[HP][真气百分比] < 100 } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
+///=== {
+// #@ char.IsPoisoned
+//    如果角色中毒了,则返回真,否则返回假。无参数。
+// };
+#func {char.IsPoisoned} {
+    #if { "$char[HP][状态]" == "{.*毒.*|.*火焰刀.*}" || "$char[HP][毒]" !== "" } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
+///=== {
+// #@ char.IsPoisonActive
+//    如果角色中毒了,且没有被压制,则返回真,否则返回假。无参数。
+// };
+#func {char.IsPoisonActive} {
+    #if { @char.IsPoisoned{} && ! @sset.Contains{{$char[HP][状态]};毒性压制} } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
 #action {^虽然藏而不发,你仍在不断积蓄攻势。(气势:%d%)$E}              {char.hp.set-qishi %1};
 #action {^你在攻击中不断积蓄攻势。(气势:%d%)$E}                        {char.hp.set-qishi %1};
 #action {^你蓄足攻势,在战斗中发出绝招「%*」。(气势:%d%)$E}            {char.hp.set-qishi %2};

+ 6 - 0
mud/pkuxkx/plugins/basic/char/jifa.tin

@@ -84,6 +84,7 @@ event.Define {char/jifa}    {无参}  {$MODULE} {已经获取到 jifa/enable 命
             #var char[Skills][$baseName][jifa-level]    {$jifaLevel};
             #var char[Skills][$baseName][jifa-type]     {$char-jifa-type};
         };
+
         #if { &char[Skills][$skillName][] > 0 } {
             #if { "$char[Skills][$skillName][jifa-to]" == "" } {
                 #var char[Skills][$skillName][jifa-to]  {$baseName};
@@ -93,6 +94,11 @@ event.Define {char/jifa}    {无参}  {$MODULE} {已经获取到 jifa/enable 命
             };
             #var char[Skills][$skillName][jifa-level]   {$jifaLevel};
         };
+
+        #if { "$baseName" == "基本内功" } {
+            #nop 精力上限先按照基本内功激发等级的 10 倍计算,简单粗暴,基本够用。;
+            #math char[HP][精力上限] {$jifaLevel * 10};
+        };
     };
 
     #action {@re.TableTail{char/jifa}} {

+ 14 - 0
mud/pkuxkx/plugins/basic/char/score.tin

@@ -239,6 +239,20 @@ event.Define {char/saving}  {无参} {$MODULE} {银行存款数据更新};
     #class char-score-parser close;
 };
 
+///=== {
+// #@ char.InFamily <门派列表>
+//    如果角色属于指定的门派,则返回真,否则返回假。
+// };
+#func {char.InFamily} {
+    #local family {%0};
+
+    #if { @sset.Contains{{$family};$char[档案][门派]} } {
+        #return 1;
+    };
+
+    #return 0;
+};
+
 #nop 取钱提示;
 #action {^%+1..4u记完帐,告诉你:「您在敝商号现还有%*。」$} {
     score.update-balance {%2};

+ 167 - 23
mud/pkuxkx/plugins/basic/char/skills.tin

@@ -68,6 +68,8 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
 #alias {cha}        {char.Skills};
 #alias {skbrief}    {char.Skbrief {%1} nogag};
 
+VAR {技能 ID 到技能名称的转换} char.skills.id2name {};
+
 #alias {char.Skills} {
     #local args {%0};
 
@@ -148,6 +150,8 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
             {limit}     {$limit}
         };
 
+        #var char.skills.id2name[$code] {$name};
+
         #if { $char-skill-limit < $limit } {
             #var char-skill-limit $limit;
         };
@@ -249,31 +253,74 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
 };
 
 #alias {char.Skbrief} {
-    #local id   {%1};
+    #local skillID {%1};
 
-    #if { "$id" == "" } {
+    #if { "$skillID" == "" } {
         errLog 用法: skbrief <技能ID>;
         #return;
     };
 
-    #alias {char.Skbrief.parse} {
+    #alias {char.Skbrief.update} {
+        #local lvl      {%%1};
+        #local point    {%%2};
+
+        #local skillID  {%1};
+
+        #local skillName {$char.skills.id2name[$skillID]};
+        #if { "$skillName" == "" } {
+            errLog skills 相关触发出现 BUG,请联系开发者。;
+            #return;
+        };
+
+        #local delta {@math.Eval{$point - $char[Skills][$skillName][point]}};
+        #if { $char[Skills][$skillName][lvl] != $lvl } {
+            #local delta 0;
+        };
+
+        #var char[Skills][$skillName][lvl]      {$lvl};
+        #var char[Skills][$skillName][point]    {$point};
+
+        #local level {};
+
+        #nop 领悟有时候会领悟过头,小点偶尔会超过等级的平方。;
+        #nop XXX: 这个时候就强制限制到 xxx.99,以免引起重大误解。;
+        #if { $point >= $lvl ** 2 } {
+            #local level {@math.Eval{$lvl + 0.99}};
+        };
+        #else {
+            #math level {$lvl*1.0000000000000000 + $point*1.00 / ($lvl ** 2)};
+            #replace level {^%*.%.%.%*$} {&1.&2&3};
+        };
+
+        #var char[Skills][$skillName][level]    {$level};
+
+        #local limit    {@char.SkillLimit{$skillName}};
+        #local jifa     {@char.GetJifaSkill{$skillName}};
+        #local jifaLvl  {@char.SkillLevel{$jifa}};
+        #local delta    {@str.FormatIfElse{$delta > 0; {(<129>+<299>%s)}; {}; $delta}};
+        #local desc     {<== <039>$level<299> / <069>$limit<299> <== <Ffc0>$skillName<299>};
+
+        #if { "$jifa" != "" } {
+            #cat desc { 当前激发至 <059>$jifa<299>($jifaLvl)};
+        };
+        #else {
+            #cat desc { <019>该技能尚未激发<299>};
+        };
+
+        #line sub var #line oneshot #sub {^#{$lvl}/{$point}$} {#<129>$lvl<299>/<029>$point<299>$delta $desc};
+
+        event.Emit {char/skbrief};
+    };
+
+    #line oneshot #alias {char.Skbrief.parse} {
         #line oneshot #action {^系统回馈:R:skbrief-begin-%%1$} {
             #class char-skbrief-parse-output-%%1 open;
             #line oneshot #action {^#%d/%d{|ID=char/skbrief-%%1}$} {
-                #local skill {};
-                #foreach {*char[Skills][]} {skill} {
-                    #if { "$char[Skills][$skill][code]" == "%%2" } {
-                        #var char[Skills][$skill][lvl]      {%%%%1};
-                        #var char[Skills][$skill][point]    {%%%%2};
-                        #local level {};
-                        #math level {
-                                $char[Skills][$skill][lvl]*1.00
-                            +   $char[Skills][$skill][point]*1.00 / ($char[Skills][$skill][lvl] ** 2)};
-                        #var char[Skills][$skill][level]    {$level};
-                        event.Emit {char/skbrief};
-                        #break;
-                    };
-                };
+                #local lvl      {%%%%1};
+                #local point    {%%%%2};
+
+                char.Skbrief.update {$lvl} {$point};
+
                 #local gag {@default{%2;gag}};
                 #if { "$gag" == "gag" } {
                     #line gag;
@@ -289,12 +336,21 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
     };
 
     #local uuid {@uuid{}};
-    char.Skbrief.parse $uuid $id;
-    xtt.Send {#response R:skbrief-begin-$uuid;skbrief $id;response R:skbrief-end-$uuid#};
+    char.Skbrief.parse $uuid $skillID;
+    xtt.Send {#response R:skbrief-begin-$uuid;skbrief $skillID;response R:skbrief-end-$uuid#};
+};
+
+///=== {
+// #@ char.SkillID <技能中文名称>
+//    返回技能的 ID。支持且仅支持所有玩家 skills 命令中可以看到的技能。
+// };
+#func {char.SkillID} {
+    #local skill {%1};
+    #return {$char[Skills][$skill][code]};
 };
 
 ///=== {
-// #@ char.SkillLimit [<技能名称>]
+// #@ char.SkillLimit [<技能中文名称>]
 //    如果指定了技能名称,则返回该技能所能达到的技能上限。
 //    如果没有指定或者技能不存在,则返回本人的武功技能上限。
 //    如果技能上限获取失败,默认值为基本内功的上限,如果基本内功上限也没有获取到,则默认为 1。
@@ -307,7 +363,7 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
 };
 
 ///=== {
-// #@ char.SkillType <技能名称>
+// #@ char.SkillType <技能中文名称>
 //    返回该技能的技能类别。
 //    常见的技能类别有:
 //        - 基本功夫
@@ -394,7 +450,7 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
         #return {$char[Skills][$arg][jifa-to]};
     };
     #else {
-        #return;
+        #return {};
     };
 };
 
@@ -421,6 +477,92 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
     #return {@defaultNum{$char[Skills][$skill][jifa-level];$level}};
 };
 
+///=== {
+// #@ char.CanLianxi [<基本武功>]
+//    如果某项武功可以练习,则返回真,否则返回假。
+//    如果省略参数,则默认为基本招架。
+//    注意: 这里不考虑游戏特定的限制,只做简单的等级比较。
+// };
+#func {char.CanLianxi} {
+    #local skill {@default{%1;基本招架}};
+
+    #if { "$skill" != "基本%*" || "$skill" == "基本内功" } {
+        #return 0;
+    };
+
+    #local baseLvl  {@math.Floor{@char.SkillLevel{$skill}}};
+    #local skill    {@char.GetJifaSkill{$skill}};
+    #local level    {@math.Floor{@char.SkillLevel{$skill}}};
+
+    #if { "$skill" == "" || $baseLvl == 0 || $level == 0 } {
+        #return 0;
+    };
+
+    #if { $baseLvl <= $level } {
+        #return 0;
+    };
+
+    #return 0;
+};
+
+///=== {
+// #@ char.CanLingwu [<基本武功>]
+//    如果某项武功可以领悟,则返回真,否则返回假。
+//    如果省略参数,则默认为基本内功。
+// };
+#func {char.CanLingwu} {
+    #local skill {@default{%1;基本内功}};
+
+    #if { "$skill" != "基本%*" } {
+        #return 0;
+    };
+
+    #local baseLvl  {@math.Floor{@char.SkillLevel{$skill}}};
+    #local skill    {@char.GetJifaSkill{$skill}};
+    #local level    {@math.Floor{@char.SkillLevel{$skill}}};
+    #local limit    {@math.Floor{@char.SkillLimit{$skill}}};
+
+    #if { "$skill" == "" || $baseLvl == 0 || $level == 0 } {
+        #return 0;
+    };
+
+    #if { $baseLvl > $level || $baseLvl >= $limit } {
+        #return 0;
+    };
+
+    #return 1;
+};
+
+///=== {
+// #@ char.CanXiulian [<内功中文名称>]
+//    如果角色拥有足够的潜能(暂定为 10w),并且内功等级尚未达到上限,则返回真,否则返回假。
+//    可选的内功名称参数用来指定具体的内功,如果省略则取当前激发的内功。
+// };
+#func {char.CanXiulian} {
+    #local force    {%1};
+
+    #if { "$force" == "" } {
+        #local force {@char.GetJifaSkill{基本内功}};
+    };
+
+    #if { "$force" == "" } {
+        #return 0;
+    };
+
+    #local level    {@math.Floor{@char.SkillLevel{$force}}};
+    #local limit    {@math.Floor{@char.SkillLimit{$force}}};
+
+    #if { @defaultNum{$char[HP][潜能];0} < 100000 } {
+        #return 0
+    };
+
+    #if { $level < 100 || $level >= $limit } {
+        #return 0;
+    };
+
+    #return 1;
+};
+
 #action {^你的「%*」进步了!$E} {
     #local name {%1};
 
@@ -428,8 +570,10 @@ event.Define {char/skbrief} {无参}  {$MODULE} {已经获取到 skbrief 命令
         #return;
     };
 
-    #local level {@math.Int{@math.Floor{ @char.SkillLevel{$name} } + 1}};
+    #local level {@math.Floor{@char.SkillLevel{$name}}};
+    #local level {@math.Eval{$level + 1}};
     #local limit {@char.SkillLimit{$name}};
+
     fullskLog @buffer.RawLine{} 当前进度: $level / $limit;
 
     #line sub var #delay char.skills.update.level {skbrief $char[Skills][$name][code]} 0.1;

+ 4 - 3
mud/pkuxkx/plugins/basic/char/special.tin

@@ -48,11 +48,12 @@ VAR {角色特技列表,表格} {char.Special} {};
         #local id       {%%4};
         #local level    {@str.Len{%%6}};
 
-        #if { "$enable" == "*" } {
-            #local enable {true};
+        #nop 这里不要和星号比,容易被转义,直接和空字符串比就可以了。;
+        #if { "$enable" == "" } {
+            #local enable {false};
         };
         #else {
-            #local enable {false};
+            #local enable {true};
         };
 
         #var char.Special[$name] {

+ 3 - 3
mud/pkuxkx/plugins/basic/map/area.tin

@@ -214,9 +214,9 @@ event.HandleOnce {map/init} {map/area} {map} {pkuxkx.map.area.init};
 };
 
 ///=== {
-/// #@ map.AreaColor <区域名称>
-//     获取该区域的颜色。
-//     区域颜色是一个区域的固有属性,可以通过该区域的平民 title 的颜色来确认。
+// #@ map.AreaColor <区域名称>
+//    获取该区域的颜色。
+//    区域颜色是一个区域的固有属性,可以通过该区域的平民 title 的颜色来确认。
 // };
 #func {map.AreaColor} {
     #local area {%1};

+ 19 - 3
mud/pkuxkx/plugins/basic/map/room.extra.tin

@@ -14,11 +14,12 @@ event.HandleOnce {map/init} {pkuxkx/map/room} {map} {pkuxkx.map.Room.init};
     set area_detail;
 };
 
-VAR {可能的房间小地图} miniMapBuffer {};
+VAR {当前所在的房间}    gMapHere        {};
+VAR {可能的房间小地图}  miniMapBuffer   {};
 
 #alias {map.Room.Watch} {
     #local dungeon  {?:\[(\S{3,10})副本\]|};
-    #local weather  {?:[ ]+\[((?:春|夏|秋|冬)(?:风|雨)|暖阳|酷晴|晴朗|雪晴|冬雾)\]|};
+    #local weather  {?:[ ]+\[((?:春|夏|秋|冬)(?:风|雨)|暖阳|酷晴|晴朗|雪晴|冬雾|飞雪|异象)\]|};
     #local nation   {?:[ ]?\[(大宋|大元|大理|大夏)国\]|};
     #local pkzone   {?:[ ]+(杀戮场)|};
     #local terrain  {?:[ ]+\[(都城|城市|城内|村镇|野外|门派|帮派|阴间|(\S+)势力范围)\]|};
@@ -94,6 +95,7 @@ VAR {可能的房间小地图} miniMapBuffer {};
         #local here {0};
         #if { "$room[direction]" == "here" } {
             #local here {@map.Here{}};
+            #var gMapHere {$gMapRoom};
         };
 
         #local header {%%99};
@@ -509,7 +511,7 @@ VAR {可能的房间小地图} miniMapBuffer {};
         #unvar gMapRoom[drawMap];
         #var gMapRoom[allDone] {true};
 
-        #if { @slist.Contains{{$gMapRoom[lookable]};{<ferry>}} } {
+        #if { @slist.Contains{{$gMapRoom[lookable]};{〈ferry〉}} } {
             #var gMapRoom[dock] {Ride};
         };
 
@@ -651,3 +653,17 @@ VAR {可能的房间小地图} miniMapBuffer {};
     #local exits {$gMapRoom[gmcp][出口信息][%*]};
     #return {@slist.Sort{$exits}};
 };
+
+///=== {
+// #@ map.Room.IsAt <房间名>
+//    判断当前房间名称是不是指定的名称。如果是则返回真,否则返回假。
+// };
+#func {map.Room.IsAt} {
+    #local roomName {%1};
+
+    #if { "$gMapHere[name]" == "$roomName" } {
+        #return 1;
+    };
+
+    #return 0;
+};

+ 2 - 2
mud/pkuxkx/plugins/bot/pp-server.tin

@@ -23,7 +23,7 @@ VAR {黑名单}        pp-black-list       {};
 load-lib storage;
 
 #func {bot_pp-server.Init} {
-    storage.Load {pp-server} {pp-vip-list;pp-svip-list;pp-black-list};
+    storage.LoadUser {pp-server} {pp-vip-list;pp-svip-list;pp-black-list};
 
     #return {true};
 };
@@ -458,5 +458,5 @@ VAR {无效目标 ID,列表表格,屏蔽这些 ID 一段时间防止重复
 };
 
 #alias {pp.store-vip} {
-    storage.Save {pp-server} {pp-vip-list;pp-svip-list;pp-black-list};
+    storage.SaveUser {pp-server} {pp-vip-list;pp-svip-list;pp-black-list};
 };

+ 22 - 2
mud/pkuxkx/plugins/gmcp.tin

@@ -18,6 +18,7 @@ event.Define {GMCP.Status}      {无参} {$MODULE} {接收到 GMCP 角色状态
 event.Define {GMCP.Combat}      {无参} {$MODULE} {接收到 GMCP 战斗信息,已更新 gGMCP[Combat]};
 event.Define {GMCP.Buff}        {无参} {$MODULE} {接收到 GMCP BUFF状态,已更新 gGMCP[Buff]};
 event.Define {GMCP.Message}     {无参} {$MODULE} {接收到 GMCP 聊天信息,已更新 gGMCP[Message]};
+event.Define {GMCP.System}      {无参} {$MODULE} {接收到 GMCP 副本跃迁,已更新 gGMCP[System]};
 
 #var {gmcp.key-e2c} {
     {Move} {
@@ -99,11 +100,15 @@ event.Define {GMCP.Message}     {无参} {$MODULE} {接收到 GMCP 聊天信息
         {name}                  {姓名}
         {url}                   {下载地址}
     }
+
+    {System} {
+        {site}                  {副本}
+    }
 };
 
 #alias {gmcp.pkuxkx.Enable} {
     #event {IAC SB GMCP} {
-        #if { "%%0" == "GMCP.{Status|Move|Combat|Buff|Message}" } {
+        #if { "%%0" == "GMCP.{Status|Move|Combat|Buff|Message|System}" } {
             #return;
         };
         gmcp.pkuxkx.OnGMCP {%%0} {%%1};
@@ -114,6 +119,7 @@ event.Define {GMCP.Message}     {无参} {$MODULE} {接收到 GMCP 聊天信息
     #event {IAC SB GMCP GMCP.Combat IAC SE}         {gmcp.pkuxkx.OnCombat {%%1} {%%2}};
     #event {IAC SB GMCP GMCP.Buff IAC SE}           {gmcp.pkuxkx.OnBuff {%%1} {%%2}};
     #event {IAC SB GMCP GMCP.Message IAC SE}        {gmcp.pkuxkx.OnMessage {%%1} {%%2}};
+    #event {IAC SB GMCP GMCP.System IAC SE}         {gmcp.pkuxkx.OnSystem {%%1} {%%2}};
 
     tune gmcp status on;
     tune gmcp move on;
@@ -205,10 +211,24 @@ event.Define {GMCP.Message}     {无参} {$MODULE} {接收到 GMCP 聊天信息
     event.Emit GMCP.Message {};
 };
 
+#alias {gmcp.pkuxkx.OnSystem} {
+    mudLog GMCP.System: {%2};
+
+    #line sub escapes #var gGMCP[System] {%1};
+    #var gGMCP[System] {$gGMCP[System][1]};
+    gmcp.translate-key System;
+
+    #if { @option.IsEnable{GMCPDebug} } {
+        #var gGMCP[System];
+    };
+
+    event.Emit GMCP.System {};
+};
+
 #alias {gmcp.translate-key} {
     #local class {%1};
 
-    #if { "$class" != "{Move|Status|Combat|Buff|Message}" } {
+    #if { "$class" != "{Move|Status|Combat|Buff|Message|System}" } {
         #return;
     };
 

+ 2 - 2
mud/pkuxkx/plugins/lib/ui/chat.tin

@@ -35,7 +35,7 @@ load-lib storage;
 load-lib alert;
 
 #func {lib_ui_chat.Init} {
-    #var chat-wujue-title {@storage.GetGlobal{五绝名号}};
+    #var chat-wujue-title {@storage.Get{五绝名号}};
     load-file {etc/ui-chat.tin};
     #return true;
 };
@@ -71,7 +71,7 @@ load-lib alert;
                 #var chat-wujue-title {@sset.Add{{$chat-wujue-title};$title}};
             };
         };
-        storage.SetGlobal 五绝名号 {$chat-wujue-title};
+        storage.Set 五绝名号 {$chat-wujue-title};
     };
 
     #if { "$type" != "{求助|北侠QQ群|闲聊|副本|谣言|门派|帮派|队伍|江湖|任务|交易|本地|区域|亡灵|表决|醒目|答问如流|备选答案|私聊}" } {

+ 4 - 4
plugins/lib/alert.tin

@@ -24,7 +24,7 @@ VAR {目前正在进行的警报,列表表格}  alert-items     {};
 load-lib storage;
 
 #func {lib_alert.Init} {
-    storage.Load {alert} {alert-items};
+    storage.LoadUser {alert} {alert-items};
     #return {true};
 };
 
@@ -59,7 +59,7 @@ load-lib storage;
         {interval}  {$interval}
     }};
 
-    storage.Save {alert} {alert-items};
+    storage.SaveUser {alert} {alert-items};
 
     alert.perform;
 };
@@ -81,7 +81,7 @@ load-lib storage;
 
     #if { $idx > 0 } {
         #list alert-items delete {$idx};
-        storage.Save {alert} {alert-items};
+        storage.SaveUser {alert} {alert-items};
         alert.perform;
     };
 };
@@ -107,7 +107,7 @@ load-lib storage;
     };
 
     #if { @isTrue{$changed} } {
-        storage.Save {alert} {alert-items};
+        storage.SaveUser {alert} {alert-items};
         alert.perform;
     };
 

+ 12 - 6
plugins/lib/event.tin

@@ -91,8 +91,8 @@ VAR {已定义的 PaoTin++ 事件列表}  gValidEvent    {};
 
     #echo {%h} { 已经定义的事件列表 };
 
-    #echo {%-30s %-5s %-40s %s} {事件/已注册的钩子} {类型} {模块} {说明/代码};
-    #echo {%-30s %-5s %-40s %s} {@str.Repeat{30;-}} {----} {@str.Repeat{40;-}} {------------};
+    #echo {%-40s %-5s %-40s %s} {事件/已注册的钩子} {类型} {模块} {说明/代码};
+    #echo {%-40s %-5s %-40s %s} {@str.Repeat{40;-}} {----} {@str.Repeat{40;-}} {------------};
 
     #local event {};
     #foreach {*gValidEvent[]} {event} {
@@ -105,7 +105,7 @@ VAR {已定义的 PaoTin++ 事件列表}  gValidEvent    {};
         #local desc {$gValidEvent[$event][desc]};
 
         #local event-line {};
-        #format {event-line} {%-30s %-5s %-40s %s} {$event} {$type}
+        #format {event-line} {%-40s %-5s %-40s %s} {$event} {$type}
             {@genModuleLink{$module;MOD}} {$desc};
 
         #local eventShown {0};
@@ -145,10 +145,12 @@ VAR {已定义的 PaoTin++ 事件列表}  gValidEvent    {};
                     #local lead {╰};
                 };
 
+                #local hookStr {@str.FormatIfElse{"$class" != "ANY_CLASS";$hook(%s);$hook;$class}};
+
                 #local len {1};
-                #format len {%L} {$hook};
-                #math len {26 - $len};
-                #echo { $lead@str.Repeat{$len;─} %s        %-40s %s}{$hook}
+                #format len {%L} {$hookStr};
+                #math len {36 - $len};
+                #echo { $lead@str.Repeat{$len;─} %s        %-40s %s}{$hookStr}
                     {@genModuleLink{$module;MOD}}
                     {$gEventHandlers[$event][$class][$hook][code]};
             };
@@ -295,6 +297,7 @@ VAR {已定义的 PaoTin++ 事件列表}  gValidEvent    {};
     #local class {$info[SESSION][CLASS]};
     #unvar info[SESSION];
     event.handle {%1} {%2} {%3} {%24} {event.ClassHandle} {{class}{$class}};
+    #class $class open;
 };
 
 ///=== {
@@ -306,6 +309,7 @@ VAR {已定义的 PaoTin++ 事件列表}  gValidEvent    {};
     #local class {$info[SESSION][CLASS]};
     #unvar info[SESSION];
     event.handle {%1} {%2} {%3} {%24} {event.ClassHandleOnce} {{justOnce}{true}{class}{$class}};
+    #class $class open;
 };
 
 ///=== {
@@ -449,6 +453,7 @@ VAR {当前正在处理的 TinTin++ 事件 %0}   gTTEventArgZero     {};
     #local class {$info[SESSION][CLASS]};
     #unvar info[SESSION];
     ttevent.handle {%1} {%2} {%3} {%24} {ttevent.ClassHandle} {{class}{$class}};
+    #class $class open;
 };
 
 ///=== {
@@ -460,6 +465,7 @@ VAR {当前正在处理的 TinTin++ 事件 %0}   gTTEventArgZero     {};
     #local class {$info[SESSION][CLASS]};
     #unvar info[SESSION];
     ttevent.handle {%1} {%2} {%3} {%24} {ttevent.ClassHandleOnce} {{justOnce}{true}{class}{$class}};
+    #class $class open;
 };
 
 ///=== {

+ 148 - 10
plugins/lib/option.tin

@@ -19,7 +19,7 @@ load-lib storage;
 event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只会唤醒和选项名称相对应的钩子};
 
 #func {lib_option.Init} {
-    #var gOptions {@storage.GetGlobal{global-options}};
+    #var gOptions {@storage.Get{global-options}};
     #return {true};
 };
 
@@ -29,22 +29,36 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
 ///// 全局选项本质上就是一些变量,这个模块主要是为了提供一些公共 API,和一个统
 ///// 一的管理方式,能够把散落在各处的选项集中管理。
 /////
-// ## option.Define <名称> <数据类型> <中文含义> [<默认值>]
+///// 选项具有作用域:
+/////   - share: 跨角色共享,修改后所有本机角色都会受到影响。
+/////   - user:  按角色隔离,每个角色有自己的值,修改本角色不会影响其他角色。
+/////
+///// 作用域在定义时指定,缺省为 user。可以在运行时通过 option.SetScope 调整。
+///// 具体细节请参考 HELP option.SetScope
+/////
+// ## option.Define <名称> <数据类型> <中文含义> [<默认值>] [<作用域>]
 //    定义一个全局选项。选项被定义之后有一些公共 API 可供使用。
 //    选项必须经过定义之后才能使用。选项的名称按照大骆驼风格命名。
-//    目前仅支持 Bool 型选项。
+//    作用域可选 share 或 user,缺省为 user。
+//    目前支持 Bool, Number, String, Slist, Time 等类型的选项。
 // };
 #alias {option.Define} {
     #local option   {%1};
     #local type     {%2};
     #local meaning  {%3};
     #local default  {%4};
+    #local scope    {@default{%5;user}};
 
     #if { "$option" == "" || "$type" == "" || "$meaning" == "" } {
         xtt.Usage option.Define;
         #return;
     };
 
+    #if { "$scope" != "{share|user}" } {
+        xtt.Usage option.Define 作用域只能是 share 或 user。;
+        #return;
+    };
+
     #if { "$gOptions[$option]" != "" } {
         #if {  "$gOptions[$option][name]" != "$option"
             || "$gOptions[$option][type]" != "$type"
@@ -52,6 +66,14 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         } {
             errLog 选项「$option」已存在,请检查代码。;
         };
+
+        #if { "$gOptions[$option][scope]" == "" } {
+            #nop 兼容旧数据,补齐缺省作用域。;
+            #var gOptions[$option][scope] {$scope};
+        };
+        #elseif { "$gOptions[$option][scope]" != "$scope" } {
+            errLog 选项「$option」的作用域与已有定义不一致,请检查代码。;
+        };
         #return;
     };
 
@@ -64,9 +86,10 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         {type}      {$type}
         {meaning}   {$meaning}
         {value}     {$default}
+        {scope}     {$scope}
     };
 
-    storage.SetGlobal {global-options} {$gOptions};
+    option.persist;
 };
 
 ///=== {
@@ -74,9 +97,11 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
 //    列出所有的选项。
 // };
 #alias {option.List} {
+    option.ensure;
+
     #echo {};
-    #echo {   <128>%-25s%-10s  %-30s %+22s} {选项名称} {选项类型} {选项含义} {选项当前值};
-    #draw Yellow scroll line 1 1 1 100;
+    #echo {   <128>%-25s %-10s %-8s %-30s %+22s} {选项名称} {选项类型} {作用域} {选项含义} {选项当前值};
+    #draw Yellow scroll line 1 1 1 110;
 
     #local option {};
     #foreach {*gOptions[]} {option} {
@@ -88,9 +113,10 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
             };
         };
 
-        #echo {   %-25s %-10s %-30s %+22s}
+        #echo {   %-25s %-10s %-8s %-30s %+22s}
             {$gOptions[$option][name]}
             {$gOptions[$option][type]}
+            {$gOptions[$option][scope]}
             {$gOptions[$option][meaning]}
             {{$value}};
     };
@@ -110,6 +136,8 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         #return {};
     };
 
+    option.ensure;
+
     #if { "$gOptions[$option]" == "" } {
         errLog 不存在选项「$option」,请先定义后再使用。;
         #return {};
@@ -131,6 +159,8 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         #return {};
     };
 
+    option.ensure;
+
     #local old {$gOptions[$option]};
     #if { "$old" == "" } {
         errLog 不存在选项「$option」,请先定义后再使用。;
@@ -143,7 +173,7 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
 
     #if { "$old[value]" !== "$value" } {
         #var {gOptions[$option][value]} {$value};
-        storage.SetGlobal {global-options} {$gOptions};
+        option.persist;
         event.Emit {option/changed} {opt/$option/%*};
     };
 
@@ -174,6 +204,8 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         #return {};
     };
 
+    option.ensure;
+
     #local old {$gOptions[$option]};
     #if { "$old" == "" } {
         errLog 不存在选项「$option」,请先定义后再使用。;
@@ -186,7 +218,7 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
 
     #var {gOptions[$option][value]} {$value};
     #list gOptions[$option][stack] add {$old[value]};
-    storage.SetGlobal {global-options} {$gOptions};
+    option.persist;
     event.Emit {option/changed} {opt/$option/%*};
 };
 
@@ -202,6 +234,8 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         #return {};
     };
 
+    option.ensure;
+
     #local old {$gOptions[$option]};
     #if { "$old" == "" } {
         errLog 不存在选项「$option」,请先定义后再使用。;
@@ -214,7 +248,7 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
 
     #var {gOptions[$option][value]} {$old[stack][-1]};
     #list gOptions[$option][stack] delete -1;
-    storage.SetGlobal {global-options} {$gOptions};
+    option.persist;
     event.Emit {option/changed} {opt/$option/%*};
 };
 
@@ -316,6 +350,60 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
     #local _ {@option.Toggle{$option}};
 };
 
+///=== {
+// #@ option.Scope <选项名称>
+//    获取选项的作用域,返回 share 或 user。
+//    参见 option.SetScope
+// };
+#func {option.Scope} {
+    #local option {%1};
+
+    #if { "$option" == "" } {
+        #return {};
+    };
+
+    #if { "$gOptions[$option]" == "" } {
+        #return {};
+    };
+
+    #return {$gOptions[$option][scope]};
+};
+
+///=== {
+// ## option.SetScope <选项名称> <share|user>
+//    修改选项的作用域,并将选项数据从原来的存储迁移到新的存储。
+//    NOTE: 本命令会移动选项的数据,操作前请确认该选项的当前值。
+// };
+#alias {option.SetScope} {
+    #local option {%1};
+    #local scope {%2};
+
+    #if { "$option" == "" || "$scope" == "" } {
+        xtt.Usage option.SetScope;
+        #return;
+    };
+
+    #if { "$gOptions[$option]" == "" } {
+        errLog 不存在选项「$option」,请先定义后再使用。;
+        #return;
+    };
+
+    #if { "$scope" != "{share|user}" } {
+        xtt.Usage option.SetScope 作用域只能是 share 或 user。;
+        #return;
+    };
+
+    #local oldScope {$gOptions[$option][scope]};
+    #if { "$oldScope" == "$scope" } {
+        #return;
+    };
+
+    #var gOptions[$option][scope] {$scope};
+    option.persist;
+
+    infoLog 选项「$gOptions[$option][meaning]($option)」的作用域已从 $oldScope 调整为 $scope。;
+};
+
 ///=== {
 // ## option.Enable <选项名称>
 //    启用该选项。
@@ -354,3 +442,53 @@ event.Define {option/changed} {无参} {$MODULE} {选项已更新。本事件只
         #return 1;
     };
 };
+
+VAR {角色私有选项已加载}  option-user-loaded  {false};
+
+#nop 内部命令,确保角色私有选项已经从磁盘加载。;
+#alias {option.ensure} {
+    #if { @isTrue{$option-user-loaded} || "$user[id]" == "" } {
+        #return;
+    };
+
+    #local userOpts {@storage.GetUser{options}};
+    #local opt {};
+    #foreach {*userOpts[]} {opt} {
+        #if { "$userOpts[$opt]" != "" } {
+            #if { "$userOpts[$opt][scope]" == "" } {
+                #nop 兼容旧数据,保留现有作用域。;
+                #var userOpts[$opt][scope] {$gOptions[$opt][scope]};
+            };
+            #var gOptions[$opt] {$userOpts[$opt]};
+        };
+    };
+
+    #var option-user-loaded {true};
+};
+
+#nop 内部命令,将选项字典持久化到磁盘。
+#nop global-options 键保存全部跨角色共享的选项;
+#nop options 键仅保存当前角色的私有选项,供不同角色隔离读取。;
+#alias {option.persist} {
+    option.ensure;
+
+    #if { "$user[id]" == "" } {
+        #return;
+    };
+
+    #local globalOpts {};
+    #local userOpts {};
+
+    #local opt {};
+    #foreach {*gOptions[]} {opt} {
+        #if { "$gOptions[$opt][scope]" == "share" } {
+            #var globalOpts[$opt] {$gOptions[$opt]};
+        };
+        #else {
+            #var userOpts[$opt] {$gOptions[$opt]};
+        };
+    };
+
+    storage.Set     {global-options}    {$globalOpts};
+    storage.SetUser {options}           {$userOpts};
+};

+ 240 - 38
plugins/lib/storage.tin

@@ -2,7 +2,7 @@
 
 /*
 本文件属于 PaoTin++ 的一部分。
-PaoTin++ © 2020~2023 的所有版权均由担子炮(dzp <danzipao@gmail.com>) 享有并保留一切法律权利
+PaoTin++ © 2020~2026 的所有版权均由担子炮(dzp <danzipao@gmail.com>) 享有并保留一切法律权利
 你可以在遵照 GPLv3 协议的基础之上使用、修改及重新分发本程序。
 */
 
@@ -16,12 +16,29 @@ PaoTin++ © 2020~2023 的所有版权均由担子炮(dzp <danzipao@gmail.com>) 
 /////   1. var/data/file1.tin (优先)
 /////   2. data/file1.tin     (其次)
 /////
-///// 各模块可以通过 API storage.Save 按需创建自己的存储文件,在其中存储一个或多个
-///// 变量,并在需要时通过 API storage.Load 提取这些变量。
+///// storage 支持的存储有两大维度:
+/////   - 存储形式
+/////        - 文件式(Save/Load): 按文件组织多个变量。
+/////        - 键值式(Set/Get): 把琐碎的数据集中存到同一个文件里,不必单独建文件。
+/////   - 作用域
+/////        - share: 跨角色共享。
+/////        - user: 按角色 ID 隔离,存 data/<角色ID>/ 下。
 /////
-///// 另外,storage 模块自行维护了一个全局存储文件。在简单使用场景下,其它模块
-///// 可以通过 API storage.SetGlobal 和 storage.GetGlobal 来读写这个全局存储文件。
-///// 这对读写少量的数据显得更加方便。
+///// 两大维度正交,组合成四组 API:
+/////   - 文件式 share: storage.Save     / storage.Load
+/////   - 文件式 user : storage.SaveUser / storage.LoadUser
+/////   - 键值式 share: storage.Set      / storage.Get
+/////   - 键值式 user : storage.SetUser  / storage.GetUser
+/////
+///// 各模块可以自行决定变量的存放位置,通常来说,对于较大的变量,建议通过文件式 API,
+///// 也就是 storage.Save/SaveUser 来创建自己模块专属的存储文件,并在需要时通过读取
+///// API storage.Load/LoadUser 提取这些变量。每个存储文件可以存储一个或多个变量。
+/////
+///// 而那些较为琐碎的存储项,通常是一些较小的变量,storage 模块自行维护了一组全局收
+///// 纳文件(data/storage.tin),各模块可通过键值式 API 来读写它们,更为方便。
+/////
+///// NOTE: 旧 API storage.SetGlobal/storage.GetGlobal 与 storage.Set/storage.Get 等
+///// 价,目前已废弃,但暂时保留以兼容既有代码,请各模块作者尽快更换。
 ///// };
 
 #var lib_storage[META] {
@@ -30,7 +47,9 @@ PaoTin++ © 2020~2023 的所有版权均由担子炮(dzp <danzipao@gmail.com>) 
     {AUTHOR}    {担子炮}
 };
 
-VAR {存储路径} storage-path {data};
+VAR {存储路径}              storage-path            {data};
+VAR {跨角色共享存储项}      storage-globals         {};
+VAR {角色私有存储项}        storage-user-globals    {};
 
 #func {lib_storage.Init} {
     #local _ {@mkdir{data}};
@@ -43,24 +62,40 @@ VAR {存储路径} storage-path {data};
 
     storage.Load {storage} {storage-globals};
 
-    dbgLog storage 全局存储项已加载。;
-
     #return {true};
 };
 
-///=== {
-// ## storage.Save <文件名> <变量名1> [...]
-//    将由变量名列表所指定的变量及其值存储到指定的存储文件中。
-// };
-#alias {storage.Save} {
-    #local file {%1};
-    #local vars {%2};
+#nop 内部函数,计算指定作用域的存储文件路径。user 作用域需要已登录的角色 ID。;
+#func {storage.path} {
+    #local scope    {%1};
+    #local file     {%2};
 
-    #if { "$file" == "" || "$vars" == "" } {
-        xtt.Usage storage.Save;
-        #return;
+    #replace file {.tin$} {};
+    #cat file {.tin};
+
+    #if { "$scope" != "{share|user}" } {
+        errLog storage.path 参数错误,scope 只能为 share 或 user。;
+        #return {};
+    };
+
+    #if { "$scope" == "share" } {
+        #return {$storage-path/$file};
     };
 
+    #if { "$user[id]" == "" } {
+        errLog 尚未登录角色,无法操作 user 作用域的存储文件。;
+        #return {};
+    };
+
+    #local _ {@mkdir{$storage-path/$user[id]}};
+    #return {$storage-path/$user[id]/$file};
+};
+
+#nop 内部命令,将指定变量列表存储到给定的存储文件中。;
+#alias {storage.dump} {
+    #local vars {%1};
+    #local path {%2};
+
     #class comm-store-tmp open;
     #local var {};
     #foreach {$vars} {var} {
@@ -82,26 +117,22 @@ VAR {存储路径} storage-path {data};
     };
     #class comm-store-tmp close;
 
-    #class comm-store-tmp write {$storage-path/${file}.tin};
+    #class comm-store-tmp write {$path};
 
     #class comm-store-tmp kill;
 };
 
-///=== {
-// ## storage.Load <文件名> <变量名1> [...]
-//    从指定的存储文件中加载变量。
-//    存储文件中实际存储的变量可能更多一些,但本函数可以只加载其中一部分变量。
-// };
-#alias {storage.Load} {
-    #local file {%1};
+#nop 内部命令,从给定的存储文件中加载变量到指定变量列表中。
+#nop 存储文件不存在时静默跳过,不会报错。;
+#alias {storage.load} {
+    #local path {%1};
     #local vars {%2};
 
-    #if { "$file" == "" || "$vars" == "" } {
-        xtt.Usage storage.Load;
+    #if { !@existsFile{$path} } {
         #return;
     };
 
-    #line quiet #class comm-store-tmp {assign} {load-file data/${file}.tin};
+    #line quiet #class comm-store-tmp {assign} {load-file $path};
     #local var {};
     #foreach {$vars} {var} {
         #local count {&{dump-${var}[]}};
@@ -124,16 +155,107 @@ VAR {存储路径} storage-path {data};
 };
 
 ///=== {
-// ## storage.SetGlobal <KEY> [<值>]
+// ## storage.Save <文件名> <变量名1> [...]
+//    将由变量名列表所指定的变量及其值存储到指定的存储文件中。
+//    存储文件的路径为 share 作用域,所有角色共享同一份数据。
+//    例子: storage.Save {foo} {my-var1;my-var2};
+//    注意文件名可以省略且建议省略 .tin 后缀。
+// };
+#alias {storage.Save} {
+    #local file {%1};
+    #local vars {%2};
+
+    #if { "$file" == "" || "$vars" == "" } {
+        xtt.Usage storage.Save;
+        #return;
+    };
+
+    storage.dump {$vars} {@storage.path{share;$file}};
+};
+
+///=== {
+// ## storage.Load <文件名> <变量名1> [...]
+//    从指定的存储文件中加载变量。
+//    存储文件的路径为 share 作用域,所有角色共享同一份数据。
+//    存储文件中实际存储的变量可能更多一些,但本函数可以只加载其中一部分变量。
+//    例子: storage.Load {foo} {my-var1;my-var2};
+//    注意文件名可以省略且建议省略 .tin 后缀。
+// };
+#alias {storage.Load} {
+    #local file {%1};
+    #local vars {%2};
+
+    #if { "$file" == "" || "$vars" == "" } {
+        xtt.Usage storage.Load;
+        #return;
+    };
+
+    storage.load {@storage.path{share;$file}} {$vars};
+};
+
+///=== {
+// ## storage.SaveUser <文件名> <变量名1> [...]
+//    将由变量名列表所指定的变量及其值存储到当前角色的私有存储文件中。
+//    存储文件的路径为 user 作用域,位于 data/<角色ID>/ 目录下,各角色相互隔离。
+//    本命令需要登录完成、获得角色 ID 之后才能使用,否则报错拒绝。
+//    例子: storage.SaveUser {foo} {my-var1;my-var2};
+//    注意文件名可以省略且建议省略 .tin 后缀。
+// };
+#alias {storage.SaveUser} {
+    #local file {%1};
+    #local vars {%2};
+
+    #if { "$file" == "" || "$vars" == "" } {
+        xtt.Usage storage.SaveUser;
+        #return;
+    };
+
+    #if { "$user[id]" == "" } {
+        errLog storage 尚未登录角色,无法使用 storage.SaveUser。;
+        #return;
+    };
+
+    storage.dump {$vars} {@storage.path{user;$file}};
+};
+
+///=== {
+// ## storage.LoadUser <文件名> <变量名1> [...]
+//    从当前角色的私有存储文件中加载变量。
+//    存储文件的路径为 user 作用域,位于 data/<角色ID>/ 目录下,各角色相互隔离。
+//    本命令需要登录完成、获得角色 ID 之后才能使用,否则报错拒绝。
+//    存储文件中实际存储的变量可能更多一些,但本函数可以只加载其中一部分变量。
+//    例子: storage.LoadUser {foo} {my-var1;my-var2};
+//    注意文件名可以省略且建议省略 .tin 后缀。
+// };
+#alias {storage.LoadUser} {
+    #local file {%1};
+    #local vars {%2};
+
+    #if { "$file" == "" || "$vars" == "" } {
+        xtt.Usage storage.LoadUser;
+        #return;
+    };
+
+    #if { "$user[id]" == "" } {
+        errLog storage 尚未登录角色,无法使用 storage.LoadUser。;
+        #return;
+    };
+
+    storage.load {@storage.path{user;$file}} {$vars};
+};
+
+///=== {
+// ## storage.Set <KEY> [<值>]
 //    将值关联到 KEY 上,并存储到全局存储文件中。
-//    参见 storage.GetGlobal
+//    本命令与 storage.SetUser 相对,读写的是 share 作用域的键值存储。
+//    参见 storage.Get
 // };
-#alias {storage.SetGlobal} {
+#alias {storage.Set} {
     #local key      {%1};
     #local value    {%2};
 
     #if { "$key" == "" } {
-        xtt.Usage storage.SetGlobal;
+        xtt.Usage storage.Set;
         #return;
     };
 
@@ -144,17 +266,97 @@ VAR {存储路径} storage-path {data};
 };
 
 ///=== {
-// #@ storage.GetGlobal <KEY>
+// #@ storage.Get <KEY>
 //    从全局存储文件中根据 KEY 提取值。
-//    参见 storage.SetGlobal
+//    本函数与 storage.GetUser 相对,读写的是 share 作用域的键值存储。
+//    参见 storage.Set
 // };
-#func {storage.GetGlobal} {
+#func {storage.Get} {
     #local key  {%1};
 
     #if { "$key" == "" } {
-        xtt.Usage storage.SetGlobal;
+        xtt.Usage storage.Get;
         #return {};
     };
 
     #return {$storage-globals[$key]};
 };
+
+///=== {
+// ## storage.SetGlobal <KEY> [<值>]
+//    旧名称,已废弃,等同于 storage.Set,保留以兼容既有代码,请尽快迁移。
+// };
+#alias {storage.SetGlobal} {
+    warnLog 本接口已废弃,请尽快改用 storage.Set;
+    storage.Set %0;
+};
+
+///=== {
+// #@ storage.GetGlobal <KEY>
+//    旧名称,已废弃,等同于 storage.Get,保留以兼容既有代码,请尽快迁移。
+// };
+#func {storage.GetGlobal} {
+    #local key {%1};
+    warnLog 本接口已废弃,请尽快改用 storage.Get;
+    #return @storage.Get{$key};
+};
+
+///=== {
+// ## storage.SetUser <KEY> [<值>]
+//    将值关联到 KEY 上,并存储到当前角色的私有存储文件中。
+//    需要登录完成、获得角色 ID 之后才能使用,否则报错拒绝。
+//    参见 storage.GetUser
+// };
+#alias {storage.SetUser} {
+    #local key      {%1};
+    #local value    {%2};
+
+    #if { "$key" == "" } {
+        xtt.Usage storage.SetUser;
+        #return;
+    };
+
+    #if { "$user[id]" == "" } {
+        errLog storage 尚未登录角色,无法使用 storage.SetUser。;
+        #return;
+    };
+
+    storage.ensure-user;
+    #var storage-user-globals[$key] {$value};
+    storage.SaveUser {storage-user} {storage-user-globals};
+};
+
+///=== {
+// #@ storage.GetUser <KEY>
+//    从当前角色的私有存储文件中根据 KEY 提取值。
+//    需要登录完成、获得角色 ID 之后才能使用,否则报错拒绝。
+//    参见 storage.SetUser
+// };
+#func {storage.GetUser} {
+    #local key  {%1};
+
+    #if { "$key" == "" } {
+        xtt.Usage storage.SetUser;
+        #return {};
+    };
+
+    #if { "$user[id]" == "" } {
+        errLog storage 尚未登录角色,无法使用 storage.GetUser。;
+        #return {};
+    };
+
+    storage.ensure-user;
+    #return {$storage-user-globals[$key]};
+};
+
+VAR {角色私有存储已加载}    storage-user-loaded     {false};
+
+#nop 内部命令,确保角色私有存储项已从磁盘加载。;
+#alias {storage.ensure-user} {
+    #if { @isTrue{$storage-user-loaded} } {
+        #return;
+    };
+
+    storage.LoadUser {storage-user} {storage-user-globals};
+    #var storage-user-loaded {true};
+};

+ 1 - 1
plugins/lib/ui/beautify.tin

@@ -34,7 +34,7 @@
 
     {▼|▲|△|▽|□|◇|◆|☆|★}                                {left-align}
     {•|◎|⊙|☉|○|●|Θ|⊕|Ο}                                {left-align}
-    {·|∶|∴|∷|˙|¨|ō|ó|ǒ|ò}                             {left-align}
+    {·|∶|∴|∷|˙|¨|ō|ó|ǒ|ò}                          {left-align}
     {≤|≧|≥|≮|≯|∨|∧|≌|╳|×}                             {left-align}
     {↙|↘|↗|↖|←|↑|→|↓}                                   {left-align}
     {※|Ψ|Ж|ξ|ф|∩|⊥|♀|∞|≈|√|⌒|Ω|¤||ψ|ζ|∽}     {left-align}

+ 5 - 3
plugins/lib/xtintin/regex.tin

@@ -114,8 +114,10 @@ VAR {服务器渲染的表格数据}  gServerTable   {};
             #local colorBody[$field] {$b};
         };
 
-        #local body[group] {$gServerTable.helper[group]};
-        #local colorBody[group] {$gServerTable.helper[colorGroup]};
+        #if { "$gServerTable.helper[group]" != "" } {
+            #local body[group] {$gServerTable.helper[group]};
+            #local colorBody[group] {$gServerTable.helper[colorGroup]};
+        };
 
         #list gServerTable[body] add {{$body}};
         #list gServerTable[colorBody] add {{$colorBody}};
@@ -130,7 +132,7 @@ VAR {服务器渲染的表格数据}  gServerTable   {};
         #var gServerTable[note] {};
     };
 
-    #action {{*UTF8}{?:^}╰─{?:(?:─|┴)+}{\p{Han}+}{?:(?:─|┴)+}─╯{|re/ParseServerTable/body}$} {
+    #action {{*UTF8}{?:^}╰─{?:(?:─|┴)+}{(|\p{So})\p{Han}+(|\p{So})}{?:(?:─|┴)+}─╯{|re/ParseServerTable/body}$} {
         #class re.ParseServerTable kill;
 
         #if { "$gServerTable.helper[gag]" == "gag" } {

+ 26 - 0
plugins/lib/xtintin/string.tin

@@ -285,6 +285,32 @@ PaoTin++ © 2020~2023 的所有版权均由担子炮(dzp <danzipao@gmail.com>) 
 #func {str.Format}  {#format result {%1} {%2}};
 #func {format}      {#return {@str.Format{%0}}};
 
+///=== {
+// #@ str.FormatIf <条件> <格式> <参数>
+//    类似于 str.Format,但只有条件满足时才执行。否则维持原值不变。
+// };
+#func {str.FormatIf} {
+    #if { %1 } {
+        #format result {%2} {%3};
+    };
+    #else {
+        #return {%3};
+    };
+};
+
+///=== {
+// #@ str.FormatIfElse <条件> <格式1> <格式2> <参数>
+//    类似于 str.FormatIf,但当条件不满足时,换成另一种格式。
+// };
+#func {str.FormatIfElse} {
+    #if { %1 } {
+        #format result {%2} {%4};
+    };
+    #else {
+        #format result {%3} {%4};
+    };
+};
+
 ///=== {
 // #@ str.Left <字符串> <截取宽度>
 //    按照屏幕宽度标准,截取字符串左边的若干字符。