一套面向 Bukkit 服务端的变量与积分管理框架。

起点

服务器里所有"数值"都需要被记下来。金币、体力、连击数、签到状态、VIP 等级、自定义计数器,本质都是 (玩家, 键, 值) 这一组关系。各种插件各自实现一套,又会出现重复的存储层、重复的过期逻辑、重复的 PAPI 接入。

AbolethPlus 把这件事抽出来做成一层基础设施。开发者只对接"变量"这一个概念,存储、缓存、过期、刷新、排行、PAPI、Kether、调度全部由它接管。

下文按 存储层 → 配置驱动 → 操作语法 → 调度器 → API 的顺序展开,最后再讲分析模块。

存储层

三段式存储:内存(临时变量) → Redis(缓存) → 数据库(MySQL / SQLite)

整体架构关系如下,三层各司其职:

drawing-1780474667398

数据库里只有一张表,(user, key, value, over_time) 四列。user 一律按 UUID 字符串存,服务器全局变量也走同样的列,UUID 固定为 2feeb78e-c527-3a42-92f5-01f201cd5eeb,对应玩家名 BukkitServerserver,这两个 ID 在启动时被加进 BanList,确保不会被真实玩家占用。

读路径优先级是:临时变量内存 → Redis → 数据库。Redis 命中就直接返回,过期时间 30 分钟。

写路径采用写穿透 (Write-Through):写库之后直接用最新值刷新缓存,不删 key。这一步的关键是避免"先删后读"的击穿窗口——传统的写后删缓存会让下一个读请求落到数据库上,写穿透把这一点抹掉了。

operator fun set(user: String, key: String, data: String) {
    // ...写库
    val freshMeta = AbolethMeta(user, key, data, -1)
    RedisCache.setCacheObject(cacheKey, freshMeta, 30, TimeUnit.MINUTES)
}

只有在删除场景才走 RedisCache.del,因为这时缓存里没有有意义的"新值"可以替换。

写穿透与传统的写后删的差别如下:

drawing-1780474686665

removeAll(user) 会按前缀清掉这个用户的所有缓存条目;removeAllKey(key) 没有反向索引,会走宽前缀清理 abolethplus__,这是一个已知的代价点,文档在源码里也标注了。

完整的读写路径分支:

drawing-1780474707620

配置驱动:basedata.yml

变量的行为不是写死在代码里的,而是声明在 base/ 目录下任意 yaml 中。每一个键都对应一份 AblethBaseData:默认值、最大最小值、是否周期刷新、刷新条件、刷新方式、阶段奖励等等。

最小可用的一份配置长这样:

体力恢复:
  key: "体力"
  enable: true
  default: "500"
  interval:
    min: "0"
    max: "1000"

key 是真正存到数据库的变量名,外层节点 体力恢复 只是一个分组标签,方便阅读。enable: false 表示完全跳过这条配置,连默认值都不写入。

默认值与边界

default 在玩家首次进入时写入。interval.min / max 在每一次写入路径上做截断,写 "none" 表示不限制。

边界检查的实现要点是不破坏字符串格式:写 "100" 经过截断仍然是 "100",不会被强制变成 "100.0"。这一点在排行榜按 DECIMAL(10,2) 排序时其实不影响,但 PAPI 拿出来直接显示时会有差别,所以代码里专门加了整数表示保留逻辑。

Shadow 映射

玩家名:
  key: "玩家名"
  shadow: "%player_name%"

shadow 把变量绑到一个 PAPI 表达式上,每次刷新周期都用 PAPI 解析后的值覆盖当前值。适合那些"本质不归我管,但我要用"的字段,例如玩家名、等级、所在世界。

周期刷新

体力自动恢复:
  key: "体力"
  update:
    enable: true
    type: "value"
    action: "ADD"
    value: "10"
    condition: "{value} < 1000"
    period: "0 0 * * * *"
  default: "100"
  interval:
    min: "0"
    max: "1000"

