返回提交历史
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerEvaluator.java
+15
-4
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerValueTypes.java
+61
-0
Modified
common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/trigger/TriggerEngineTest.java
+12
-0
Modified
common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/trigger/TriggerValueTypesTest.java
+6
-0
Modified
docs/implementation-status.md
+2
-2
Modified
docs/openapi.yaml
+40
-0
Modified
web-ui/src/pages/triggers.tsx
+5
-4
XFEstudio/XFEServerManager
完善变量渲染与接口文档
0f4f762
代码差异
7 个文件
+141
-10
@@ -48,7 +48,7 @@ public final class TriggerEvaluator {
48
48
// a match; authors can explicitly use not_exists when absence is intended.
49
49
if (actual == null) return operator.equals("neq") || operator.equals("not_contains")
50
50
|| operator.equals("not_in");
51
String text = Objects.toString(actual, "");
51
String text = conditionText(actual);
52
52
return switch (operator) {
53
53
case "eq" -> scalarEquals(actual, expected);
54
54
case "neq" -> !scalarEquals(actual, expected);
@@ -93,7 +93,14 @@ public final class TriggerEvaluator {
93
93
}
94
94
catch (NumberFormatException ignored) { return false; }
95
95
}
96
return Objects.toString(actual, "").equalsIgnoreCase(expected);
96
return conditionText(actual).equalsIgnoreCase(expected);
97
}
98
99
private static String conditionText(Object actual) {
100
if (actual instanceof Collection<?> || actual instanceof Map<?, ?>) {
101
return TriggerValueTypes.renderVariable(actual);
102
}
103
return Objects.toString(actual, "");
97
104
}
98
105
99
106
private static boolean compareNumber(String actual, String expected, String operator) {
@@ -330,10 +337,14 @@ public final class TriggerEvaluator {
330
337
value = formattedTime(path, format, safeContext);
331
338
}
332
339
boolean legacyVariable = !path.equals(variable) || variable.equals("date");
333
if ((value == null || value instanceof Map<?, ?>) && !legacyVariable) {
340
boolean stateVariable = path.startsWith("var.") || path.startsWith("global.");
341
if ((value == null || value instanceof Map<?, ?> && !stateVariable) && !legacyVariable) {
334
342
matcher.appendReplacement(rendered, Matcher.quoteReplacement(matcher.group()));
335
343
} else {
336
matcher.appendReplacement(rendered, Matcher.quoteReplacement(Objects.toString(value, "")));
344
String replacement = stateVariable
345
? TriggerValueTypes.renderVariable(value)
346
: Objects.toString(value, "");
347
matcher.appendReplacement(rendered, Matcher.quoteReplacement(replacement));
337
348
}
338
349
}
339
350
matcher.appendTail(rendered);
@@ -140,6 +140,67 @@ public final class TriggerValueTypes {
140
140
};
141
141
}
142
142
143
/** Serializes container variables as compact JSON while leaving scalar values human-readable. */
144
public static String renderVariable(Object value) {
145
if (!(value instanceof List<?>) && !(value instanceof Map<?, ?>)) {
146
return String.valueOf(value);
147
}
148
StringBuilder result = new StringBuilder();
149
appendJson(result, value);
150
return result.toString();
151
}
152
153
private static void appendJson(StringBuilder result, Object value) {
154
if (value == null) {
155
result.append("null");
156
} else if (value instanceof String text) {
157
appendJsonString(result, text);
158
} else if (value instanceof Boolean || value instanceof Number) {
159
result.append(value);
160
} else if (value instanceof List<?> values) {
161
result.append('[');
162
for (int index = 0; index < values.size(); index++) {
163
if (index > 0) result.append(',');
164
appendJson(result, values.get(index));
165
}
166
result.append(']');
167
} else if (value instanceof Map<?, ?> values) {
168
result.append('{');
169
boolean first = true;
170
for (Map.Entry<?, ?> entry : values.entrySet()) {
171
if (!first) result.append(',');
172
first = false;
173
appendJsonString(result, String.valueOf(entry.getKey()));
174
result.append(':');
175
appendJson(result, entry.getValue());
176
}
177
result.append('}');
178
} else {
179
appendJsonString(result, String.valueOf(value));
180
}
181
}
182
183
private static void appendJsonString(StringBuilder result, String value) {
184
result.append('"');
185
for (int index = 0; index < value.length(); index++) {
186
char current = value.charAt(index);
187
switch (current) {
188
case '"' -> result.append("\\\"");
189
case '\\' -> result.append("\\\\");
190
case '\b' -> result.append("\\b");
191
case '\f' -> result.append("\\f");
192
case '\n' -> result.append("\\n");
193
case '\r' -> result.append("\\r");
194
case '\t' -> result.append("\\t");
195
default -> {
196
if (current < 0x20) result.append(String.format("\\u%04x", (int) current));
197
else result.append(current);
198
}
199
}
200
}
201
result.append('"');
202
}
203
143
204
private static Object coerce(VariableType type, Object value) {
144
205
if (value == null) throw new IllegalArgumentException("null is not valid for " + type.canonical());
145
206
if (type.name().equals("array")) {
@@ -59,6 +59,18 @@ class TriggerEngineTest {
59
59
TriggerEvaluator.render("unknown {custom.missing}", context));
60
60
}
61
61
62
@Test
63
void rendersAndComparesNestedStateVariablesAsCompactJson() {
64
Map<String, Object> context = Map.of(
65
"var.names", List.of("甲", "line\nbreak"),
66
"global.rewards", Map.of("coins", List.of(1L, 2L)));
67
68
assertEquals("[\"甲\",\"line\\nbreak\"] / {\"coins\":[1,2]}",
69
TriggerEvaluator.render("{var.names} / {global.rewards}", context));
70
assertTrue(conditionMatches("global.rewards", "contains", "\"coins\":[1,2]", context));
71
assertTrue(conditionMatches("var.names", "eq", "[\"甲\",\"line\\nbreak\"]", context));
72
}
73
62
74
@Test
63
75
void validatesSchedulesAndRejectsUnknownActions() {
64
76
assertEquals("08:30", TriggerScriptCompiler.compile("""
@@ -39,6 +39,12 @@ class TriggerValueTypesTest {
39
39
"dictionary<integer,string>", "{\"not-an-integer\":\"value\"}"));
40
40
assertThrows(IllegalArgumentException.class, () -> TriggerValueTypes.parseVariable(
41
41
"dictionary<string,string>", "{\"key\":\"one\",\"key\":\"two\"}"));
42
43
Map<String, Object> rendered = new java.util.LinkedHashMap<>();
44
rendered.put("scores", List.of(1L, 2L));
45
rendered.put("name", "a\nb");
46
assertEquals("{\"scores\":[1,2],\"name\":\"a\\nb\"}",
47
TriggerValueTypes.renderVariable(rendered));
42
48
}
43
49
44
50
@Test
@@ -11,8 +11,8 @@ This document describes the current source boundary, not a release promise. `Imp
11
11
| Web authentication / Web 认证 | First-owner bootstrap, Argon2id, TOTP/recovery codes, session revocation, rate limits, Origin/CSRF, recent reauthentication, and owner-gated multi-account RBAC UI/API / 首位 owner、二次认证、会话与多账户 RBAC UI/API 已实现 | Deployment penetration testing and durable idempotency across process restarts / 部署渗透测试及跨进程重启的持久幂等 |
12
12
| Status and online players / 状态与在线玩家 | Live status DTO/SSE, player detail/actions, destructive preview token, inventory and ender-chest revision CAS, and Web workflows / 状态、玩家操作、预览确认及背包界面已实现 | Four-version gameplay/disconnect races; component-bearing slots are redacted and fail closed except deletion / 四版本实机与掉线竞态;含组件槽位除删除外故障关闭 |
13
13
| Player join experience / 玩家入服体验 | Legacy welcome, bulletin, rules and maintenance notices migrate idempotently into ordinary triggers; timezone and per-player/day delivery state remain durable / 旧欢迎、每日公告、规则及维护提醒会幂等迁移为普通触发器,时区与按玩家每日投递状态持久化 | Dedicated-server rendering, localization and reconnect-storm acceptance / 专服显示、本地化与重连风暴验收 |
14
| Trigger automation / 触发器自动化 | Durable bounded trigger groups, recoverable leased daily/interval schedules, broad Forge player/item/block/entity/world/chunk events, ALL/ANY comparisons, ordered actions, visual authoring, XFE Script, optimistic revisions, bounded execution, audit admission, rich-message validation, and idempotent migration of legacy messages / 已实现有上限的持久触发器组、可恢复租约定时任务、广泛 Forge 事件、条件与顺序动作、可视化和脚本双编辑、版本控制、有界执行、审计准入、富文本校验及旧消息迁移 | Dedicated-server event/action matrix on all four targets; third-party events beyond stable Forge hooks depend on custom adapters / 四版本专服事件动作矩阵;稳定 Forge 钩子之外的第三方事件依赖自定义适配器 |
15
| Multi-currency economy / 多货币经济 | Exact fixed-point balances, configurable currency icons/precision/bounds, one primary currency, atomic CAS adjustments/transfers, immutable ledger, Web UI, player commands, SSE, RBAC, audit admission, trigger events/actions/variables and menu-variable/image linkage / 已接通精确定点余额、图标/精度/边界、主货币、原子 CAS 调整与转账、不可变流水、Web、玩家指令、SSE、权限、审计、触发器与菜单联动 | Dedicated-server concurrency/load tests, UX acceptance and third-party economy compatibility adapters / 仍需专服并发压力、交互验收及第三方经济模组兼容适配 |
14
| Trigger automation / 触发器自动化 | Durable bounded trigger groups, recoverable leased daily/interval schedules, broad Forge player/item/block/entity/world/chunk events, ALL/ANY comparisons, ordered actions, visual authoring, XFE Script, scoped trigger/global variables with nested typed containers, module copy/paste, optimistic revisions, bounded execution, audit admission, rich-message validation, and idempotent migration of legacy messages / 已实现有上限的持久触发器组、可恢复租约定时任务、广泛 Forge 事件、条件与顺序动作、可视化和脚本双编辑、带嵌套强类型容器的触发器/全局作用域变量、模块复制粘贴、版本控制、有界执行、审计准入、富文本校验及旧消息迁移 | Dedicated-server event/action matrix on all four targets; third-party events beyond stable Forge hooks depend on custom adapters / 四版本专服事件动作矩阵;稳定 Forge 钩子之外的第三方事件依赖自定义适配器 |
15
| Multi-currency economy / 多货币经济 | Exact fixed-point balances, configurable currency icons/precision/bounds, one primary currency, atomic CAS adjustments/transfers, immutable ledger, Web UI, player commands, OP-only balance administration commands, SSE, RBAC, audit admission, trigger events/actions/variables and menu-variable/image linkage / 已接通精确定点余额、图标/精度/边界、主货币、原子 CAS 调整与转账、不可变流水、Web、玩家指令、仅 OP 可用的余额管理指令、SSE、权限、审计、触发器与菜单联动 | Dedicated-server concurrency/load tests, UX acceptance and third-party economy compatibility adapters / 仍需专服并发压力、交互验收及第三方经济模组兼容适配 |
16
16
| Audit and operations / 审计与操作 | Redaction, bounded SQLite queues, operation registry, SSE progress, checkpoint and gap accounting / 已实现 | Soak/load tests, crash-boundary validation, and explicit verification of the queue-admission durability boundary / 长稳压力、崩溃边界及队列准入持久性边界验证 |
17
17
| Metrics, webhook and soft probes / 指标、Webhook 与软检测 | Token-protected OpenMetrics formatter/endpoint, HMAC webhook sink, Spark/BlueMap presence probes / 部件已实现 | Outbound webhook configuration wiring and third-party API integrations / 出站 Webhook 配置接线及第三方 API 集成 |
18
18
| Graceful maintenance shutdown / 优雅维护关服 | Creation-time target notice, five-minute final-hour reminders, per-second final-minute countdown, `save-all flush`, SQLite checkpoint and vanilla stop are wired / 已接通创建提醒、末小时五分钟提醒、末分钟逐秒倒计时、保存、检查点与原版停服 | Dedicated-server shutdown ordering and failure-injection acceptance / 专服关停顺序与故障注入验收 |
@@ -2808,6 +2808,46 @@ components:
2808
2808
maxProperties: 16
2809
2809
description: schedule.daily uses time and timezone. schedule.interval accepts legacy total seconds or hours/minutes/seconds components, normalizes them to total seconds (1..31536000), and aligns execution to server-local wall-clock boundaries. player.command_trigger uses command plus an optional whitespace-separated arguments declaration (up to 16 required word arguments).
2810
2810
additionalProperties: {type: string, maxLength: 4096}
2811
arguments:
2812
type: array
2813
maxItems: 16
2814
description: Recursive Brigadier alternatives for player.command_trigger. Siblings are alternatives and children form the next input level.
2815
items: {$ref: "#/components/schemas/TriggerCommandArgument"}
2816
variables:
2817
type: array
2818
maxItems: 32
2819
description: Strongly typed trigger-local or server-global state declarations. Missing visibility/storage fields migrate as trigger-local trigger storage.
2820
items: {$ref: "#/components/schemas/TriggerStateVariable"}
2821
TriggerCommandArgument:
2822
type: object
2823
required: [name, type, literal, optional, errorMessage, minimum, maximum, suggestions, children]
2824
properties:
2825
name: {type: string, pattern: '^[a-z][a-z0-9_-]{0,31}$'}
2826
type: {type: string, description: A supported vanilla Brigadier argument family or literal.}
2827
literal: {type: string, maxLength: 128}
2828
optional: {type: boolean}
2829
errorMessage: {type: string, maxLength: 256}
2830
minimum: {type: string}
2831
maximum: {type: string}
2832
suggestions: {type: array, maxItems: 64, items: {type: string, minLength: 1, maxLength: 128}}
2833
children: {type: array, maxItems: 16, items: {$ref: "#/components/schemas/TriggerCommandArgument"}}
2834
TriggerStateVariable:
2835
type: object
2836
required: [name, type, initialValue]
2837
properties:
2838
name: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_-]{0,47}$'}
2839
type:
2840
type: string
2841
description: Scalar type, legacy list, or recursively nested array<T>/dictionary<K,V>; dictionary keys must be scalar and generic depth is bounded.
2842
initialValue:
2843
type: string
2844
maxLength: 65536
2845
description: Scalars use their text form; arrays and dictionaries use strictly typed JSON.
2846
visibility: {type: string, enum: [trigger, global], default: trigger}
2847
storage:
2848
type: string
2849
enum: [server, player, dimension, trigger]
2850
description: Selects one shared value, a value per triggering player, per dimension, or per executing trigger.
2811
2851
TriggerCondition:
2812
2852
type: object
2813
2853
required: [field, operator, value]
@@ -1986,7 +1986,7 @@ function actionIsValid(action: TriggerAction, catalog: TriggerCatalog): boolean
1986
1986
return templateOr(action.parameters.level ?? 'info', (entry) =>
1987
1987
['debug', 'info', 'warn', 'error'].includes(entry.trim().toLowerCase()));
1988
1988
case 'variable':
1989
return triggerVariableNameIsValid(action.parameters.name ?? '')
1989
return /^(?:global\.)?[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test((action.parameters.name ?? '').trim())
1990
1990
&& templateOr(action.parameters.operation ?? 'set', (entry) => variableOperations.includes(entry.trim().toLowerCase()));
1991
1991
case 'wait': {
1992
1992
const mode = (action.parameters.mode ?? 'duration').trim().toLowerCase();
@@ -2057,8 +2057,9 @@ function uuidIsValid(value: string): boolean {
2057
2057
2058
2058
function triggerVariableInitialValueIsValid(variable: TriggerStateVariableDefinition): boolean {
2059
2059
const value = variable.initialValue;
2060
if (value.length > 8_192) return false;
2061
if (variable.type.startsWith('array<') || variable.type.startsWith('dictionary<')) {
2060
const structured = variable.type.startsWith('array<') || variable.type.startsWith('dictionary<');
2061
if (value.length > (structured ? 65_536 : 8_192)) return false;
2062
if (structured) {
2062
2063
try { return structuredVariableValueIsValid(parseVariableTypeNode(variable.type), JSON.parse(value), 0); }
2063
2064
catch { return false; }
2064
2065
}
@@ -2289,7 +2290,7 @@ function toScript(value: TriggerDefinition): string {
2289
2290
const lines = [`# XFE Script v2`, `on ${value.event.type}`];
2290
2291
Object.entries(value.event.configuration).forEach(([key, entry]) => lines.push(`set ${key}=${quote(entry)}`));
2291
2292
(value.event.variables ?? []).forEach((variable) => lines.push(
2292
`var ${variable.name} ${variable.type} initial=${quote(variable.initialValue)} visibility=${quote(variable.visibility ?? 'trigger')} storage=${quote(variable.storage ?? 'trigger')}`));
2293
`var ${variable.name} ${variable.type} initial=${quote(variable.initialValue)} visibility=${quote(variable.visibility ?? 'trigger')} storage=${quote(variable.storage ?? ((variable.visibility ?? 'trigger') === 'global' ? 'server' : 'trigger'))}`));
2293
2294
const appendArguments = (argumentsList: TriggerCommandArgument[], parent = '') => argumentsList.forEach((argument) => {
2294
2295
const fields = [parent && `parent=${quote(parent)}`, argument.literal && `literal=${quote(argument.literal)}`,
2295
2296
argument.optional && 'optional=true', argument.errorMessage && `error=${quote(argument.errorMessage)}`,