period 是标准 6 段 Cron。condition 是一段简化表达式,支持 () && || > < >= <= == !=,里面用 {value} 代表当前值,PAPI 会先解析。字符串比较要打单引号,数字直接写。

condition 的求值是单独写的解析器,没有走 JEXL 之类的库,原因是表达式形态固定、可控,且不希望把任意脚本能力放在配置里。

三种更新模式

update.type 决定刷新到来时怎么处理变量:

  • value —— 按 actionvalue 做一次 SET / ADD / TAKE / MULTIPLY / DIVIDE。最常见。

  • kether —— 跑一段 Kether 脚本。脚本里可以读 {{ &value }}{{ &previous_value }}{{ &current_value }}{{ &key }}{{ &uuid }}{{ &player }}

  • mixed —— 先做一次 value 操作,再跑 Kether。previous_value 是变更前的值,current_value 是变更后的值。这是为"算完账之后再决定要不要播音效"这类场景准备的。

等级提升通知:
  key: "玩家等级"
  update:
    enable: true
    type: "mixed"
    action: "ADD"
    value: "1"
    kether:
      - "if check {{ &current_value }} > {{ &previous_value }} then tell inline '&6升级!&e{{ &previous_value }} → {{ &current_value }}'"
    period: "0 */10 * * * *"
  default: "1"

注意,对于字符类型的条件,需要用单引号进行标记,例如 "'{value}'=='test'"

阶段式奖励 (milestones)

阶段奖励是一份子配置,跟变量值绑在一起:玩家的变量第一次跨过 value 就触发一次 actions,再触发不会重复发。

等级系统:
  key: "等级"
  default: "0"
  milestones:
    level_10:
      value: 10
      type: "kether"
      actions:
        - "abp 金币 edit + to 1000"
      message: "&6达到 10 级!"

领取标记单独存一条变量:milestone_<key>_<阶段ID>。要让玩家重新领,删这条变量即可。

阶段检查走两条路径:写入路径 (AbolethPlusAPI.editValuePlayerDatabase.checkMilestoneIfNeeded) 是事件驱动主路径;周期任务里的 checkMilestones 是兜底。这样既能在数值刚跨过门槛时立刻发奖,也不会因为某次写入跳过事件分发而漏发。

临时变量

不是所有变量都值得写库。战斗连击数、副本临时积分、PVP 连杀,这些数据高频读写、生命周期短、玩家下线就该清掉。每次都打数据库浪费 IO,缓存层也撑不住。

临时变量在 basedata.yml 里用 type: temporary 标记:

战斗连击数:
  key: "combo"
  type: temporary
  persist: false
  default: "0"
  interval:
    min: "0"
    max: "999"

persist: true 时,玩家正常退出会异步写一次库;persist: false 时直接丢弃。下次登录不会读库恢复,因为这种变量本来就是局部上下文。

实现层面有几个细节:

  • 内存使用 ConcurrentHashMap<String, ConcurrentHashMap<String, TempVarData>>,外层是 UUID,内层是变量名。

  • 过期清理改成了 DelayQueue 主动出队,时间复杂度从 O(玩家数 × 变量数) 降到 O(过期数)

  • 出队时做二次校验:data.expireTime == task.expireAt,避免 set 之后过期时间被覆盖却仍按旧任务删除。

  • 玩家下线时先从主存储原子摘除快照,再异步落库,防止持久化期间该玩家重新登录被脏读。

整型字符串场景做了特别处理。"100""1" 仍然是 "101",不会变成 "101.0"。判定逻辑是:当所有源字符串都能 toLong() 解析、且结果整数与浮点相等,按整数格式输出。

非数值字符串遇到 ADD / TAKE / MULTIPLY / DIVIDE 直接返回当前值不修改,SET / REMOVE 不受影响。

临时变量不进入排行榜,因为排行榜走数据库 SQL 排序,临时变量根本不在那里。

操作语法

变量层面对外暴露三套接口:指令、ED 语法、Kether、PAPI。它们最终都落到 AbolethPlusAPI.editValue / getMeta,区别只是入口形态。

指令

插件一共注册三个命令头,每个都有简写别名:

全名

别名

权限

用途

/abolethplus

/abp

abolethplus.command.main

主入口,调试 / 重载

/abolethplusedit

/abpe

abolethplus.command.edit

修改与查询变量

/abolethcommandselect

/abps

abolethplus.command.select

排行 / 分组(4.x 起子命令暂注释,仅保留命令头)

/analytics

/ana

abolethplus.command.analytics

分析模块

/abp 主指令

子命令

行为

/abp

显示帮助

/abp debug

切换 Debug 日志开关,看缓存命中、写穿透是否生效时用

/abp reload

重载 basedata.yml / task/ / Quartz 配置,等价于不重启服务器把配置重新读一遍

/abpe 修改与查询

/abpe 共有 9 个子指令。修改玩家数据时优先用 /abpe ed——参数命名清晰、可写过期时间和备注,扫一眼就知道这条命令在做什么。其它子命令保留是为了脚本里写起来更直观。

子命令

形态

行为

set

/abpe set <key> <value> <target>

直接覆盖目标变量值。等价于 editValue(target, key, value, SET)

get

/abpe get <key> <target>

查询并打印目标变量当前值、默认值、过期时间。

getAll

/abpe getAll <target>

列出目标的所有变量及对应默认值。

edit

/abpe edit <target> <key> <action> <value> [overTime]

复合编辑,支持过期时间,是脚本主用。

editMo

/abpe editMo <target> <key> <action> <value> [overTime]

edit,但不回显消息。批量调用时用,避免刷屏。

ed

/abpe ed <ED 语法>

见下文 ED 语法 一节。

overTime

/abpe overTime <target> <key> <time>

只改过期时间不动值。

removeAll

/abpe removeAll <target>

删除该目标的所有变量。不可恢复

removeKey

/abpe removeKey <key>

全服删除某个变量。不可恢复,慎用,会触发宽前缀缓存清理。

<action> 接受多种写法,中英符号通用:

EditAction

别名

SET

set / = / == / 设

ADD

plus / add / + / += / 加

TAKE

subtract / sub / - / -= / 减

MULTIPLY

multiply / mul / * / x / X / 乘

DIVIDE

divide / div / / / 除

REMOVE

remove / <- / != / 删

NONE

none / 否

<target> 可以是玩家名、UUID 字符串,或特殊值 server / BukkitServer(即服务器全局变量)。<overTime> 写法 1d2h3m4s30s5d5天 都行。

实战例子:

# 给在线玩家 Ray_Hughes 加 100 金币,1 天后过期
/abpe edit Ray_Hughes 金币 + 100 1d

# 把全局变量 today_event 设置为 1
/abpe set today_event 1 server

# 查看玩家 Ray_Hughes 的体力
/abpe get 体力 Ray_Hughes

# 删除玩家 Ray_Hughes 的所有变量
/abpe removeAll Ray_Hughes

/ana 分析模块

只有当 config.ymlanalytics.enable: true 时这组指令才有意义。

子命令

行为

/ana status

总开关状态、活跃数据系列、性能评分、玩家会话数

/ana performance

CPU、内存、TPS、缓存命中率、慢查询数

/ana health

系统健康总评 + 改进建议(前 5 条)

/ana player [玩家名]

玩家行为画像:活跃度、会话时长、热门变量、热门指令

/ana variables [N]

全服 TOP N 热门变量,默认 10

/ana trends [系列]

趋势分析:方向、置信度、波动性、未来 5 期预测

/ana export [json|csv]

数据导出(注:当前实现只是控制台预览,并未落盘)

/ana report

综合报告,把上面几项压缩成一份概览

ED 语法

ED 是一套参数化的微语言,把"对哪个目标、改哪个键、做什么操作、值是什么、过期多久、要不要解析 PAPI、要不要回显"压缩成一行字符串。它的设计目标是让运维写出来的指令具备自描述性——不需要回去查文档就能看懂这条命令在做什么。

底层依赖 TabooLib 的 Demand 解析器,所有参数都是 -key value 形式,先后顺序无所谓。

形式定义

abpe ed -k <KEY> [-v <VALUE>] [-a <ACTION>] [-id <TARGET>]
                 [-o <DURATION>] [-papi <BOOL>] [-m <BOOL>]

Demand 在解析前会做一步预处理:<r> 替换成空格。这是为某些不能直接写空格的容器准备的(例如某些 PAPI 嵌套场景)。

参数全表

每个参数都接受多个别名,首字母缩写最常用。

参数语义

主名

全部别名

默认值

说明

目标

-id

id / target / T / t

命令执行者

接受玩家名、UUID、serverBukkitServer

变量名

-key

key / k / Key / K

必填

缺失会回 no_key

变量值

-value

value / v / Value / V

留空

留空 = 删除变量(见下文分支)

操作

-action

action / a / Action / A

=(即 SET)

接受 EditAction 的所有别名

过期时间

-overTime

overTime / overtime / o / O

不过期

1d2h3m4s 风格

PAPI 解析

-papi

papi / P / Papi / p

false

true 时把 value 在目标玩家身上做一次 PAPI 替换

回显消息

-message

message / msg / Message / Msg / m / M

false

true 时给执行者反馈结果

解析后的四个分支

读源码 AbolethPlusAPI.evalString 可以看出,(value, overTime) 两个字段的组合决定走哪条分支:

value

overTime

行为

editValue(target, key, value, action) —— 普通编辑

editValue,再 setOverTime —— 编辑 + 设置过期

setOverTime —— 只刷新过期时间

deleteValue —— 删除变量

注意,只要 value 留空,就会触发删除。这是约定,不是 bug,写脚本时要特别小心别误删。

PAPI 解析时机

-papi true 的解析时机是在编辑前:取目标玩家的 Bukkit Player 对象,把 value 字段过一次 replacePlaceholder。所以 -id Ray_Hughes -k level -v %player_level% -papi true 会取 Ray_Hughes 的等级,而不是命令执行者的等级。

如果目标玩家不在线,Bukkit.getPlayer 返回 null,PAPI 跳过解析,原样写入 value。

实战写法

按场景归类:

# === 设置类 ===
# 直接覆盖
abpe ed -k 金币 -v 1000

# 给指定玩家覆盖,并提示
abpe ed -id Ray_Hughes -k 金币 -v 1000 -m true

# === 加减类 ===
abpe ed -k 金币 -v 100 -a +
abpe ed -k 金币 -v 50 -a -
abpe ed -k 金币 -v 1.1 -a *
abpe ed -k 金币 -v 2 -a /

# === 过期类 ===
# 给 BUFF 设置 1 天后过期
abpe ed -k vip_buff -v active -o 1d

# 只刷新过期时间,不动值
abpe ed -k vip_buff -o 7d

# === PAPI ===
# 把当前玩家等级写入变量
abpe ed -k snapshot_level -v %player_level% -papi true

# === 跨目标 ===
# 操作服务器全局变量
abpe ed -id server -k 全服公告序号 -v 1 -a +

# === 删除 ===
# value 留空 = 删除
abpe ed -k 金币
abpe ed -id Ray_Hughes -k 金币

易踩的坑

  1. value 留空触发删除——在脚本里组装命令时如果 value 来自外部变量为空,会变成误删。建议在拼装前先判空。

  2. PAPI 解析针对 target 而非执行者——这是设计如此,但脚本作者经常踩。

  3. -o 是相对时间不是时间戳——内部用 parseMillis()1d 解析成毫秒后再加 currentTimeMillis()

  4. 过期时间和值同时填——会先写值,再单独写过期时间。两个写都走数据库,没有合并优化。

  5. 空格字段——值里如果必须包含空格,用 <r> 占位(PAPI 入口也用同一个机制)。

  6. 回显是异步延迟 1 tick 的——确保 editValue 完成后再读取打印;如果你本地写完立即查,可能拿不到最新值,要走同样的 1 tick 延迟。

Kether

Kether 入口语句是 abp,单一动作覆盖读写两条路径:

abp <key> [(edit|ed) <action> to <value>] [(-t|target) <target>] [time <long>] [(def|default) <default>]
  • 没写 edit 子句 = 读路径:返回 getMeta(target, key).getValueData(),不存在时返回 def 指定的默认值。

  • 写了 edit 子句 = 写路径:调用 editValue(target, key, value, action)time 给的是毫秒时间戳(不是相对时间),不写则永久。

写法举例:

abp 金币 def "0"                                  # 取金币,没有就给默认 0
abp 金币 -t "Ray_Hughes" def "0"                  # 取指定玩家
abp 金币 edit "+" to 100                          # 当前调用者 +100
abp 金币 edit "-" to 1 target "Ray" time 1234567  # 指定目标 -1,过期时间戳

target 不填时优先取脚本 sender,sender 不是玩家时取 BukkitServer

旧版 abpe / abpg

为了兼容历史脚本,还保留两套 Kether parser:

abpe(写):

abpe 金币 = 1000                    # 设
abpe 金币 + 100 @ Ray_Hughes mark "首充"  # 加,带备注
abpe 金币 - 50                      # 减
abpe 金币 * 1.1                     # 乘
abpe 金币 / 2                       # 除
abpe 金币 ~ val                     # 删除变量

abpg(读):

abpg 金币                # 取值
abpg 金币 def "0"        # 取值带默认
abpg 金币 def            # 取该 key 的全局默认值
abpg 金币 @ Ray_Hughes   # 取指定玩家

新写法优先选 abp,统一入口好维护。

PAPI

PAPI 抛弃了"一变量一占位符"的旧设计,统一用 %abp_<动作> -<参数> <值>% 的形式。空格不能用的场景用 <r> 替代——这是 Demand 解析前的预处理替换,专门给那些不允许空格的容器(嵌套占位符、某些指令上下文)用。

5 个动作,各自的参数表:

abp_get —— 取变量值

参数

别名

说明

id

id / ID

目标,缺省为当前玩家或 BukkitServer

key

k / K / Key

变量名(必填)

default

d / D / def / Def

默认值,未找到返回

format

f / F / Format

格式化字符串

type

t / T / Type

value(默认)/ time / over

type 控制返回什么:

  • value —— 当前值。formatDecimalFormat 数字格式(如 #.00)。

  • time —— 把当前值当时间戳格式化。formatSimpleDateFormat(如 yyyy-MM-dd)。

  • over —— 变量过期时间。format=lite 时返回 1d2h3m 这种相对时间,否则按 SimpleDateFormat 格式化。

%abp_get -id Ray_Hughes -k 金币%
%abp_get -k 金币 -f #.00%
%abp_get -k last_login -t time -f yyyy-MM-dd_HH:mm:ss%
%abp_get -k vip_buff -t over -f lite%

abp_sort —— 排行榜

参数

别名

默认

说明

key

k / K

-

必填

limit

l / L

10

取第 N 名(不是前 N 条,注意)

desc

dc / Dc

false

true=降序

default

d / D / def

""

兜底

type

t / T

value

value 返回值,user 返回玩家名

源码里 sort(...).lastOrNull() 取的是结果列表的最后一条,结合 limit 含义就是"第 limit 名"。要拿前 10 名得自己循环:%abp_sort -k 金币 -l 1 -dc true -t user% 是第 1 名,-l 2 是第 2 名。

abp_sum —— 求和

%abp_sum -k 金币 -f #.00%

走 SQL SELECT value FROM table WHERE key=? AND <未过期>,把所有数值类型 value 求和。临时变量不在内。

abp_task —— 调度器时间

参数

默认

说明

id

-

任务 ID(必填)

type

next

next / last

format

不格式化

SimpleDateFormat

%abp_task -id TaskPlayer -t next -f yyyy-MM-dd_HH:mm:ss%
%abp_task -id TaskPlayer -t last%

abp_base —— 配置侧查询

basedata.yml 里的字段解析出来,PAPI 替换发生在目标玩家身上。

type 可选:updatecondition / updatetime / defaultvalue / minvalue / maxvalue / shadow

%abp_base -id Ray_Hughes -k 体力 -t defaultvalue%
%abp_base -k 玩家名 -t shadow%

aboe_* —— 简易 PAPI

aboe 是另一套精简语法,参数靠下划线分隔:%aboe_<key>_<default>_<target>_<format>%

%aboe_金币_0_me_#.00%             # 当前玩家的金币,默认 0,保留两位
%aboe_last_login_0_me_T:yyyy-MM-dd%   # T: 前缀表示按日期格式

targetme = 当前玩家。这套语法存在主要是为了和某些只支持简单占位符的容器兼容,新写法尽量用 abp_get

调度器(不推荐,优先用 basedata)

⚠️ 调度器是 1.x / 2.x 的老设计,仍然能用,但新写法不推荐

大部分定时任务的本质是"周期性地对某个变量做点什么"——这件事在新版里应该交给 basedata.ymlupdate 配置。update 已经原生支持 Cron、condition 表达式、value / kether / mixed 三种处理模式、interval 边界限制、shadow 实时映射,比 task/*.yml 更贴近变量自身的生命周期,也更容易和 milestones 阶段奖励、enable 总开关一起协作。

调度器章节保留下来主要是为了存量配置可以继续看明白。新项目尽量都写在 basedata 里。

调度器走 Quartz 2.3.2,运行时下载、不打包进 jar(@RuntimeDependencies)。配置文件在 task/ 下任意 yaml:

TaskPlayer:
  group: 'default'
  type: 'online'
  cron: "0/5 * * * * ?"
  action:
    - command "say Hello <target.name>"

type 决定怎么遍历目标、怎么解释 action:

type

触发对象

action 语法

online

全体在线玩家

Kether

online ed

全体在线玩家

ED

all

全体玩家(含离线)

ED

server

服务器全局

ED

command

控制台执行

原生指令

kether

在线玩家随机一个作为载体

Kether

player

玩家进入游戏后跟随,下线时取消

Kether

action 字符串里 <target.name><target.uuid> 在每次触发前替换成当前目标。替换发生在 getActionList(target) —— 同一份 yaml 配置,每个目标都有一份独立的 action 字符串。

all 模式的批处理AbolethPlusAPI.getUserUUIDList() 拿到全部目标后按 50 一批 chunked(50),每批 submit(async = true) 提交。一千个玩家分成 20 批走异步线程池,主线程不会被吃光。

player 模式比较特殊:cron 字段不是 cron 表达式,而是tick 数。玩家加入服务器时 loadPlayerTasks(uuid) 给每个 player 类型的任务挂一个 submit(delay=cron, period=cron),玩家退出时遍历 taskMap[uuid] 全部 cancel。这让 player 模式的语义贴近"跟随玩家"——玩家不在,调度也不存在。

每次执行后会写两个变量做记录:Task_<id> 是当前时间戳,Task_Next_<id> 是下次执行预估时间。

abp_task PAPI 可以拿到任意 task 的下次/上次触发时间,给前端展示倒计时用。

旧任务怎么迁到 basedata

举一个常见例子。旧的"每 5 秒给在线玩家发问候":

# task/old.yml (不推荐)
HelloTask:
  type: 'online'
  cron: "0/5 * * * * ?"
  action:
    - tell "&a你好 <target.name>"

迁到 basedata,绑定一个心跳变量:

# base/hello.yml (推荐)
心跳问候:
  key: "_hello_tick"
  enable: true
  update:
    enable: true
    type: "kether"
    kether:
      - "tell inline '&a你好 {{ &player }}'"
    condition: "true"
    period: "0/5 * * * * ?"
  default: "0"

差别在于:basedata 的 update 把"什么时候发"和"发什么"绑定到一个变量身上,玩家退出时自然停止;它会和 condition、interval、milestones 一起走同一套生命周期管理。task 是孤立的调度器,有自己的状态机,遇到玩家上下线、配置 reload 时容易和其他模块对不齐。

API

API 入口有两个:Kotlin 用 AbolethPlusAPI,Java 用 AbolethAPI(在 Java 端封装了一层静态方法以避免 INSTANCE 写法)。

import ray.mintcat.abolethplus.api.AbolethPlusAPI
import ray.mintcat.abolethplus.database.edit.EditAction

val uuid = player.uniqueId

// 读
val money = AbolethPlusAPI.getValueString(uuid, "money", "0")
val meta  = AbolethPlusAPI.getMeta(uuid, "money")            // 带过期时间
val keys  = AbolethPlusAPI.getKeys(uuid)
val all   = AbolethPlusAPI.getAllData(uuid)                  // 全量

// 写
AbolethPlusAPI.setValue(uuid, "money", "1000")
AbolethPlusAPI.editValue(uuid, "money", "500", EditAction.ADD)
AbolethPlusAPI.editValue(uuid, "buff", "1", EditAction.SET, System.currentTimeMillis() + 5000)

// 过期 / 删除
AbolethPlusAPI.setExpire(uuid, "money", -1)                  // -1 = 永久
AbolethPlusAPI.deleteValue(uuid, "money")

// 排行 / 求和
val top = AbolethPlusAPI.sort("money", 10, desc = true)

// ED 语法
AbolethPlusAPI.evalString(player, "-k money -v 100 -a + -msg")

Java 端:

import ray.mintcat.abolethplus.api.AbolethAPI;
import ray.mintcat.abolethplus.database.edit.EditAction;

AbolethAPI.set(uuid, "money", "1000");
AbolethAPI.add(uuid, "money", "500");
AbolethAPI.edit(uuid, "money", "500", EditAction.ADD);
String v = AbolethAPI.get(uuid, "money", "0");
AbolethAPI.runED(sender, "-k money -v 100 -a + -msg");

API 全部线程安全。数据库走连接池,临时变量走 ConcurrentHashMap,缓存层支持并发访问。getMetaNoCache 是兜底用的,主路径不要绕开缓存——绕开等于把 Redis 当摆设。

getUserUUID(name) 解析顺序:服务器特殊名 → UUID 字符串 → 在线玩家 → Display_Name 变量 → Bukkit.getOfflinePlayer。这个顺序保证"玩家名 / UUID / server"三种入参都能用同一个 API。

分析模块

config.yml 里的 analytics 段是一组开关,默认全部关闭。开启后会启动几个独立的统计组件:

模块

开关

作用

player_behavior

命令使用、登录登出、会话时长

玩家行为画像

variable_usage

变量读写次数、热门变量

变量使用统计

performance_monitor

CPU、内存、TPS、查询耗时、缓存命中率

性能监控

trend_analyzer

时间序列趋势、季节性、预测

趋势分析

maintenance_task

周期清理、定期报告

定期维护

操作入口是 /analytics(别名 /ana)。status / performance / health / player / variables / trends / export / report 各对应一份子报告。

模块都做了开关分流:总开关关时直接 return;总开关开但子模块关时也 return,避免无意义采样。

分析模块和主流程之间走 AnalyticsManager.recordPlayerAction / recordSystemMetric / recordDatabaseOperation / recordCacheOperation 四个钩子。开关关上时这几个调用是空操作,关掉分析对热路径几乎没有性能影响。

端到端示例:写一个签到系统

把上面零散的概念串起来。需求:玩家每天可以签到一次,连续签到累计金币,每天 0 点重置当日签到标记。

整套方案优先在 basedata.yml 里描述清楚——什么变量、什么默认值、什么周期、什么时候刷——避免再去 task/ 写孤立的调度器。

第一步,在 base/sign.yml 声明三个变量:

连续签到:
  key: "sign_streak"
  enable: true
  default: "0"
  interval:
    min: "0"
    max: "365"

每日签到标记:
  key: "sign_today"
  enable: true
  default: "0"
  update:
    enable: true
    type: "value"
    action: "SET"
    value: "0"               # 每天 0 点重置为 0
    condition: "true"
    period: "0 0 0 * * *"

签到金币:
  key: "money"
  enable: true
  default: "0"
  interval:
    min: "0"
    max: "none"

sign_today 借助 basedata 的 update 自动重置,不需要任何调度器代码。

第二步,签到指令交给玩家手动触发(防代签)。Kether:

def signed = abp sign_today def "0"
if check &signed == "1" then {
    tell inline "&c你今天已经签到过了"
    return
}
abp sign_today edit "+" to 1
abp sign_streak edit "+" to 1
abp money edit "+" to 100
tell inline "&a签到成功 连续 {{ %abp_get -k sign_streak% }} 天 +100 金币"

第三步,前端展示用 PAPI:

%abp_get -k sign_streak%                              # 当前连续签到天数
%abp_sort -k sign_streak -l 1 -dc true -t user%       # 签到榜第一名
%abp_sum -k money%                                    # 全服总金币

整套流程里:变量声明、变量行为(默认值、边界、刷新)、指令、PAPI 各司其职,业务代码只负责"今天该不该送金币"这一件事。所有定时逻辑都收敛到 basedata,没有用到 task/ 调度器。

几个有意思的边界

全局变量为什么不单独建一张表——保持单表 schema,所有用户/键的写入路径完全一致,少一张表少一处维护。代价是 BukkitServer 这个 UUID 占了 BanList 一个名额,启动时主动 ban 掉避免冲突。

为什么 condition 自己写解析器——表达式集合可控,不需要把 JEXL / Groovy 这种通用脚本引擎拉进来。配置文件里的"判断公式"应该是受限的,不应该能跑命令。

周期刷新为什么是 5 秒一次——原本是 1 秒,太激进。BaseData 的更新粒度本来就由配置里的 period(Cron)决定,扫描周期再细也只是在等触发。把扫描放慢,仅在玩家数据变化时事件驱动检查 milestones,是更合理的分层。

为什么写穿透而不是先写后删——后者会在并发场景下出现"读请求看到旧值后,写请求才删缓存"的窗口,导致缓存里反复写入旧值。写穿透直接落最新值,没有这个窗口。代价是写入路径多一次缓存写。

总结

这套变量系统的核心思路只有一句话:把 (玩家, 键, 值) 这一组关系做厚,让其他插件不用再自己造一遍

存储分层让冷热数据各得其所,配置驱动让"什么时候刷"和"怎么刷"分离,ED / Kether / PAPI 三套语法是给不同人群(运维、脚本作者、配置工程师)的不同入口。三者最终都收敛到同一份 API,行为一致。

要知其然也要知其所以然——上面那些细节(写穿透、整型保留、过期 DelayQueue、milestones 双路径),都是踩过坑之后才补回来的边界。

关于售卖

AbolethPlus 是一款付费插件,目前售价 150 元,购买请联系开发者 (GitHub: FxRayHughes)。

几点说明:

  • 一次性付费 买断之后不会有任何额外费用、不会有订阅、不会限制服务器数量。

  • 不加密 jar 包不混淆、不绑机器、不联网校验,文件交付即拥有。开发者也好、服主也好,拿到的是一份正常的、能解包看到完整结构的插件。

  • 学习版 如果你现在拿到的是流传出去的学习版,完全理解。每个人都有囊中羞涩的时候,初创团队、独立开发者、个人服主的难处大家都懂。等什么时候手头宽裕了,再考虑支持一下;不支持也没关系,能在你的项目里跑得稳就是这套插件存在的意义。

  • 支持 如果文档里的内容帮你省下了一段开发时间,或者让你少踩几个坑,转发、反馈、提 issue 都是莫大的帮助。

这套系统从 1.x 写到 4.x,每一版的迭代都是真实服务器跑出来的反馈:写穿透是被缓存击穿打过之后补的,DelayQueue 是被全表轮询拖慢之后改的,milestones 双路径是漏发奖之后才加上的。

这类基础设施类插件的价值不在文档本身,而在你不需要再为同一件事写一遍。把变量这件事彻底从 todo list 里划掉,省下来的时间留给真正的玩法。