import {
ArrowDown, ArrowUp, Braces, ChevronDown, ChevronRight, CirclePlay, ClipboardPaste, Code2, Copy, CopyPlus,
FolderPlus, GripVertical, Maximize2, Minimize2, Plus, Save, Trash2, Workflow,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { RichTextEditor, richTextIsValid, richTextPlainText } from '../components/rich-text-editor';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { templateTimeFormatsAreValid, VariableTextInput } from '../components/variable-catalog-picker';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Select, Textarea, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import { hasCapability } from '../lib/permissions';
import { insertTriggerNode, parseTriggerNode, serializeTriggerNode, triggerProgramFitsLimits,
type TriggerPasteTarget, type TriggerProgramNode } from '../lib/trigger-program-clipboard';
import type {
TriggerAction, TriggerCatalog, TriggerCommandArgument, TriggerCondition, TriggerDefinition, TriggerEventSpec,
TriggerEventCapture, TriggerEventSample, TriggerExecution, TriggerExpression, TriggerFunctionDeclaration,
TriggerGroup, TriggerLibraryInstallation, TriggerLibraryPackage, TriggerProgramV2, TriggerSimulationResponse,
TriggerStateVariableDefinition, TriggerStatement, TriggerValidation, TriggerVariableDeclarationV2,
TriggerVariableDefinition, TriggerWorkspace,
} from '../types';
const fallbackCatalog: TriggerCatalog = {
defaultMessageSender: 'XFEServerManager',
events: ['schedule.daily', 'schedule.interval', 'server.started', 'server.stopping', 'player.join',
'player.leave', 'player.chat', 'player.command', 'player.command_trigger', 'player.death', 'player.respawn',
'player.dimension_change', 'player.advancement', 'player.item_pickup', 'player.item_drop',
'player.item_use', 'player.item_use_finish', 'player.item_craft', 'player.attack', 'player.hurt',
'player.heal', 'player.sleep', 'player.wake', 'player.interact', 'player.entity_interact',
'block.break', 'block.place', 'block.change', 'block.grow', 'block.tool_modify', 'entity.spawn',
'entity.remove', 'entity.death', 'world.explosion', 'world.weather_change', 'world.load',
'world.unload', 'chunk.load', 'chunk.unload', 'variable.changed', 'menu.open', 'menu.close',
'menu.control', 'economy.balance_changed', 'economy.deposit', 'economy.withdraw', 'economy.set',
'economy.transfer', 'protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow', 'protection.mod_entity_overflow', 'protection.spawn_burst',
'protection.command_block_rate', 'protection.slow_tick', 'protection.loaded_chunk_overflow',
'protection.memory_pressure', 'custom'],
operators: ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'not_starts_with',
'ends_with', 'not_ends_with', 'matches', 'not_matches', 'gt', 'gte', 'lt', 'lte',
'between', 'not_between', 'in', 'not_in', 'exists', 'not_exists', 'empty', 'not_empty',
'true', 'false'],
actions: ['send_player', 'broadcast', 'title', 'actionbar', 'sound', 'server_command',
'player_command', 'kick', 'teleport', 'give_item', 'clear_inventory', 'set_gamemode',
'add_effect', 'remove_effects', 'heal', 'feed', 'set_time', 'set_weather', 'whitelist_add',
'whitelist_remove', 'ban', 'pardon', 'log', 'variable', 'wait', 'run_trigger', 'open_menu', 'close_menu',
'economy_deposit', 'economy_withdraw', 'economy_set_balance', 'economy_transfer',
'economy_deposit_player', 'economy_withdraw_player', 'economy_set_player_balance',
'economy_transfer_players'],
actionParameters: {
send_player: ['message'], broadcast: ['message'], title: ['title', 'subtitle', 'numberPrecision', 'fadeIn', 'stay', 'fadeOut'],
actionbar: ['message'], sound: ['sound', 'volume', 'pitch'], server_command: ['command', 'showFeedback'],
player_command: ['command', 'showFeedback'], kick: ['message'], log: ['message', 'level'],
teleport: ['destination'], give_item: ['item', 'count'], clear_inventory: ['item', 'maxCount'],
set_gamemode: ['gamemode'], add_effect: ['effect', 'duration', 'amplifier'], remove_effects: [],
heal: [], feed: [], set_time: ['time'], set_weather: ['weather', 'duration'], whitelist_add: [],
whitelist_remove: [], ban: ['reason'], pardon: [],
variable: ['name', 'operation', 'value', 'extra'],
wait: ['mode', 'value', 'timeout', 'pollTicks', 'field', 'operator', 'expected'],
run_trigger: ['triggerId', 'waitForCompletion'],
open_menu: ['menuId'], close_menu: [],
economy_deposit: ['currency', 'amount', 'reason'],
economy_withdraw: ['currency', 'amount', 'reason'],
economy_set_balance: ['currency', 'amount', 'reason'],
economy_transfer: ['currency', 'amount', 'target', 'reason'],
economy_deposit_player: ['player', 'currency', 'amount', 'reason'],
economy_withdraw_player: ['player', 'currency', 'amount', 'reason'],
economy_set_player_balance: ['player', 'currency', 'amount', 'reason'],
economy_transfer_players: ['source', 'target', 'currency', 'amount', 'reason'],
},
commandArgumentTypes: ['literal', 'bool', 'integer', 'long', 'float', 'double', 'coordinate', 'block_pos',
'column_pos', 'vec2', 'vec3', 'rotation', 'angle', 'string', 'word', 'greedy_string', 'player',
'players', 'entity', 'entities', 'game_profile', 'block_state', 'block_predicate', 'item_stack',
'item_predicate', 'color', 'component', 'message', 'nbt', 'nbt_tag', 'compound_tag', 'nbt_path', 'objective',
'objective_criteria', 'operation', 'score_holder', 'scoreboard_slot', 'swizzle', 'team',
'int_range', 'float_range', 'particle', 'resource_location', 'resource',
'resource_key', 'resource_or_tag', 'resource_or_tag_key', 'dimension', 'gamemode', 'time', 'uuid',
'function', 'entity_anchor', 'enchantment', 'mob_effect', 'item_slot', 'item_slots', 'template_mirror',
'template_rotation', 'heightmap', 'loot_table', 'loot_predicate', 'loot_modifier', 'biome',
'structure', 'advancement', 'recipe'],
triggerVariableTypes: ['bool', 'integer', 'float', 'string', 'coordinate', 'uuid', 'player',
'resource_location', 'block_state', 'item_stack', 'component', 'nbt', 'position', 'rotation',
'duration', 'instant', 'region_ref', 'player_ref', 'entity_ref', 'block_ref', 'item_ref', 'record',
'array', 'set', 'optional', 'dictionary', 'list'],
variableLifetimes: ['session', 'ttl', 'persistent'],
variableStorageScopes: ['server', 'player', 'dimension', 'trigger', 'execution', 'chunk', 'entity'],
};
const eventLabels: Record<string, [string, string]> = {
'schedule.daily': ['每天定时', 'Daily schedule'], 'schedule.interval': ['固定间隔', 'Interval'],
'server.started': ['服务器启动', 'Server started'], 'server.stopping': ['服务器停止', 'Server stopping'],
'player.join': ['玩家进入服务器', 'Player joined'], 'player.leave': ['玩家离开服务器', 'Player left'],
'player.chat': ['玩家聊天', 'Player chat'], 'player.command': ['玩家执行指令', 'Player command'],
'player.command_trigger': ['玩家输入自定义指令', 'Player enters a custom command'],
'player.death': ['玩家死亡', 'Player death'], 'player.respawn': ['玩家重生', 'Player respawn'],
'player.dimension_change': ['玩家切换维度', 'Dimension changed'],
'player.advancement': ['玩家完成进度', 'Advancement'], 'player.item_pickup': ['玩家拾取物品', 'Item pickup'],
'player.item_drop': ['玩家丢弃物品', 'Item dropped'], 'player.item_use': ['玩家使用物品', 'Item used'],
'player.item_use_finish': ['玩家使用完物品', 'Item use finished'], 'player.item_craft': ['玩家合成物品', 'Item crafted'],
'player.attack': ['玩家攻击', 'Player attack'], 'player.hurt': ['玩家受伤', 'Player hurt'],
'player.heal': ['玩家恢复生命', 'Player healed'], 'player.sleep': ['玩家入睡', 'Player slept'],
'player.wake': ['玩家醒来', 'Player woke'], 'player.interact': ['玩家交互', 'Player interact'],
'player.entity_interact': ['玩家与实体交互', 'Player interacted with entity'],
'block.break': ['方块被破坏', 'Block broken'], 'block.place': ['方块被放置', 'Block placed'],
'block.change': ['方块变化', 'Block changed'], 'block.grow': ['方块生长', 'Block grew'],
'block.tool_modify': ['工具修改方块', 'Block modified by tool'], 'entity.spawn': ['实体生成', 'Entity spawned'],
'entity.remove': ['实体移除', 'Entity removed'], 'entity.death': ['实体死亡', 'Entity died'],
'world.explosion': ['世界爆炸', 'Explosion'], 'world.weather_change': ['天气变化', 'Weather changed'],
'world.load': ['世界加载', 'World loaded'], 'world.unload': ['世界卸载', 'World unloaded'],
'chunk.load': ['区块加载', 'Chunk loaded'], 'chunk.unload': ['区块卸载', 'Chunk unloaded'],
'variable.changed': ['触发器变量发生变化', 'Trigger variable changed'],
'menu.open': ['菜单打开', 'Menu opened'], 'menu.close': ['菜单关闭', 'Menu closed'],
'menu.control': ['菜单控件交互', 'Menu control interaction'],
'economy.balance_changed': ['任意经济余额变化', 'Any economy balance change'],
'economy.deposit': ['货币增加', 'Economy deposit'], 'economy.withdraw': ['货币扣除', 'Economy withdrawal'],
'economy.set': ['余额设置', 'Economy balance set'], 'economy.transfer': ['玩家转账', 'Player transfer'],
'protection.item_overflow': ['掉落物数量超限', 'Dropped-item count exceeded'],
'protection.mob_overflow': ['生物数量超限', 'Mob count exceeded'],
'protection.entity_overflow': ['实体总数超限', 'Total entity count exceeded'],
'protection.mod_entity_overflow': ['指定模组实体超限', 'Mod entity count exceeded'],
'protection.spawn_burst': ['实体生成速率超限', 'Entity spawn rate exceeded'],
'protection.command_block_rate': ['命令方块执行过快', 'Command-block rate exceeded'],
'protection.slow_tick': ['服务器连续慢刻', 'Consecutive slow server ticks'],
'protection.loaded_chunk_overflow': ['加载区块数量超限', 'Loaded-chunk count exceeded'],
'protection.memory_pressure': ['堆内存使用率过高', 'Heap memory pressure'],
'region.enter': ['进入区域', 'Entered region'], 'region.leave': ['离开区域', 'Left region'],
'region.stay': ['停留在区域', 'Stayed in region'],
'custom.content': ['自定义内容交互', 'Custom content interaction'],
custom: ['自定义/模组事件', 'Custom/mod event'],
};
const eventDescriptions: Record<string, [string, string]> = {
'schedule.daily': ['每天在指定时区和时间运行一次。', 'Runs once per day at the configured time and time zone.'],
'schedule.interval': ['按照固定秒数或游戏刻间隔重复运行。', 'Repeats at the configured real-time or tick interval.'],
'server.started': ['服务器完成启动、可以处理游戏事件时运行。', 'Runs after the server has started and can process game events.'],
'server.stopping': ['服务器进入安全停止流程时运行。', 'Runs when the server enters its orderly shutdown sequence.'],
'player.command_trigger': ['玩家输入此触发器注册的自定义命令时运行,并提供命令参数。',
'Runs when a player enters the custom command registered by this trigger, with parsed arguments.'],
'variable.changed': ['持久或会话触发器变量成功写入新值后运行。', 'Runs after a session or persistent trigger variable changes.'],
'menu.control': ['玩家点击或修改 Web 菜单控件时运行。', 'Runs when a player activates or changes a menu control.'],
'region.enter': ['玩家或实体从区域外移动到区域内时运行。', 'Runs when a player or entity crosses into a region.'],
'region.leave': ['玩家或实体从区域内移动到区域外时运行。', 'Runs when a player or entity leaves a region.'],
'region.stay': ['玩家或实体持续位于区域内并达到检测间隔时运行。', 'Runs while a player or entity remains inside a region.'],
'custom.content': ['仅通过内容工作台的“调用触发器”行为显式调用,提供交互玩家与 content.* 响应;不会自动订阅所有内容。',
'Invoked explicitly by a Call trigger behavior in the content workbench, with the interacting player and content.* responses; does not automatically subscribe to all content.'],
custom: ['接收由其他模组通过命名空间发布并经过校验的事件。', 'Receives a validated namespaced event published by another mod.'],
};
const actionLabels: Record<string, [string, string]> = {
send_player: ['向事件玩家发消息', 'Message event player'], broadcast: ['全服广播', 'Broadcast'],
title: ['显示标题', 'Show title'], actionbar: ['显示操作栏', 'Show action bar'],
sound: ['播放声音', 'Play sound'], server_command: ['执行控制台指令', 'Run server command'],
player_command: ['以玩家身份执行指令', 'Run as player'], kick: ['踢出玩家', 'Kick player'],
teleport: ['传送玩家', 'Teleport player'], give_item: ['给予物品', 'Give item'],
clear_inventory: ['清理背包', 'Clear inventory'], set_gamemode: ['设置游戏模式', 'Set game mode'],
add_effect: ['添加效果', 'Add effect'], remove_effects: ['清除效果', 'Clear effects'],
heal: ['治疗玩家', 'Heal player'], feed: ['恢复饱食度', 'Feed player'],
set_time: ['设置世界时间', 'Set world time'], set_weather: ['设置天气', 'Set weather'],
whitelist_add: ['加入白名单', 'Add to whitelist'], whitelist_remove: ['移出白名单', 'Remove from whitelist'],
ban: ['封禁玩家', 'Ban player'], pardon: ['解除封禁', 'Pardon player'],
log: ['写入日志', 'Write log'],
variable: ['操作触发器变量', 'Modify trigger variable'],
wait: ['等待', 'Wait'], run_trigger: ['执行其他触发器', 'Run another trigger'],
open_menu: ['打开 UI 菜单', 'Open UI menu'], close_menu: ['关闭 UI 菜单', 'Close UI menu'],
economy_deposit: ['增加事件玩家余额', 'Deposit to event player'],
economy_withdraw: ['扣除事件玩家余额', 'Withdraw from event player'],
economy_set_balance: ['设置事件玩家余额', 'Set event player balance'],
economy_transfer: ['从事件玩家转账', 'Transfer from event player'],
economy_deposit_player: ['增加指定玩家余额', 'Deposit to selected player'],
economy_withdraw_player: ['扣除指定玩家余额', 'Withdraw from selected player'],
economy_set_player_balance: ['设置指定玩家余额', 'Set selected player balance'],
economy_transfer_players: ['指定玩家之间转账', 'Transfer between selected players'],
set_health: ['设置玩家生命值', 'Set player health'], set_food: ['设置玩家饱食度', 'Set player food'],
set_experience: ['设置玩家经验', 'Set player experience'], set_spawnpoint: ['设置重生点', 'Set spawn point'],
scoreboard_set: ['设置计分板分数', 'Set scoreboard score'], scoreboard_add: ['增加计分板分数', 'Add scoreboard score'],
team_join: ['加入计分板队伍', 'Join scoreboard team'], team_leave: ['离开计分板队伍', 'Leave scoreboard team'],
advancement_grant: ['授予进度', 'Grant advancement'], advancement_revoke: ['撤销进度', 'Revoke advancement'],
spawn_entity: ['生成实体', 'Spawn entity'], damage_entity: ['伤害实体', 'Damage entity'],
teleport_entity: ['传送实体', 'Teleport entity'], remove_entity: ['移除实体', 'Remove entity'],
set_entity_attribute: ['设置实体属性', 'Set entity attribute'], tag_entity: ['修改实体标签', 'Modify entity tag'],
set_block: ['设置方块', 'Set block'], fill_blocks: ['批量填充方块', 'Fill blocks'],
explosion: ['创建爆炸', 'Create explosion'], lightning: ['召唤闪电', 'Summon lightning'],
set_difficulty: ['设置难度', 'Set difficulty'], set_gamerule: ['设置游戏规则', 'Set game rule'],
world_border: ['修改世界边界', 'Modify world border'],
};
const actionDescriptions: Record<string, [string, string]> = {
send_player: ['向触发当前事件的玩家发送消息。需要玩家事件上下文。', 'Sends a message to the player that caused the event. Requires player context.'],
broadcast: ['向服务器内所有在线玩家发送消息。', 'Sends a message to every online player.'],
title: ['在事件玩家屏幕中央显示标题、副标题和淡入淡出效果。', 'Shows a title and subtitle with timing effects to the event player.'],
actionbar: ['在事件玩家快捷栏上方显示短消息。', 'Shows a short message above the event player hotbar.'],
sound: ['在事件玩家当前位置播放指定资源声音。', 'Plays a resource sound at the event player position.'],
server_command: ['以服务器控制台权限执行单行命令;仅 Owner 可配置。', 'Runs one command as the server console; Owner only.'],
player_command: ['让事件玩家执行一条命令;仅 Owner 可配置。', 'Runs one command as the event player; Owner only.'],
kick: ['断开事件玩家连接并显示原因。', 'Disconnects the event player with a reason.'],
teleport: ['将事件玩家传送到坐标、玩家或预设位置。', 'Teleports the event player to coordinates, another player, or a position.'],
give_item: ['向事件玩家背包给予指定物品和数量。', 'Gives an item stack to the event player inventory.'],
clear_inventory: ['从事件玩家背包移除全部或指定物品。', 'Removes all or selected items from the event player inventory.'],
set_gamemode: ['修改事件玩家的游戏模式。', 'Changes the event player game mode.'],
add_effect: ['为事件玩家添加药水效果、持续时间和等级。', 'Applies an effect with duration and amplifier to the event player.'],
remove_effects: ['清除事件玩家当前的全部药水效果。', 'Clears all active effects from the event player.'],
heal: ['将事件玩家恢复到最大生命值。', 'Restores the event player to maximum health.'],
feed: ['恢复事件玩家的饱食度与饱和度。', 'Restores the event player food and saturation.'],
set_time: ['修改事件世界的昼夜时间;仅 Owner 可配置。', 'Changes world day time; Owner only.'],
set_weather: ['修改事件世界的天气与持续时间;仅 Owner 可配置。', 'Changes world weather and duration; Owner only.'],
whitelist_add: ['将事件玩家身份加入服务器白名单。', 'Adds the event player identity to the server allowlist.'],
whitelist_remove: ['将事件玩家身份移出服务器白名单。', 'Removes the event player identity from the server allowlist.'],
ban: ['封禁事件玩家并记录原因。', 'Bans the event player and records a reason.'],
pardon: ['解除事件玩家的封禁。', 'Removes the event player ban.'],
log: ['向服务器日志写入指定级别的审计消息。', 'Writes an audit message at the selected server log level.'],
variable: ['设置、计算或修改声明过的触发器变量。', 'Sets, calculates, or mutates a declared trigger variable.'],
wait: ['暂停当前执行,等待时长、游戏时间或条件;可在重启后恢复。', 'Suspends this execution for a duration, game time, or condition and can resume after restart.'],
run_trigger: ['调用另一个触发器,可选择等待子执行完成。', 'Invokes another trigger and can wait for the child execution.'],
open_menu: ['为事件玩家打开指定的自定义菜单。', 'Opens a custom menu for the event player.'],
close_menu: ['关闭事件玩家当前打开的自定义菜单。', 'Closes the event player current custom menu.'],
economy_deposit: ['增加事件玩家的指定货币余额并写入流水。', 'Credits the event player currency balance and writes a ledger entry.'],
economy_withdraw: ['扣除事件玩家余额;余额不足时操作失败。', 'Debits the event player balance and fails when funds are insufficient.'],
economy_set_balance: ['将事件玩家的指定货币余额设置为目标值。', 'Sets the event player currency balance to an exact value.'],
economy_transfer: ['从事件玩家向目标玩家原子转账。', 'Atomically transfers currency from the event player to a target.'],
economy_deposit_player: ['增加通过名称、UUID 或引用指定的玩家余额。', 'Credits a player selected by name, UUID, or reference.'],
economy_withdraw_player: ['扣除通过名称、UUID 或引用指定的玩家余额。', 'Debits a player selected by name, UUID, or reference.'],
economy_set_player_balance: ['设置指定玩家的精确货币余额。', 'Sets an exact currency balance for a selected player.'],
economy_transfer_players: ['在两个指定玩家之间原子转账。', 'Atomically transfers currency between two selected players.'],
set_health: ['把事件玩家生命值限制并设置到给定数值。', 'Clamps and sets the event player health.'],
set_food: ['把事件玩家饱食度设置为 0 到 20。', 'Sets event player food level from 0 to 20.'],
set_experience: ['按经验点或等级设置事件玩家经验。', 'Sets event player experience as points or levels.'],
set_spawnpoint: ['设置事件玩家的维度与重生坐标。', 'Sets the event player respawn dimension and position.'],
scoreboard_set: ['将事件玩家在目标计分项的分数设为指定值。', 'Sets the event player score for an objective.'],
scoreboard_add: ['在事件玩家当前计分上增加指定值。', 'Adds a value to the event player score.'],
team_join: ['把事件玩家加入指定计分板队伍。', 'Adds the event player to a scoreboard team.'],
team_leave: ['让事件玩家离开当前计分板队伍。', 'Removes the event player from the current scoreboard team.'],
advancement_grant: ['向事件玩家授予指定 Minecraft 进度。', 'Grants a Minecraft advancement to the event player.'],
advancement_revoke: ['撤销事件玩家的指定 Minecraft 进度。', 'Revokes a Minecraft advancement from the event player.'],
spawn_entity: ['在指定维度和位置生成实体;仅 Owner 可配置。', 'Spawns an entity at a world position; Owner only.'],
damage_entity: ['对选择器匹配的实体造成指定类型与数值的伤害。', 'Damages entities matched by a selector.'],
teleport_entity: ['将选择器匹配的实体传送到目标位置。', 'Teleports entities matched by a selector.'],
remove_entity: ['安全移除选择器匹配的实体。', 'Safely removes entities matched by a selector.'],
set_entity_attribute: ['修改选择器匹配实体的属性基础值。', 'Changes an attribute base value on matched entities.'],
tag_entity: ['为选择器匹配实体添加或移除命令标签。', 'Adds or removes command tags on matched entities.'],
set_block: ['在指定维度和坐标设置一个方块;仅 Owner 可配置。', 'Sets one block at a dimension position; Owner only.'],
fill_blocks: ['在两个坐标之间批量填充方块;执行前受预算限制。', 'Fills blocks between two positions under an execution budget.'],
explosion: ['在指定世界位置创建受控爆炸。', 'Creates a controlled explosion at a world position.'],
lightning: ['在指定世界位置召唤闪电。', 'Summons lightning at a world position.'],
set_difficulty: ['修改服务器世界难度。', 'Changes server world difficulty.'],
set_gamerule: ['修改指定游戏规则并记录审计。', 'Changes a game rule with an audit record.'],
world_border: ['设置或移动世界边界中心、大小与过渡时间。', 'Sets or transitions world-border center and size.'],
};
const variableOperations = ['set', 'parse', 'add', 'subtract', 'multiply', 'divide', 'modulo',
'increment', 'decrement', 'append', 'prepend', 'replace', 'regex_replace', 'trim', 'upper',
'lower', 'escape_json', 'escape_command', 'escape_regex', 'toggle', 'list_add', 'list_remove',
'array_add', 'array_insert', 'array_remove', 'array_remove_at', 'array_set',
'dictionary_put', 'dictionary_remove', 'dictionary_merge', 'clear'];
const variableOperationLabels: Record<string, [string, string]> = {
set: ['设置值', 'Set'], parse: ['解析并设置', 'Parse and set'], add: ['数值相加', 'Add'],
subtract: ['数值相减', 'Subtract'], multiply: ['数值相乘', 'Multiply'], divide: ['数值相除', 'Divide'],
modulo: ['取余', 'Modulo'], increment: ['加一', 'Increment'], decrement: ['减一', 'Decrement'],
append: ['字符串追加', 'Append'], prepend: ['字符串前置', 'Prepend'], replace: ['文本替换', 'Replace'],
regex_replace: ['正则替换', 'Regex replace'], trim: ['去除首尾空白', 'Trim'], upper: ['转为大写', 'Uppercase'],
lower: ['转为小写', 'Lowercase'], escape_json: ['JSON 转义', 'Escape JSON'],
escape_command: ['命令字符串转义', 'Escape command'], escape_regex: ['正则转义', 'Escape regex'],
toggle: ['切换布尔值', 'Toggle'], list_add: ['列表添加', 'Add to list'],
list_remove: ['列表移除', 'Remove from list'], array_add: ['数组末尾添加', 'Append to array'],
array_insert: ['数组指定位置插入', 'Insert into array'], array_remove: ['移除数组匹配值', 'Remove array value'],
array_remove_at: ['移除数组下标', 'Remove array index'], array_set: ['设置数组下标', 'Set array index'],
dictionary_put: ['设置字典键值', 'Put dictionary entry'], dictionary_remove: ['删除字典键', 'Remove dictionary key'],
dictionary_merge: ['合并字典', 'Merge dictionary'], clear: ['清空/归零', 'Clear/reset'],
};
type TriggerModuleKind = 'event' | 'argument' | 'variable' | 'condition' | 'action';
interface TriggerModuleClipboard { marker: 'xfesm-trigger-module-v1'; kind: TriggerModuleKind; value: unknown }
let triggerModuleClipboard: TriggerModuleClipboard | undefined;
async function copyTriggerModule(kind: TriggerModuleKind, value: unknown): Promise<void> {
const envelope: TriggerModuleClipboard = { marker: 'xfesm-trigger-module-v1', kind, value: structuredClone(value) };
triggerModuleClipboard = envelope;
try { await navigator.clipboard?.writeText(JSON.stringify(envelope)); } catch { /* Internal clipboard remains available. */ }
}
async function pasteTriggerModule<T>(kind: TriggerModuleKind): Promise<T | undefined> {
let envelope = triggerModuleClipboard;
try {
const text = await navigator.clipboard?.readText();
if (text) {
const candidate = JSON.parse(text) as Partial<TriggerModuleClipboard>;
if (candidate.marker === 'xfesm-trigger-module-v1' && candidate.kind === kind) {
envelope = candidate as TriggerModuleClipboard;
}
}
} catch { /* Browser permission may deny system clipboard reads; use the internal copy. */ }
return envelope?.kind === kind ? structuredClone(envelope.value) as T : undefined;
}
function ModuleCopyButton({ kind, value, zh, name }: { kind: TriggerModuleKind; value: unknown; zh: boolean; name: string }) {
return <Button type="button" variant="ghost" title={zh ? `复制${name}` : `Copy ${name}`}
aria-label={zh ? `复制${name}` : `Copy ${name}`} onClick={() => void copyTriggerModule(kind, value)}><Copy size={14} /></Button>;
}
const waitModes = ['duration', 'ticks', 'game_time', 'condition'];
const waitModeLabels: Record<string, [string, string]> = {
duration: ['现实时间(秒)', 'Real duration (seconds)'], ticks: ['服务器游戏刻', 'Server ticks'],
game_time: ['等待到游戏时间', 'Until game time'], condition: ['等待判断条件成立', 'Until condition matches'],
};
const actionParameterLabels: Record<string, [string, string]> = {
message: ['消息内容', 'Message'], title: ['主标题', 'Title'], subtitle: ['副标题', 'Subtitle'],
fadeIn: ['淡入时间(游戏刻)', 'Fade-in ticks'], stay: ['停留时间(游戏刻)', 'Stay ticks'],
fadeOut: ['淡出时间(游戏刻)', 'Fade-out ticks'], sound: ['声音资源 ID', 'Sound resource ID'],
volume: ['音量', 'Volume'], pitch: ['音调', 'Pitch'], command: ['命令(不含 /)', 'Command (without /)'],
destination: ['目标位置', 'Destination'], item: ['物品资源 ID', 'Item resource ID'], count: ['数量', 'Count'],
maxCount: ['最大移除数量', 'Maximum removal count'], gamemode: ['游戏模式', 'Game mode'],
effect: ['效果资源 ID', 'Effect resource ID'], duration: ['持续时间', 'Duration'], amplifier: ['效果等级', 'Amplifier'],
time: ['世界时间', 'World time'], weather: ['天气', 'Weather'], level: ['日志级别', 'Log level'],
name: ['变量', 'Variable'], operation: ['操作方式', 'Operation'], value: ['值/等待量', 'Value / wait amount'],
extra: ['第二值/替换内容', 'Second value / replacement'], mode: ['模式', 'Mode'],
timeout: ['超时秒数(0 为无超时)', 'Timeout seconds (0 = none)'],
pollTicks: ['检测频率(游戏刻)', 'Check interval (ticks)'], field: ['判断字段', 'Condition field'],
operator: ['判断方式', 'Operator'], expected: ['期望值', 'Expected value'],
triggerId: ['目标触发器', 'Target trigger'], waitForCompletion: ['调用方式', 'Invocation mode'],
menuId: ['目标菜单', 'Target menu'],
currency: ['货币(代码或变量)', 'Currency (code or variable)'],
amount: ['金额', 'Amount'], target: ['目标玩家(名称、UUID 或变量)', 'Target player (name, UUID, or variable)'],
player: ['指定玩家(名称、UUID 或变量)', 'Selected player (name, UUID, or variable)'],
source: ['来源玩家(名称、UUID 或变量)', 'Source player (name, UUID, or variable)'],
reason: ['流水原因', 'Ledger reason'],
showFeedback: ['显示命令反馈', 'Show command feedback'],
numberPrecision: ['浮点变量保留位数', 'Floating-point fraction digits'],
position: ['世界位置', 'World position'], selector: ['实体选择器', 'Entity selector'],
entityType: ['实体类型资源 ID', 'Entity type resource ID'], nbt: ['实体 NBT', 'Entity NBT'],
damageType: ['伤害类型', 'Damage type'], attribute: ['实体属性', 'Entity attribute'],
tag: ['实体标签', 'Entity tag'], dimension: ['维度资源 ID', 'Dimension resource ID'],
block: ['方块状态/资源 ID', 'Block state/resource ID'], from: ['起点位置', 'Start position'],
to: ['终点位置', 'End position'], power: ['爆炸威力', 'Explosion power'], fire: ['是否产生火焰', 'Create fire'],
difficulty: ['世界难度', 'World difficulty'], rule: ['游戏规则', 'Game rule'],
seconds: ['过渡秒数', 'Transition seconds'], objective: ['计分项目标', 'Scoreboard objective'],
team: ['计分板队伍', 'Scoreboard team'], advancement: ['进度资源 ID', 'Advancement resource ID'],
};
const actionParameterDescriptions: Record<string, [string, string]> = {
message: ['支持常量、事件响应、变量和函数表达式。', 'Accepts constants, event responses, variables, and function expressions.'],
selector: ['可使用 UUID、持久实体引用或受限 Minecraft 选择器。', 'Accepts a UUID, persistent entity reference, or bounded Minecraft selector.'],
position: ['使用 position 表达式,或“维度 + x/y/z”结构。', 'Use a position expression or a dimension plus x/y/z record.'],
destination: ['可引用玩家、实体或明确的世界坐标。', 'May reference a player, entity, or explicit world coordinates.'],
duration: ['动作不同,可表示秒数、游戏刻或 duration 类型。', 'Depending on the action, this is seconds, ticks, or a duration value.'],
operation: ['选择该动作要执行的具体修改方式。', 'Selects the concrete mutation performed by this action.'],
waitForCompletion: ['启用后,父触发器会等待子触发器结束再继续。', 'When enabled, the parent waits for the child trigger to finish.'],
showFeedback: ['默认关闭,避免触发器命令刷屏。', 'Disabled by default to avoid command feedback spam.'],
nbt: ['仅接受受限、可序列化的 NBT 文本;留空使用实体默认值。', 'Accepts bounded serializable NBT text; leave empty for defaults.'],
};
const catalogKindLabels: Record<string, [string, string]> = {
EVENT: ['事件', 'Event'], EVENT_RESPONSE: ['事件响应', 'Event response'],
VALUE_FUNCTION: ['值函数', 'Value function'], CONDITION_FUNCTION: ['条件函数', 'Condition function'],
ACTION: ['动作', 'Action'], TYPE: ['类型', 'Type'], ALL: ['全部六类', 'All six categories'],
};
const statementKindLabels: Record<string, [string, string]> = {
ACTION: ['执行动作', 'Action'], SET: ['设置变量', 'Set variable'], IF: ['条件分支', 'If'],
SWITCH: ['多路分支', 'Switch'], REPEAT: ['定次数循环', 'Repeat'], WHILE: ['条件循环', 'While'],
FOREACH: ['遍历集合', 'For each'], CALL: ['调用函数', 'Call'], RETURN: ['返回', 'Return'],
TRY: ['错误处理', 'Try / catch'], BREAK: ['跳出循环', 'Break'], CONTINUE: ['继续循环', 'Continue'],
};
const expressionKindLabels: Record<string, [string, string]> = {
LITERAL: ['常量', 'Literal'], REFERENCE: ['变量/事件引用', 'Reference'], UNARY: ['一元运算', 'Unary operation'],
BINARY: ['二元运算', 'Binary operation'], FUNCTION: ['函数调用', 'Function call'], INDEX: ['集合索引', 'Collection index'],
COALESCE: ['空值回退', 'Null coalescing'], CONVERT: ['类型转换', 'Type conversion'],
};
const triggerTypeLabels: Record<string, [string, string]> = {
void: ['无返回值', 'No value'], any: ['任意值', 'Any value'], bool: ['布尔值', 'Boolean'],
integer: ['整数', 'Integer'], float: ['小数', 'Number'], string: ['文本', 'Text'], coordinate: ['坐标分量', 'Coordinate'],
uuid: ['UUID', 'UUID'], player: ['在线玩家', 'Online player'], resource_location: ['资源 ID', 'Resource ID'],
block_state: ['方块状态', 'Block state'], item_stack: ['物品堆', 'Item stack'], component: ['富文本组件', 'Text component'],
nbt: ['NBT 数据', 'NBT data'], position: ['世界位置', 'World position'], rotation: ['旋转角度', 'Rotation'],
duration: ['时间长度', 'Duration'], instant: ['时间点', 'Instant'], region_ref: ['区域引用', 'Region reference'],
player_ref: ['玩家引用', 'Player reference'], entity_ref: ['实体引用', 'Entity reference'],
block_ref: ['方块引用', 'Block reference'], item_ref: ['物品引用', 'Item reference'], record: ['记录结构', 'Record'],
array: ['数组', 'Array'], set: ['集合', 'Set'], optional: ['可空值', 'Optional'], dictionary: ['字典', 'Dictionary'], list: ['列表', 'List'],
};
const triggerTypeDescriptions: Record<string, [string, string]> = {
player_ref: ['仅持久化玩家 UUID;使用前会重新验证玩家。', 'Persists only a player UUID and revalidates it before use.'],
entity_ref: ['仅持久化实体 UUID 与维度;实体失效时返回空值。', 'Persists entity UUID and dimension; resolves to empty when invalid.'],
block_ref: ['持久化维度与方块坐标,读取时重新查询方块。', 'Persists dimension and block coordinates and queries the block again on use.'],
item_ref: ['持久化物品资源 ID,不保存 Minecraft/Java 对象。', 'Persists an item resource ID rather than a Minecraft or Java object.'],
region_ref: ['引用已声明的长方体、球体或圆柱区域。', 'References a declared cuboid, sphere, or cylinder region.'],
optional: ['显式表示可能不存在的强类型值。', 'Explicitly represents a typed value that may be absent.'],
record: ['由命名字段组成的声明式结构。', 'A declarative structure made of named fields.'],
};
const functionLabels: Record<string, [string, string]> = {
len: ['获取长度', 'Length'], contains: ['是否包含', 'Contains'], starts_with: ['是否以文本开头', 'Starts with'],
ends_with: ['是否以文本结尾', 'Ends with'], lower: ['转为小写', 'Lowercase'], upper: ['转为大写', 'Uppercase'],
abs: ['绝对值', 'Absolute value'], min: ['最小值', 'Minimum'], max: ['最大值', 'Maximum'], clamp: ['限制数值范围', 'Clamp'],
random_float: ['确定性随机小数', 'Deterministic random number'], random_int: ['确定性随机整数', 'Deterministic random integer'],
instant: ['解析时间点', 'Parse instant'], is_null: ['是否为空', 'Is null'], chance: ['概率判断', 'Chance'],
random_pick: ['随机选择元素', 'Random pick'], distance: ['计算距离', 'Distance'], has_tag: ['是否含标签', 'Has tag'],
};
const functionDescriptions: Record<string, [string, string]> = {
random_float: ['使用当前执行种子生成可回放的小数结果。', 'Uses the execution seed to produce a replayable random number.'],
random_int: ['在给定整数范围内生成可回放的随机结果。', 'Produces a replayable random integer within a range.'],
chance: ['按 0 到 1 的概率值进行可复现判断。', 'Performs a reproducible probability check from 0 to 1.'],
random_pick: ['从集合中按执行种子选择一个元素。', 'Selects one collection element using the execution seed.'],
distance: ['计算两个 position、实体或玩家引用之间的距离。', 'Calculates distance between positions, entity references, or player references.'],
has_tag: ['检查实体、方块或物品是否包含指定标签。', 'Checks whether an entity, block, or item contains a tag.'],
};
const storageLabels: Record<string, [string, string]> = {
server: ['服务器', 'Server'], player: ['每个玩家', 'Per player'], dimension: ['每个维度', 'Per dimension'],
trigger: ['当前触发器', 'Trigger'], execution: ['当前执行实例', 'Execution'], chunk: ['每个区块', 'Per chunk'], entity: ['每个实体', 'Per entity'],
};
const lifetimeLabels: Record<string, [string, string]> = {
SESSION: ['本次运行会话', 'Session'], TTL: ['限时持久化', 'Time to live'], PERSISTENT: ['永久持久化', 'Persistent'],
};
const visibilityLabels: Record<string, [string, string]> = {
trigger: ['仅当前触发器', 'Trigger only'], global: ['所有触发器共享', 'Shared globally'],
};
const templateLabels: Record<string, [string, string]> = {
welcome: ['欢迎消息', 'Welcome message'], 'region-task': ['区域任务', 'Region task'],
'kill-counter': ['击杀计数', 'Kill counter'], 'timed-boss': ['定时 Boss', 'Timed boss'],
'economy-reward': ['经济奖励', 'Economy reward'], 'menu-interaction': ['菜单交互', 'Menu interaction'],
'performance-alert': ['性能告警', 'Performance alert'],
};
const templateDescriptions: Record<string, [string, string]> = {
welcome: ['玩家加入服务器时发送欢迎消息。', 'Greets each player when they join the server.'],
'region-task': ['进入区域后更新每个玩家的持久任务进度。', 'Updates durable per-player task progress on region entry.'],
'kill-counter': ['统计归因于玩家的实体击杀数量。', 'Counts entity kills attributed to each player.'],
'timed-boss': ['按照固定间隔在指定位置生成 Boss。', 'Spawns a boss at a configured position on an interval.'],
'economy-reward': ['玩家完成进度时发放货币奖励。', 'Rewards a player with currency after an advancement.'],
'menu-interaction': ['处理菜单控件事件并打开目标菜单。', 'Handles a menu-control event and opens a target menu.'],
'performance-alert': ['服务器连续慢刻时向全服广播性能告警。', 'Broadcasts a performance alert after sustained slow ticks.'],
};
const executionStatusLabels: Record<string, [string, string]> = {
RUNNING: ['运行中', 'Running'], WAITING: ['等待中', 'Waiting'], COMPLETED: ['已完成', 'Completed'],
FAILED: ['失败', 'Failed'], NEEDS_REVIEW: ['需要人工复核', 'Needs review'], SIMULATED: ['模拟完成', 'Simulated'],
CANCELLED: ['已取消', 'Cancelled'],
};
const traceKindLabels: Record<string, [string, string]> = {
PROGRAM: ['程序', 'Program'], EVENT: ['事件', 'Event'], EXPRESSION: ['表达式', 'Expression'],
CONDITION: ['条件', 'Condition'], ACTION: ['动作', 'Action'], VARIABLE: ['变量写入', 'Variable write'],
WAIT: ['等待', 'Wait'], CALL: ['调用', 'Call'], ERROR: ['错误', 'Error'], FAILURE: ['失败', 'Failure'],
};
function variableOperationLabel(value: string, zh: boolean): string {
return `${label(variableOperationLabels[value], zh, value)} · ${value}`;
}
function waitModeLabel(value: string, zh: boolean): string {
return `${label(waitModeLabels[value], zh, value)} · ${value}`;
}
function actionParameterLabel(value: string, zh: boolean): string {
return label(actionParameterLabels[value], zh, value);
}
function actionParameterDescription(value: string, zh: boolean): string {
return label(actionParameterDescriptions[value], zh,
zh ? `参数技术名称:${value}` : `Technical parameter name: ${value}`);
}
type ConditionValueKind = 'text' | 'number' | 'range' | 'list' | 'regex' | 'none';
type ConditionOperatorCategory = 'comparison' | 'text' | 'number' | 'list' | 'presence' | 'extension';
interface ConditionOperatorMetadata {
labels: [string, string];
category: ConditionOperatorCategory;
valueKind: ConditionValueKind;
}
const conditionOperatorMetadata: Record<string, ConditionOperatorMetadata> = {
eq: { labels: ['等于', 'Equals'], category: 'comparison', valueKind: 'text' },
neq: { labels: ['不等于', 'Does not equal'], category: 'comparison', valueKind: 'text' },
contains: { labels: ['包含指定文本', 'Contains the specified text'], category: 'text', valueKind: 'text' },
not_contains: { labels: ['不包含指定文本', 'Does not contain the specified text'], category: 'text', valueKind: 'text' },
starts_with: { labels: ['以指定文本开头', 'Starts with the specified text'], category: 'text', valueKind: 'text' },
not_starts_with: { labels: ['不以指定文本开头', 'Does not start with the specified text'], category: 'text', valueKind: 'text' },
ends_with: { labels: ['以指定文本结尾', 'Ends with the specified text'], category: 'text', valueKind: 'text' },
not_ends_with: { labels: ['不以指定文本结尾', 'Does not end with the specified text'], category: 'text', valueKind: 'text' },
matches: { labels: ['匹配正则表达式', 'Matches a regular expression'], category: 'text', valueKind: 'regex' },
not_matches: { labels: ['不匹配正则表达式', 'Does not match a regular expression'], category: 'text', valueKind: 'regex' },
gt: { labels: ['大于', 'Is greater than'], category: 'number', valueKind: 'number' },
gte: { labels: ['大于或等于', 'Is greater than or equal to'], category: 'number', valueKind: 'number' },
lt: { labels: ['小于', 'Is less than'], category: 'number', valueKind: 'number' },
lte: { labels: ['小于或等于', 'Is less than or equal to'], category: 'number', valueKind: 'number' },
between: { labels: ['介于两个数值之间(含边界)', 'Is between two numbers (inclusive)'], category: 'number', valueKind: 'range' },
not_between: { labels: ['不在两个数值之间(含边界)', 'Is not between two numbers (inclusive)'], category: 'number', valueKind: 'range' },
in: { labels: ['属于列表中的任一值', 'Is one of the listed values'], category: 'list', valueKind: 'list' },
not_in: { labels: ['不属于列表中的任一值', 'Is not one of the listed values'], category: 'list', valueKind: 'list' },
exists: { labels: ['字段存在', 'Field exists'], category: 'presence', valueKind: 'none' },
not_exists: { labels: ['字段不存在', 'Field does not exist'], category: 'presence', valueKind: 'none' },
empty: { labels: ['字段为空', 'Field is empty'], category: 'presence', valueKind: 'none' },
not_empty: { labels: ['字段不为空', 'Field is not empty'], category: 'presence', valueKind: 'none' },
true: { labels: ['字段为真', 'Field is true'], category: 'presence', valueKind: 'none' },
false: { labels: ['字段为假', 'Field is false'], category: 'presence', valueKind: 'none' },
};
const conditionOperatorCategories: Array<{ category: ConditionOperatorCategory; labels: [string, string] }> = [
{ category: 'comparison', labels: ['基础比较', 'Basic comparisons'] },
{ category: 'text', labels: ['文本与正则', 'Text and regular expressions'] },
{ category: 'number', labels: ['数值比较', 'Numeric comparisons'] },
{ category: 'list', labels: ['列表判断', 'List membership'] },
{ category: 'presence', labels: ['字段状态', 'Field state'] },
{ category: 'extension', labels: ['扩展判断', 'Extension operators'] },
];
const conditionFieldLabels: Record<string, [string, string]> = {
'player.uuid': ['玩家 UUID', 'Player UUID'], 'player.name': ['玩家名称', 'Player name'],
'player.firstJoin': ['玩家是否首次加入', 'Whether this is the player’s first join'],
'player.dailyDue': ['玩家今日消息是否待发送', 'Whether the player’s daily message is due'],
'player.dimension': ['玩家当前维度', 'Player’s current dimension'],
'player.fromDimension': ['玩家原维度', 'Player’s previous dimension'],
'player.toDimension': ['玩家目标维度', 'Player’s destination dimension'],
'player.x': ['玩家 X 坐标', 'Player X coordinate'], 'player.y': ['玩家 Y 坐标', 'Player Y coordinate'],
'player.z': ['玩家 Z 坐标', 'Player Z coordinate'], 'server.online': ['在线玩家数', 'Online player count'],
'server.maxPlayers': ['最大玩家数', 'Maximum player count'],
'server.maintenanceEnabled': ['维护模式是否启用', 'Whether maintenance mode is enabled'],
'server.maintenanceMessage': ['维护提示消息', 'Maintenance message'],
'server.defaultMessageSender': ['默认消息发送者', 'Default message sender'],
'chat.message': ['聊天消息', 'Chat message'], 'command.value': ['玩家执行的命令', 'Command entered by the player'],
'command.name': ['自定义指令名称', 'Custom command name'],
'command.raw': ['自定义指令原始输入', 'Raw custom command input'],
'advancement.id': ['进度 ID', 'Advancement ID'], 'block.id': ['方块 ID', 'Block ID'],
'block.x': ['方块 X 坐标', 'Block X coordinate'], 'block.y': ['方块 Y 坐标', 'Block Y coordinate'],
'block.z': ['方块 Z 坐标', 'Block Z coordinate'], 'block.previousId': ['变化前方块 ID', 'Previous block ID'],
'item.id': ['物品 ID', 'Item ID'], 'item.count': ['物品数量', 'Item count'],
'item.resultId': ['合成结果物品 ID', 'Crafting result item ID'],
'item.resultCount': ['合成结果数量', 'Crafting result count'],
'interaction.hand': ['交互使用的手', 'Interaction hand'], 'use.duration': ['使用持续时间', 'Use duration'],
'damage.amount': ['伤害数值', 'Damage amount'], 'damage.type': ['伤害类型', 'Damage type'],
'attacker.uuid': ['攻击者 UUID', 'Attacker UUID'], 'attacker.type': ['攻击者类型', 'Attacker type'],
'attacker.name': ['攻击者名称', 'Attacker name'], 'heal.amount': ['治疗数值', 'Healing amount'],
'sleep.x': ['睡眠位置 X 坐标', 'Sleep position X coordinate'],
'sleep.y': ['睡眠位置 Y 坐标', 'Sleep position Y coordinate'],
'sleep.z': ['睡眠位置 Z 坐标', 'Sleep position Z coordinate'], 'sleep.result': ['睡眠结果', 'Sleep result'],
'tool.action': ['工具操作', 'Tool action'], 'entity.uuid': ['实体 UUID', 'Entity UUID'],
'entity.type': ['实体类型', 'Entity type'], 'entity.x': ['实体 X 坐标', 'Entity X coordinate'],
'entity.y': ['实体 Y 坐标', 'Entity Y coordinate'], 'entity.z': ['实体 Z 坐标', 'Entity Z coordinate'],
'world.dimension': ['世界维度', 'World dimension'], 'chunk.x': ['区块 X 坐标', 'Chunk X coordinate'],
'chunk.z': ['区块 Z 坐标', 'Chunk Z coordinate'], 'chunk.new': ['是否为新生成区块', 'Whether the chunk is new'],
'explosion.affectedBlocks': ['爆炸影响的方块数', 'Blocks affected by the explosion'],
'weather.raining': ['是否下雨', 'Whether it is raining'],
'weather.thundering': ['是否雷暴', 'Whether it is thundering'],
'event.type': ['事件类型', 'Event type'], 'event.time': ['事件发生时间', 'Event time'],
'economy.primary.code': ['主货币代码', 'Primary currency code'],
'economy.primary.balance': ['事件玩家主货币余额', 'Event player primary balance'],
'economy.transaction.type': ['经济交易类型', 'Economy transaction type'],
'economy.transaction.amount': ['经济交易金额', 'Economy transaction amount'],
'economy.transaction.origin': ['经济交易来源', 'Economy transaction origin'],
'economy.transaction.actor': ['经济交易执行者', 'Economy transaction actor'],
'economy.transaction.correlationId': ['经济交易关联 ID', 'Economy transaction correlation ID'],
'economy.currency.code': ['交易货币代码', 'Transaction currency code'],
'economy.currency.icon': ['交易货币图标', 'Transaction currency icon'],
'economy.currency.fractionDigits': ['交易货币小数位数', 'Currency fraction digits'],
'economy.source.balanceBefore': ['来源账户变更前余额', 'Source balance before'],
'economy.source.balanceAfter': ['来源账户变更后余额', 'Source balance after'],
'economy.target.balanceBefore': ['目标账户变更前余额', 'Target balance before'],
'economy.target.balanceAfter': ['目标账户变更后余额', 'Target balance after'],
'protection.kind': ['防护类型', 'Protection kind'],
'protection.scope': ['统计范围', 'Protection scope'],
'protection.count': ['当前数量/测量值', 'Observed count/value'],
'protection.threshold': ['服务端防护阈值', 'Server protection threshold'],
'protection.excess': ['超出数量', 'Excess amount'],
'protection.removed': ['已清理掉落物数', 'Removed dropped items'],
'protection.action': ['自动防护动作', 'Automatic protection action'],
'protection.reason': ['自动拦截原因', 'Automatic block reason'],
'protection.namespace': ['实体模组命名空间', 'Entity mod namespace'],
'protection.windowTicks': ['统计窗口游戏刻', 'Window ticks'],
'protection.windowSeconds': ['统计窗口秒数', 'Window seconds'],
'protection.consecutive': ['连续慢刻数', 'Consecutive slow ticks'],
'protection.heapUsedBytes': ['堆内存已用字节', 'Heap used bytes'],
'protection.heapMaxBytes': ['堆内存上限字节', 'Heap maximum bytes'],
'protection.commandSource': ['命令方块来源', 'Command-block source'],
date: ['当前日期', 'Current date'],
};
const baseFieldSuggestions = Object.keys(conditionFieldLabels);
const booleanConditionFields = new Set([
'player.firstJoin', 'player.dailyDue', 'server.maintenanceEnabled', 'chunk.new',
'weather.raining', 'weather.thundering',
]);
const numericConditionFields = new Set([
'player.x', 'player.y', 'player.z', 'server.online', 'server.maxPlayers', 'block.x', 'block.y',
'block.z', 'item.count', 'item.resultCount', 'use.duration', 'damage.amount', 'heal.amount',
'sleep.x', 'sleep.y', 'sleep.z', 'entity.x', 'entity.y', 'entity.z', 'chunk.x', 'chunk.z',
'explosion.affectedBlocks',
'economy.primary.balance', 'economy.currencyCount', 'economy.currency.fractionDigits', 'economy.transaction.amount', 'economy.source.balanceBefore', 'economy.source.balanceAfter',
'economy.target.balanceBefore', 'economy.target.balanceAfter',
'protection.count', 'protection.threshold', 'protection.excess', 'protection.removed',
'protection.windowTicks', 'protection.windowSeconds', 'protection.consecutive',
'protection.heapUsedBytes', 'protection.heapMaxBytes',
]);
const playerContextActions = new Set([
'send_player', 'title', 'actionbar', 'sound', 'player_command', 'kick', 'teleport', 'give_item',
'clear_inventory', 'set_gamemode', 'add_effect', 'remove_effects', 'heal', 'feed',
'whitelist_add', 'whitelist_remove', 'ban', 'pardon', 'open_menu', 'close_menu',
'economy_deposit', 'economy_withdraw', 'economy_set_balance', 'economy_transfer',
]);
const onlinePlayerActions = new Set([
'send_player', 'title', 'actionbar', 'sound', 'player_command', 'kick', 'teleport', 'give_item',
'clear_inventory', 'set_gamemode', 'add_effect', 'remove_effects', 'heal', 'feed',
'open_menu', 'close_menu',
]);
const commandActions = new Set(['server_command', 'player_command']);
const CONDITION_ACTION_TYPE = 'condition';
const commandPartPattern = /^[a-z][a-z0-9_-]{0,31}$/;
function blankTrigger(groupId: string): TriggerDefinition {
const now = new Date().toISOString();
const eventNode = newNodeId();
const actionNode = newNodeId();
return {
schemaVersion: 2,
id: '', groupId, name: 'New trigger', description: '', enabled: true, mode: 'visual',
event: { type: 'player.join', configuration: {} }, conditionMode: 'all', conditions: [],
actions: [{ type: 'send_player', parameters: { message: 'Welcome, {player.name}!' } }],
events: [{ nodeId: eventNode, type: 'player.join', configuration: {} }], declarations: [], functions: [],
statements: [{ nodeId: actionNode, kind: 'ACTION', name: 'send_player',
inputs: { message: literalExpression('Welcome, adventurer!', 'string') },
cases: [], statements: [], elseStatements: [] }],
script: '', revision: 0, createdBy: '', createdAt: now, updatedAt: now, migrated: false,
};
}
export function TriggersPage() {
const { api, session } = useServer();
const { locale } = useI18n();
const zh = locale === 'zh-CN';
const l = useCallback((chinese: string, english: string) => zh ? chinese : english, [zh]);
const resource = useApiResource(() => api.triggerWorkspace(), [], ['triggers-changed']);
const catalogResource = useApiResource(() => api.triggerCatalog(), []);
const menuResource = useApiResource(() => api.menuWorkspace(), [], ['menus-changed']);
const economyResource = useApiResource(() => api.economy(), [], ['economy-changed']);
const catalog = catalogResource.data ?? fallbackCatalog;
const writable = hasCapability(session?.actor, 'trigger_write');
const commandCapable = hasCapability(session?.actor, 'console_execute');
const [groupId, setGroupId] = useState('');
const [triggerId, setTriggerId] = useState('');
const [groupDraft, setGroupDraft] = useState<TriggerGroup>();
const [draft, setDraft] = useState<TriggerDefinition>();
const [showGroupSettings, setShowGroupSettings] = useState(false);
const [focused, setFocused] = useState(false);
useEffect(() => { if (!draft) setFocused(false); }, [draft]);
const [groupBaseline, setGroupBaseline] = useState('');
const [draftBaseline, setDraftBaseline] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' | 'warn' }>();
const mutationInFlight = useRef(false);
const knownTriggerIds = useRef<Set<string>>(new Set());
const pendingGroupId = useRef('');
const orphanedGroupId = useRef('');
const groupGeneration = useRef(0);
const selectionGeneration = useRef(0);
const groups = resource.data?.groups ?? [];
const group = groups.find((item) => item.id === groupId);
const selected = group?.triggers.find((item) => item.id === triggerId);
const persistedDraft = group?.triggers.find((item) => item.id === draft?.id);
const commandLocked = Boolean(!commandCapable && persistedDraft && triggerUsesCommandAction(persistedDraft));
const groupDirty = Boolean(groupDraft && (!groupDraft.id
|| (groupBaseline && groupFingerprint(groupDraft) !== groupBaseline)));
const draftDirty = Boolean(draft && (!draft.id || triggerFingerprint(draft) !== draftBaseline));
const clearTriggerSelection = useCallback(() => {
selectionGeneration.current += 1;
setTriggerId('');
setDraft(undefined);
setDraftBaseline('');
}, []);
const confirmDiscard = useCallback((includeGroup: boolean) => {
if (!draftDirty && (!includeGroup || !groupDirty)) return true;
return window.confirm(l(
'当前有尚未保存的修改,确定丢弃并继续?',
'You have unsaved changes. Discard them and continue?'));
}, [draftDirty, groupDirty, l]);
useEffect(() => {
if (pendingGroupId.current && groups.some((item) => item.id === pendingGroupId.current)) {
setGroupId(pendingGroupId.current);
if (draft?.groupId !== pendingGroupId.current) clearTriggerSelection();
if (groupDraft?.id !== pendingGroupId.current) {
setGroupDraft(undefined);
setGroupBaseline('');
}
pendingGroupId.current = '';
return;
}
if (!groupId && groups[0] && !pendingGroupId.current && !groupDraft) setGroupId(groups[0].id);
if (groupId && !groups.some((item) => item.id === groupId)) {
const preserveTrigger = draft?.groupId === groupId && draftDirty;
const preserveGroup = groupDraft?.id === groupId && (groupDirty || preserveTrigger);
if (preserveGroup && groupDraft) {
groupGeneration.current += 1;
orphanedGroupId.current = groupId;
selectionGeneration.current += 1;
setGroupId('');
setTriggerId('');
setGroupDraft({ ...structuredClone(groupDraft), id: '', revision: 0,
triggers: [], migrated: false });
setGroupBaseline('');
if (preserveTrigger && draft) {
setDraft({ ...structuredClone(draft), id: '', groupId: '', revision: 0, migrated: false });
setDraftBaseline('');
} else {
setDraft(undefined);
setDraftBaseline('');
}
setToast({
message: l('远端触发器组已删除;本地修改已保留,可保存为新组。',
'The remote trigger group was deleted; local edits were kept and can be saved as a new group.'),
tone: 'warn',
});
return;
}
setGroupId(groups[0]?.id ?? '');
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
}
}, [clearTriggerSelection, draft, draftDirty, groupDraft, groupDirty, groupId, groups, l]);
useEffect(() => {
if (!resource.data) return;
const next = new Set(resource.data.groups.flatMap((item) => item.triggers.map((trigger) => trigger.id)));
if (orphanedGroupId.current && draft?.groupId === orphanedGroupId.current) {
knownTriggerIds.current = next;
return;
}
if (triggerId && knownTriggerIds.current.has(triggerId) && !next.has(triggerId)) {
if (draft?.id === triggerId && draftDirty) {
selectionGeneration.current += 1;
setTriggerId('');
setDraft({ ...structuredClone(draft), id: '', revision: 0, migrated: false });
setDraftBaseline('');
setToast({
message: l('远端触发器已删除;本地修改已保留为未保存副本。',
'The remote trigger was deleted; your local edits were kept as an unsaved copy.'),
tone: 'warn',
});
} else {
clearTriggerSelection();
}
}
knownTriggerIds.current = next;
}, [clearTriggerSelection, draft, draftDirty, l, resource.data, triggerId]);
useEffect(() => {
if (!group) {
if (groupDraft && (groupDraft.id === ''
|| groupDraft.id === orphanedGroupId.current
|| groupDraft.id === pendingGroupId.current
|| groups.some((item) => item.id === groupDraft.id))) return;
setGroupDraft(undefined);
setGroupBaseline('');
return;
}
// A child mutation increments its parent group's revision. If the editable group fields did
// not change remotely, retain the local edit while absorbing the new CAS revision/metadata.
if (groupDraft?.id === group.id && groupDirty) {
if (groupFingerprint(group) === groupBaseline) {
setGroupDraft((current) => current?.id === group.id ? {
...structuredClone(group),
name: current.name,
description: current.description,
enabled: current.enabled,
} : current);
}
return;
}
setGroupDraft(structuredClone(group));
setGroupBaseline(groupFingerprint(group));
// groupDraft/groupDirty intentionally describe the state at the remote revision boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group?.id, group?.revision, groups]);
useEffect(() => {
if (!selected) return;
// Refresh an unchanged editor from SSE, but never overwrite an optimistic local edit.
if (draft?.id === selected.id && draftDirty) return;
setDraft(structuredClone(selected));
setDraftBaseline(triggerFingerprint(selected));
// draft/draftDirty intentionally describe the local state at this remote revision boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.id, selected?.revision]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!draftDirty && !groupDirty) return;
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, [draftDirty, groupDirty]);
const act = useCallback(async <T,>(action: () => Promise<T>, success: string,
reload = true): Promise<T | undefined> => {
if (mutationInFlight.current) return undefined;
mutationInFlight.current = true;
setBusy(true); setToast(undefined);
try {
const result = await action();
setToast({ message: success, tone: 'good' });
if (reload) resource.reload();
return result;
}
catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
if (reload) resource.reload();
}
finally { mutationInFlight.current = false; setBusy(false); }
}, [resource]);
const createGroup = () => {
if (!confirmDiscard(true)) return;
void act(async () => {
const before = new Set(groups.map((item) => item.id));
const workspace = await api.createTriggerGroup({ name: l('新触发器组', 'New trigger group'), description: '', enabled: true });
pendingGroupId.current = workspace.groups.find((item) => !before.has(item.id))?.id ?? '';
setShowGroupSettings(true);
}, l('触发器组已创建', 'Trigger group created'));
};
const saveGroup = () => groupDraft && void act(async () => {
const generation = groupGeneration.current;
const creatingCopy = !groupDraft.id;
const existingIds = new Set(groups.map((item) => item.id));
const workspace = creatingCopy
? await api.createTriggerGroup({ name: groupDraft.name, description: groupDraft.description,
enabled: groupDraft.enabled })
: await api.updateTriggerGroup(groupDraft);
const saved = creatingCopy
? workspace.groups.find((item) => !existingIds.has(item.id))
: workspace.groups.find((item) => item.id === groupDraft.id);
if (generation !== groupGeneration.current) return workspace;
if (saved) {
setGroupDraft(structuredClone(saved));
setGroupBaseline(groupFingerprint(saved));
if (creatingCopy) {
orphanedGroupId.current = '';
pendingGroupId.current = saved.id;
if (draft && !draft.groupId) setDraft({ ...draft, groupId: saved.id });
}
}
return workspace;
}, l('触发器组已保存', 'Trigger group saved'));
const deleteGroup = () => groupDraft && window.confirm(l(
'删除触发器组会同时删除组内所有触发器,确定继续?',
'Deleting this group also deletes all triggers in it. Continue?')) && void act(async () => {
await api.deleteTriggerGroup(groupDraft.id, groupDraft.revision);
setGroupId('');
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
}, l('触发器组已删除', 'Trigger group deleted'));
const saveTrigger = useCallback(() => {
if (!draft || !writable || !triggerIsSavable(draft, commandCapable, catalog)) return;
const generation = selectionGeneration.current;
void act(async () => {
// The Java validator remains authoritative for both authoring modes. Visual documents are
// sent as their structured program so no DSL round-trip can alter values. This also protects
// Ctrl/Cmd+S from bypassing the visible button's local checks.
const v2 = triggerV2Program(draft);
const validation = v2
? await api.validateTriggerV2(v2)
: draft.mode === 'code'
? await api.validateTriggerScript(draft.script)
: await api.validateVisualTrigger({
event: draft.event,
conditionMode: draft.conditionMode,
conditions: draft.conditions,
actions: draft.actions,
});
if (!validation.valid) throw new Error(l('触发器预检未通过', 'Trigger validation failed'));
const result = draft.id ? await api.updateTrigger(draft) : await api.createTrigger({
groupId: draft.groupId, name: draft.name, description: draft.description, enabled: draft.enabled,
mode: draft.mode, event: draft.event, conditionMode: draft.conditionMode,
conditions: draft.conditions, actions: draft.actions, script: draft.script,
schemaVersion: draft.schemaVersion, events: draft.events, declarations: draft.declarations,
functions: draft.functions, statements: draft.statements,
});
if (generation !== selectionGeneration.current) return result;
setTriggerId(result.trigger.id);
setDraft(result.trigger);
setDraftBaseline(triggerFingerprint(result.trigger));
}, draft.enabled ? l('触发器已保存并立即生效', 'Trigger saved and activated')
: l('触发器已保存(当前停用)', 'Trigger saved (disabled)'));
}, [act, api, catalog, commandCapable, draft, l, writable]);
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's' || !draft || !writable) return;
event.preventDefault();
saveTrigger();
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [draft, saveTrigger, writable]);
const deleteTrigger = () => draft?.id && window.confirm(l('确定删除这个触发器?', 'Delete this trigger?'))
&& void act(async () => {
await api.deleteTrigger(draft.id, draft.revision);
clearTriggerSelection();
}, l('触发器已删除', 'Trigger deleted'));
const duplicate = () => {
if (!draft || mutationInFlight.current) return;
selectionGeneration.current += 1;
setTriggerId('');
setDraftBaseline('');
setDraft({ ...structuredClone(draft), id: '', revision: 0,
name: `${draft.name} ${l('副本', 'copy')}`, migrated: false });
};
const selectGroup = (id: string) => {
if (mutationInFlight.current || id === group?.id || !confirmDiscard(true)) return;
setGroupId(id);
setShowGroupSettings(false);
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
};
const selectTrigger = (trigger: TriggerDefinition) => {
if (mutationInFlight.current || trigger.id === triggerId || !confirmDiscard(false)) return;
selectionGeneration.current += 1;
setTriggerId(trigger.id);
setDraft(structuredClone(trigger));
setDraftBaseline(triggerFingerprint(trigger));
};
const createTrigger = () => {
if (!group || mutationInFlight.current || !confirmDiscard(false)) return;
selectionGeneration.current += 1;
setTriggerId('');
setDraftBaseline('');
setDraft(blankTrigger(group.id));
};
const compileDraft = useCallback(async (script: string) => {
const generation = selectionGeneration.current;
const result = await act(() => api.validateTriggerScript(script),
l('脚本校验通过', 'Script is valid'), false);
return generation === selectionGeneration.current ? result : undefined;
}, [act, api, l]);
return <div className={`page trigger-page${focused ? ' trigger-page--focused' : ''}`}>
<PageHeader eyebrow="AUTOMATION" title={l('触发器', 'Triggers')}
description={l('用模块化流程或 XFE Script 编排服务器事件、条件与操作。',
'Automate server events, conditions, and actions with visual blocks or XFE Script.')}
actions={<><Badge tone="info">{resource.data?.totalTriggers ?? 0} {l('个触发器', 'triggers')}</Badge>
<Button variant="secondary" disabled={!draft} aria-pressed={focused} onClick={() => setFocused((current) => !current)}
title={focused ? l('恢复导航与预设库', 'Restore navigation and presets')
: l('收起导航与预设库,让程序占满工作区', 'Hide navigation and presets to expand the program workspace')}>
{focused ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
{focused ? l('退出专注', 'Exit focus') : l('专注编辑', 'Focus editor')}</Button>
<Button onClick={createGroup} disabled={!writable || busy}><FolderPlus size={15} />{l('新建组', 'New group')}</Button></>} />
{!writable && <div className="confirm-box"><Workflow /><div><strong>{l('当前为只读模式', 'Read-only mode')}</strong>
<p>{l('管理员或 Owner 可以编辑触发器。', 'Administrators and Owners can edit triggers.')}</p></div></div>}
<ResourceState loading={resource.loading} error={resource.error} onRetry={resource.reload}>
<div className="trigger-layout">
<aside className="trigger-sidebar">
<div className="trigger-sidebar__heading"><span>{l('触发器组', 'Trigger groups')}</span><Badge>{groups.length}</Badge></div>
{groups.map((item) => <button key={item.id} type="button"
disabled={busy}
aria-pressed={item.id === group?.id}
className={`trigger-group-item ${item.id === group?.id ? 'is-active' : ''}`}
onClick={() => selectGroup(item.id)}>
<span aria-hidden="true" className={`status-dot ${item.enabled ? 'is-on' : ''}`} />
<span><strong>{item.name}</strong><small>{item.triggers.length} {l('个触发器', 'triggers')}{item.migrated ? ` · ${l('已迁移', 'migrated')}` : ''}</small></span>
<ChevronRight size={14} />
</button>)}
{groups.length === 0 && <div className="trigger-empty">{l('创建第一个组以开始编排。', 'Create your first group to begin.')}</div>}
{group && <><div className="trigger-sidebar__subheading"><span>{l('组内触发器', 'Triggers in group')}</span>
<div className="button-row"><Button type="button" variant="ghost" disabled={!groupDraft}
aria-pressed={showGroupSettings} onClick={() => setShowGroupSettings((current) => !current)}>
{l('组设置', 'Group settings')}</Button>
<Button type="button" variant="ghost" disabled={!writable || busy} onClick={createTrigger}
aria-label={l('新建触发器', 'New trigger')}><Plus size={13} /></Button></div></div>
<div className="trigger-list trigger-sidebar__trigger-list">
{group.triggers.map((item) => <button type="button" key={item.id}
disabled={busy} aria-pressed={item.id === triggerId}
className={item.id === triggerId ? 'is-active' : ''} onClick={() => selectTrigger(item)}>
<span aria-hidden="true" className={`status-dot ${item.enabled ? 'is-on' : ''}`} />
<span><strong>{item.name}</strong><small>{eventLabel(item.event.type, zh)} · {item.mode === 'code' ? 'CODE' : 'UI'}</small></span>
<ChevronRight size={13} />
</button>)}
{!group.triggers.length && <div className="trigger-empty">{l('此组中还没有触发器。', 'No triggers in this group yet.')}</div>}
</div></>}
</aside>
<div className="trigger-content">
{groupDraft && showGroupSettings && <Panel className="trigger-group-panel" title={<><Workflow size={17} />{l('组设置', 'Group settings')}</>}
action={<div className="button-row"><Button variant="ghost" onClick={deleteGroup} disabled={!writable || busy || !groupDraft.id}
aria-label={l('删除触发器组', 'Delete trigger group')} title={l('删除触发器组', 'Delete trigger group')}><Trash2 size={14} /></Button>
<Button variant="secondary" onClick={saveGroup} disabled={!writable || busy}><Save size={14} />{l('保存组', 'Save group')}</Button></div>}>
<fieldset className="trigger-fieldset form-grid form-grid--three" disabled={!writable || busy}>
<Field label={l('名称', 'Name')}><Input value={groupDraft.name} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></Field>
<Field label={l('说明', 'Description')}><Input value={groupDraft.description} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></Field>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={groupDraft.enabled}
onChange={(event) => setGroupDraft({ ...groupDraft, enabled: event.target.checked })} /><span><strong>{groupDraft.enabled ? l('组已启用', 'Group enabled') : l('组已停用', 'Group disabled')}</strong></span></label>
</fieldset>
</Panel>}
{group && <div className="trigger-workspace">
<div className="trigger-editor-wrap">
{draft ? <TriggerEditor value={draft} catalog={catalog} writable={writable && !busy}
defaultSenderName={catalog.defaultMessageSender}
globalVariables={groups.flatMap((entry) => entry.triggers.flatMap((trigger) =>
(trigger.event.variables ?? []).filter((variable) => variable.visibility === 'global')))}
triggerChoices={groups.flatMap((entry) => entry.triggers.map((trigger) => ({ id: trigger.id, name: trigger.name })))}
menuChoices={(menuResource.data?.menus ?? []).map((menu) => ({ id: menu.id, name: menu.name }))}
canExecuteCommands={commandCapable} commandLocked={commandLocked}
zh={zh} onChange={setDraft} onSave={saveTrigger} onDelete={deleteTrigger} onDuplicate={duplicate}
onCompile={compileDraft} />
: <Panel className="trigger-editor-empty"><div className="trigger-empty trigger-empty--large"><CirclePlay />
<strong>{l('选择或创建触发器', 'Select or create a trigger')}</strong>
<span>{l('编辑器会显示在这里。', 'The editor will appear here.')}</span></div></Panel>}
</div>
</div>}
</div>
</div>
</ResourceState>
<TriggerOperationsPanel catalog={catalog} selectedTrigger={draft?.id ? draft : undefined}
writable={writable} owner={commandCapable} zh={zh} />
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
<datalist id="trigger-economy-currencies">{economyResource.data?.currencies.map((currency) =>
<option key={currency.id} value={currency.code} label={`${currency.symbol} ${currency.name}`} />)}</datalist>
</div>;
}
function TriggerOperationsPanel({ catalog, selectedTrigger, writable, owner, zh }: { catalog: TriggerCatalog;
selectedTrigger?: TriggerDefinition; writable: boolean; owner: boolean; zh: boolean }) {
const { api } = useServer();
const l = (chinese: string, english: string) => zh ? chinese : english;
const [tab, setTab] = useState<'history' | 'capture' | 'libraries'>('history');
const [executions, setExecutions] = useState<TriggerExecution[]>([]);
const [execution, setExecution] = useState<TriggerExecution>();
const [captures, setCaptures] = useState<TriggerEventCapture[]>([]);
const [samples, setSamples] = useState<TriggerEventSample[]>([]);
const [captureEvent, setCaptureEvent] = useState('player.join');
const [libraries, setLibraries] = useState<TriggerLibraryInstallation[]>([]);
const [libraryDraft, setLibraryDraft] = useState('');
const [operationMessage, setOperationMessage] = useState('');
const loadExecutions = async () => setExecutions((await api.triggerExecutions(100)).items);
const loadCaptures = async () => setCaptures((await api.triggerEventCaptures(100)).items);
const loadLibraries = async () => setLibraries((await api.triggerLibraries()).items);
useEffect(() => { void loadExecutions().catch(() => undefined); }, [api]);
useEffect(() => { if (tab === 'capture') void loadCaptures().catch(() => undefined); }, [api, tab]);
useEffect(() => { if (tab === 'libraries') void loadLibraries().catch(() => undefined); }, [api, tab]);
const run = async (work: () => Promise<unknown>, success: string) => {
setOperationMessage('');
try { await work(); setOperationMessage(success); }
catch (failure) { setOperationMessage(failure instanceof Error ? failure.message : String(failure)); }
};
const parseLibrary = (): TriggerLibraryPackage => JSON.parse(libraryDraft) as TriggerLibraryPackage;
const exportLibrary = async (item: TriggerLibraryInstallation) => {
await run(async () => {
const data = await api.exportTriggerLibrary(item.namespace, item.version);
const json = JSON.stringify(data, null, 2);
setLibraryDraft(json);
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url; link.download = `${item.namespace}-${item.version}.xfelib`; link.click();
URL.revokeObjectURL(url);
}, l('库已导出。', 'Library exported.'));
};
return <Panel className="trigger-operations" title={<><Workflow size={16} />{l('运行与扩展中心', 'Runtime & extension center')}</>}
action={<div className="segmented" role="tablist"><button type="button" className={tab === 'history' ? 'is-active' : ''}
onClick={() => setTab('history')}>{l('执行历史', 'History')}</button><button type="button"
className={tab === 'capture' ? 'is-active' : ''} onClick={() => setTab('capture')}>{l('事件采集', 'Capture')}</button>
<button type="button" className={tab === 'libraries' ? 'is-active' : ''}
onClick={() => setTab('libraries')}>.xfelib</button></div>}>
{operationMessage && <div className="confirm-box"><Workflow /><div><strong>{operationMessage}</strong></div></div>}
{tab === 'history' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<div className="button-row"><Button type="button" variant="secondary" onClick={() => void loadExecutions()}>
{l('刷新', 'Refresh')}</Button><span>{l('摘要保留 7 天 / 50,000 条', 'Summaries retained 7 days / 50,000')}</span></div>
{executions.map((item) => <button type="button" key={item.executionId}
className={execution?.executionId === item.executionId ? 'is-active' : ''}
onClick={() => void api.triggerExecution(item.executionId).then(setExecution)}><Badge
tone={item.status === 'COMPLETED' || item.status === 'SIMULATED' ? 'good'
: item.status === 'NEEDS_REVIEW' || item.status === 'FAILED' ? 'danger' : 'warn'}>
{executionStatusLabel(item.status, zh)}</Badge>
<span><strong>{eventLabel(item.eventType, zh)}</strong><small>{item.eventType} · {new Date(item.startedAt).toLocaleString()}</small></span>
<code>{item.executionId.slice(0, 8)}</code></button>)}</div>
<div className="trigger-runtime-detail">{execution ? <><div className="galaxy-pane__heading"><strong>
{eventLabel(execution.eventType, zh)}</strong><Badge>{executionStatusLabel(execution.status, zh)}</Badge></div>
{execution.error && <div className="form-error">{execution.error}</div>}
<JsonValueEditor label={l('执行摘要', 'Execution summary')} value={execution.summary} onCommit={() => undefined} />
<div className="trigger-block-list">{execution.steps.map((step) => <div className="trigger-trace-row" key={step.sequence}>
<code>#{step.sequence}</code><strong>{traceKindLabel(step.kind, zh)}</strong><span>{step.nodeId?.slice(0, 8) ?? 'program'}</span>
<small>{step.instructions} {l('条指令', 'instructions')}</small></div>)}</div></>
: <div className="trigger-empty">{l('选择记录查看逐节点追踪。', 'Select an execution for node-level trace.')}</div>}</div></div>}
{tab === 'capture' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<div className="form-grid"><Field label={l('事件类型', 'Event type')}><Select value={captureEvent}
onChange={(event) => setCaptureEvent(event.target.value)}>{catalog.events.map((entry) =>
<option key={entry} value={entry}>{eventLabel(entry, zh)}</option>)}</Select></Field>
<Button type="button" disabled={!writable} onClick={() => void run(async () => {
await api.startTriggerEventCapture({ eventTypes: [captureEvent], durationSeconds: 300, maximumSamples: 100 });
await loadCaptures();
}, l('已开始 5 分钟脱敏采集。', 'Started a 5-minute redacted capture.'))}>{l('开始采集', 'Start capture')}</Button></div>
{captures.map((item) => <button type="button" key={item.captureId} onClick={() => void api
.triggerEventSamples(item.captureId).then((result) => setSamples(result.items))}><Badge
tone={new Date(item.expiresAt).getTime() > Date.now() ? 'good' : 'neutral'}>{item.sampleCount}/{item.maximumSamples}</Badge>
<span><strong>{item.eventTypes.map((entry) => eventLabel(entry, zh)).join(zh ? '、' : ', ')}</strong>
<small>{item.eventTypes.join(', ')} · {new Date(item.expiresAt).toLocaleString()}</small></span></button>)}</div>
<div className="trigger-runtime-detail"><p className="field__hint">{l(
'采集默认关闭;样本已脱敏,回放只进入模拟器,不执行真实副作用。',
'Capture is opt-in and redacted; replay only simulates and never performs real side effects.')}</p>
{samples.map((sample) => <div className="trigger-sample" key={sample.sampleId}><div><strong>{eventLabel(sample.eventType, zh)}</strong>
<small>{sample.eventType} · {new Date(sample.capturedAt).toLocaleString()}</small></div><Button type="button" variant="secondary"
disabled={!selectedTrigger} onClick={() => selectedTrigger && void run(async () => {
await api.simulateTriggerEventSample(sample.sampleId, selectedTrigger.id); await loadExecutions();
}, l('样本模拟已写入执行历史。', 'Sample simulation added to execution history.'))}>{l('用所选触发器模拟', 'Simulate selected')}</Button></div>)}</div></div>}
{tab === 'libraries' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<p className="field__hint">{l('声明式库不允许携带字节码或脚本入口;安装前检查依赖 DAG、版本和风险权限。',
'Declarative libraries cannot carry bytecode or script entry points; dependency DAG, versions, and risk are checked before install.')}</p>
{libraries.map((item) => <div className="trigger-library-row" key={`${item.namespace}:${item.version}`}>
<span><strong>{item.manifest.displayName}</strong><small>{item.namespace}@{item.version}</small></span>
<Badge tone={item.active ? 'good' : 'neutral'}>{item.active ? l('已启用', 'Active') : l('未启用', 'Inactive')}</Badge>
<Button type="button" variant="ghost" onClick={() => void exportLibrary(item)}>{l('导出', 'Export')}</Button>
{!item.active && <Button type="button" variant="ghost" disabled={!owner} onClick={() => void run(async () => {
await api.rollbackTriggerLibrary(item.namespace, item.version); await loadLibraries();
}, l('库版本已回滚。', 'Library version rolled back.'))}>{l('回滚', 'Rollback')}</Button>}</div>)}</div>
<div className="trigger-runtime-detail"><Field label={l('.xfelib JSON', '.xfelib JSON')}><Textarea value={libraryDraft}
onChange={(event) => setLibraryDraft(event.target.value)} /></Field><input type="file" accept=".xfelib,.json,application/json"
onChange={(event) => { const file = event.target.files?.[0]; if (file) void file.text().then(setLibraryDraft); }} />
<div className="button-row"><Button type="button" variant="secondary" disabled={!libraryDraft}
onClick={() => void run(async () => {
const result = await api.validateTriggerLibrary(parseLibrary());
if (!result.valid) throw new Error(result.missingDependencies.join(', '));
}, l('库校验通过。', 'Library validation passed.'))}>{l('校验', 'Validate')}</Button>
<Button type="button" disabled={!owner || !libraryDraft} onClick={() => void run(async () => {
await api.importTriggerLibrary(parseLibrary(), libraries.some((entry) =>
entry.namespace === parseLibrary().manifest.namespace)); await loadLibraries();
}, l('库已安装。', 'Library installed.'))}>{l('导入/升级', 'Import / upgrade')}</Button></div></div></div>}
</Panel>;
}
function TriggerEditor({ value, catalog, writable, canExecuteCommands, commandLocked, defaultSenderName, globalVariables, triggerChoices, menuChoices, zh,
onChange, onSave, onDelete, onDuplicate, onCompile }: {
value: TriggerDefinition; catalog: TriggerCatalog; writable: boolean; canExecuteCommands: boolean;
commandLocked: boolean; defaultSenderName: string; triggerChoices: Array<{ id: string; name: string }>; zh: boolean;
globalVariables: TriggerStateVariableDefinition[];
menuChoices: Array<{ id: string; name: string }>;
onChange: (value: TriggerDefinition) => void; onSave: () => void; onDelete: () => void;
onDuplicate: () => void; onCompile: (script: string) => Promise<TriggerValidation | undefined>;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const { api } = useServer();
const latestScript = useRef(value.script);
latestScript.current = value.script;
const [validatedScript, setValidatedScript] = useState(
value.mode === 'code' && value.id ? value.script : '');
useEffect(() => {
setValidatedScript(value.mode === 'code' && value.id ? value.script : '');
}, [value.id, value.revision]);
const [liveVariableValues, setLiveVariableValues] = useState<Record<string, unknown>>({});
const [simulation, setSimulation] = useState<TriggerSimulationResponse>();
const [simulating, setSimulating] = useState(false);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof globalThis.setInterval> | undefined;
const load = async () => {
if (!value.id || !(value.event.variables?.length)) {
if (active) setLiveVariableValues({});
return;
}
try {
const result = await api.triggerVariableValues(value.id);
if (active) setLiveVariableValues(result.values);
} catch {
// A newly created/disabled trigger may not have a runtime value until it is saved.
}
};
void load();
if (value.id && value.event.variables?.length) timer = globalThis.setInterval(() => void load(), 1_000);
return () => { active = false; if (timer !== undefined) globalThis.clearInterval(timer); };
}, [api, value.id, value.revision, value.event.variables?.length]);
const updateEvent = (event: TriggerEventSpec) => onChange({ ...value, event });
const validateCode = async (script: string) => {
const compiled = await onCompile(script);
if (compiled?.valid && latestScript.current === script) setValidatedScript(script);
return latestScript.current === script ? compiled : undefined;
};
const switchMode = async (mode: TriggerDefinition['mode']) => {
if (mode === value.mode) return;
if (mode === 'code') {
if (triggerV2Program(value) && !window.confirm(l(
'XFE Script v1 无法表达函数、循环和多事件;继续会切换为兼容脚本副本。',
'XFE Script v1 cannot represent functions, loops, or multiple events. Continue with a compatibility script copy?'))) return;
setValidatedScript('');
onChange({ ...value, schemaVersion: undefined, events: undefined, declarations: undefined,
functions: undefined, statements: undefined, mode, script: toScript(value) });
return;
}
const compiled = await validateCode(value.script);
if (compiled?.valid) onChange({ ...value, mode, event: compiled.event,
conditionMode: compiled.conditionMode, conditions: compiled.conditions, actions: compiled.actions });
};
const leafActions = actionLeaves(value.actions);
const incompatible = value.mode === 'visual' && leafActions.some((action) =>
!actionCompatibleWithEvent(action.type, value.event.type));
const leaveIncompatible = value.event.type === 'player.leave' && leafActions.some((action) =>
onlinePlayerActions.has(action.type.trim().toLowerCase()));
const commandRestricted = !canExecuteCommands && triggerUsesCommandAction(value);
const editorWritable = writable && !commandLocked;
const incomplete = !triggerIsSavable(value, canExecuteCommands, catalog)
|| (value.mode === 'code' && validatedScript !== value.script);
const customEvent = value.event.type === 'custom' || value.event.type.startsWith('custom.');
const customEventSuffix = value.event.type === 'custom' ? ''
: customEvent ? value.event.type.slice('custom.'.length) : '';
const customEventInvalid = Boolean(customEventSuffix && !/^[a-z0-9_.-]+$/.test(customEventSuffix));
const eventTypes = catalog.events.includes('custom') ? catalog.events : [...catalog.events, 'custom'];
const selectableEvents = !customEvent && !eventTypes.includes(value.event.type)
? [value.event.type, ...eventTypes] : eventTypes;
const commandArguments = [...new Set(commandArgumentNames(value.event)
.filter((argument) => commandPartPattern.test(argument)))];
const stateVariables = (value.event.variables ?? []).filter((variable) => triggerVariableNameIsValid(variable.name));
const localVariables = stateVariables.filter((variable) => (variable.visibility ?? 'trigger') === 'trigger');
const availableGlobals = [...new Map([...globalVariables,
...stateVariables.filter((variable) => variable.visibility === 'global')]
.filter((variable) => triggerVariableNameIsValid(variable.name))
.map((variable) => [variable.name, variable])).values()];
const actionVariables = [...commandArguments.map((argument) => `{args.${argument}}`),
...localVariables.map((variable) => `{var.${variable.name}}`),
...availableGlobals.map((variable) => `{global.${variable.name}}`)];
const variableCatalog: TriggerVariableDefinition[] = [
...(catalog.variables ?? []).filter((variable) => variable.templateAllowed !== false && variable.sensitive !== true),
...localVariables.map((variable) => ({ key: `var.${variable.name}`,
nameZh: `触发器变量 ${variable.name}`, nameEn: `Trigger variable ${variable.name}`,
descriptionZh: `当前 ${variable.type} 类型值;存储位置:${variable.storage ?? 'trigger'}。`,
descriptionEn: `Current ${variable.type} trigger value; storage: ${variable.storage ?? 'trigger'}.`,
category: 'trigger', scopes: ['global'], type: variable.type, templateAllowed: true,
conditionAllowed: true, sampleValue: variable.initialValue })),
...availableGlobals.map((variable) => ({ key: `global.${variable.name}`,
nameZh: `全局变量 ${variable.name}`, nameEn: `Global variable ${variable.name}`,
descriptionZh: `可被所有触发器读写的 ${variable.type} 类型值;存储位置:${variable.storage ?? 'server'}。`,
descriptionEn: `Mutable ${variable.type} value shared by all triggers; storage: ${variable.storage ?? 'server'}.`,
category: 'trigger', scopes: ['global'], type: variable.type, templateAllowed: true,
conditionAllowed: true, sampleValue: variable.initialValue })),
];
const suggestedFields = [...new Set([...eventFieldSuggestions(value.event, catalog),
...availableGlobals.map((variable) => `global.${variable.name}`)])];
const actionNodeCount = countActionNodes(value.actions);
const v2Program = triggerV2Program(value);
const simulate = async () => {
if (!value.id || simulating) return;
setSimulating(true);
try {
setSimulation(await api.simulateTrigger({ triggerId: value.id,
event: sampleSimulationContext(value.event, catalog), variables: liveVariableValues, seed: 0 }));
} finally { setSimulating(false); }
};
return <Panel className="trigger-editor" title={<><GripVertical size={16} />{value.id ? l('编辑触发器', 'Edit trigger') : l('新触发器', 'New trigger')}</>}
action={<div className="button-row"><Button variant="ghost" onClick={onDuplicate} disabled={!editorWritable}
aria-label={l('复制触发器', 'Duplicate trigger')} title={l('复制触发器', 'Duplicate trigger')}><CopyPlus size={14} /></Button>
{value.id && <Button variant="ghost" onClick={onDelete} disabled={!editorWritable}
aria-label={l('删除触发器', 'Delete trigger')} title={l('删除触发器', 'Delete trigger')}><Trash2 size={14} /></Button>}
{value.id && <Button type="button" variant="secondary" onClick={() => void simulate()} disabled={simulating}
title={l('使用脱敏样例上下文模拟已保存版本', 'Simulate the saved revision with sample context')}>
<CirclePlay size={14} />{simulating ? l('模拟中', 'Simulating') : l('模拟', 'Simulate')}</Button>}
<Button onClick={onSave} disabled={!editorWritable || incomplete}><Save size={14} />{l('保存', 'Save')}</Button>
<span className="keyboard-hint" aria-label={l('快捷保存:Control 或 Command 加 S', 'Quick save: Control or Command plus S')}
title={l('快捷保存:Control 或 Command 加 S', 'Quick save: Control or Command plus S')}>
<span>{l('快捷保存', 'Quick save')}</span><kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>S</kbd>
</span></div>}>
{commandRestricted && <div className="form-error" role="alert">{commandLocked
? l('此触发器包含命令操作,仅 Owner 可编辑或保存。当前以只读方式展示。',
'This trigger contains command actions. Only an Owner can edit or save it; it is shown read-only.')
: l('命令操作需要 Owner 权限;请移除后再保存。',
'Command actions require Owner permission. Remove them before saving.')}</div>}
<fieldset className="trigger-fieldset trigger-editor__fields" disabled={!editorWritable}>
<div className="form-grid">
<Field label={l('触发器名称', 'Trigger name')}><Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('说明', 'Description')}><Input value={value.description} onChange={(event) => onChange({ ...value, description: event.target.value })} /></Field>
</div>
<div className="trigger-editor-toolbar">
<div className="segmented" role="group" aria-label={l('编辑模式', 'Editing mode')}><button type="button"
aria-pressed={value.mode === 'visual'} className={value.mode === 'visual' ? 'is-active' : ''}
onClick={() => void switchMode('visual')}><Workflow size={14} />{l('模块化 UI', 'Visual')}</button>
<button type="button" aria-pressed={value.mode === 'code'} className={value.mode === 'code' ? 'is-active' : ''}
onClick={() => void switchMode('code')}><Code2 size={14} />XFE Script</button></div>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={value.enabled}
onChange={(event) => onChange({ ...value, enabled: event.target.checked })} /><span><strong>{value.enabled ? l('已启用', 'Enabled') : l('已停用', 'Disabled')}</strong></span></label>
</div>
{value.mode === 'code' ? <div className="trigger-code-editor">
<div className="trigger-step-heading"><Braces size={16} /><span><strong>XFE Script</strong><small>{l('安全、可审计的触发器 DSL', 'Safe, auditable trigger DSL')}</small></span>
<Button variant="secondary" onClick={() => void validateCode(value.script)}>{l('校验', 'Validate')}</Button></div>
<Textarea aria-label="XFE Script" spellCheck={false} value={value.script} onChange={(event) => onChange({ ...value, script: event.target.value })} />
<code>{`on player.join
match all
when player.name contains "Steve"
do send_player message="Hello"`}</code>
</div> : v2Program ? <TriggerProgramWorkspace value={value} program={v2Program} catalog={catalog}
writable={editorWritable} owner={canExecuteCommands} zh={zh} onChange={onChange} /> : <div className="trigger-flow">
<section className="trigger-step">
<StepTitle number="01" title={l('触发条件', 'Event')} detail={l('什么时候运行', 'When it runs')}
action={<div className="button-row"><ModuleCopyButton kind="event" value={value.event} zh={zh} name={l('触发条件模块', 'event module')} />
<Button type="button" variant="ghost" title={l('粘贴触发条件模块', 'Paste event module')}
aria-label={l('粘贴触发条件模块', 'Paste event module')} onClick={() => void pasteTriggerModule<TriggerEventSpec>('event')
.then((event) => event && updateEvent(event))}><ClipboardPaste size={14} /></Button></div>} />
<div className="form-grid">
<Field label={l('事件类型', 'Event type')}><Select value={customEvent ? 'custom' : value.event.type}
onChange={(event) => { const next = { ...defaultEvent(event.target.value), variables: value.event.variables ?? [] }; const actions = actionCompatibleWithEvent('send_player', next.type)
? value.actions : mapActionTree(value.actions, (action) => action.type === 'send_player'
? { ...action, type: 'broadcast' } : action); onChange({ ...value, event: next, actions }); }}>
{selectableEvents.map((event) => <option value={event} key={event}>{eventLabel(event, zh)}</option>)}</Select></Field>
{customEvent && <Field label={l('自定义事件 ID', 'Custom event ID')} hint="custom.mod_name.event">
<Input aria-label={l('自定义事件 ID', 'Custom event ID')}
value={customEventSuffix} pattern="[a-z0-9_.-]+" aria-invalid={customEventInvalid}
placeholder="mod_name.event"
onChange={(event) => { const suffix = event.target.value.trim().replace(/^custom\./, ''); updateEvent({ ...value.event, type: suffix ? `custom.${suffix}` : 'custom' }); }} /></Field>}
{value.event.type === 'schedule.daily' && <><Field label={l('每天时间', 'Time')}><Input type="time" value={value.event.configuration.time ?? '08:00'}
onChange={(event) => updateEvent({ ...value.event, configuration: { ...value.event.configuration, time: event.target.value } })} /></Field>
<Field label={l('时区', 'Time zone')}><Input value={value.event.configuration.timezone ?? 'Asia/Shanghai'}
onChange={(event) => updateEvent({ ...value.event, configuration: { ...value.event.configuration, timezone: event.target.value } })} /></Field></>}
{value.event.type === 'schedule.interval' && <IntervalFields key={`${value.id}:${value.revision}`}
totalSeconds={value.event.configuration.seconds ?? '60'} zh={zh}
onChange={(seconds) => updateEvent({ ...value.event, configuration: { seconds } })} />}
{value.event.type.startsWith('protection.') && <ProtectionEventFields
event={value.event} zh={zh} onChange={updateEvent} />}
{value.event.type === 'player.command_trigger' && <CommandTriggerFields key={`${value.id}:${value.revision}`}
event={value.event} catalog={catalog} zh={zh}
onChange={updateEvent} />}
</div>
</section>
<section className="trigger-step trigger-variable-step">
<StepTitle number="V" title={l('触发器变量', 'Trigger variables')}
detail={l('强类型状态;可用于条件、文本、参数和变量操作', 'Strongly typed state for conditions, text, parameters, and variable actions')} />
<TriggerVariablesEditor values={value.event.variables ?? []} liveValues={liveVariableValues}
types={catalog.triggerVariableTypes ?? fallbackCatalog.triggerVariableTypes ?? []} zh={zh}
onChange={(variables) => updateEvent({ ...value.event, variables })} />
</section>
{incompatible && <div className="form-error" role="alert">{leaveIncompatible
? l('玩家离服后无法再执行在线玩家操作,请改用广播、日志或身份列表操作。',
'Online-player actions cannot run after a player leaves. Use broadcast, log, or identity-list actions.')
: l('当前事件没有玩家上下文,请移除需要玩家的操作。',
'This event has no player context. Remove player-only actions.')}</div>}
<section className="trigger-step">
<StepTitle number="02" title={l('条件判断', 'Conditions')} detail={l('可选;可要求全部或任意条件成立', 'Optional; require all or any conditions')} action={<div className="button-row">
<Select aria-label={l('条件匹配模式', 'Condition match mode')} value={value.conditionMode}
onChange={(event) => onChange({ ...value, conditionMode: event.target.value as 'all' | 'any' })}><option value="all">{l('全部满足', 'Match all')}</option><option value="any">{l('任意满足', 'Match any')}</option></Select>
<Button type="button" variant="ghost" title={l('粘贴条件', 'Paste condition')}
aria-label={l('粘贴条件', 'Paste condition')}
onClick={() => void pasteTriggerModule<TriggerCondition>('condition').then((condition) =>
condition && onChange({ ...value, conditions: [...value.conditions, condition] }))}><ClipboardPaste size={14} /></Button>
<Button variant="ghost" onClick={() => onChange({ ...value, conditions: [...value.conditions, { field: 'player.name', operator: 'eq', value: '' }] })}><Plus size={14} />{l('添加条件', 'Add')}</Button></div>} />
<p className="field__hint" id="trigger-condition-help">{l(
'字段来自当前事件的上下文;请选择建议项,也可输入模组提供的扩展字段代码。列表值使用英文逗号分隔,区间填写两个有限数字,正则表达式采用服务端支持的 Java 安全语法;字段状态判断无需填写值。',
'Fields come from the current event context. Choose a suggestion or enter an extension field supplied by a mod. Separate list values with commas, enter two finite numbers for ranges, and use the server-supported safe Java regular-expression syntax. Field-state checks do not need a value.')}</p>
<datalist id="trigger-fields">{suggestedFields.map((field) => <option key={field} value={field}
label={`${conditionFieldLabel(field, zh, catalog)} · ${field}`} />)}</datalist>
<div className="trigger-block-list">{value.conditions.map((condition, index) => <ConditionRow key={index} value={condition} catalog={catalog}
eventType={value.event.type} position={index} count={value.conditions.length} zh={zh}
onChange={(next) => onChange({ ...value, conditions: replace(value.conditions, index, next) })}
onMove={(offset) => onChange({ ...value, conditions: move(value.conditions, index, offset) })}
onDelete={() => onChange({ ...value, conditions: remove(value.conditions, index) })} />)}
{!value.conditions.length && <div className="trigger-empty">{l('没有条件:每次事件发生都会执行。', 'No conditions: every matching event will run.')}</div>}</div>
</section>
<section className="trigger-step">
<StepTitle number="03" title={l('执行操作', 'Actions')}
detail={l('按从上到下执行;条件节点仅在判断通过后执行其内部操作',
'Runs top to bottom; a condition executes its contained actions only when it matches')} />
<ActionTreeEditor actions={value.actions} catalog={catalog} eventType={value.event.type}
canExecuteCommands={canExecuteCommands} defaultSenderName={defaultSenderName}
triggerChoices={triggerChoices} menuChoices={menuChoices}
variables={actionVariables} variableCatalog={variableCatalog} zh={zh} depth={0} path={[]}
totalNodes={actionNodeCount} root
onChange={(actions) => onChange({ ...value, actions })} />
<p className="field__hint">{l(`操作树当前有 ${actionNodeCount} 个节点;上限 4096 个节点、32 层嵌套。`,
`The action tree currently has ${actionNodeCount} nodes; limits are 4,096 nodes and 32 levels.`)}</p>
</section>
</div>}
</fieldset>
{simulation && <section className="trigger-step" aria-label={l('模拟追踪', 'Simulation trace')}>
<StepTitle number="T" title={l('模拟追踪', 'Simulation trace')}
detail={l('只读;不会执行真实动作', 'Read-only; no real side effects are executed')} />
<div className="button-row"><Badge tone={simulation.result.status === 'COMPLETED' ? 'good' : 'danger'}>
{executionStatusLabel(simulation.result.status, zh)}</Badge><span>{l('指令', 'Instructions')}: {simulation.result.instructions}</span>
<span>{l('计划动作', 'Planned actions')}: {simulation.result.actions.length}</span></div>
{simulation.result.error && <div className="form-error" role="alert">{simulation.result.error}</div>}
<div className="trigger-block-list">{simulation.result.trace.slice(-100).map((step) =>
<div className="trigger-condition-row" key={step.sequence}><code>{step.sequence}</code>
<strong>{traceKindLabel(step.kind, zh)}</strong><code>{step.nodeId ?? 'program'}</code>
<span>{formatLiveVariable(step.result)}</span></div>)}</div>
</section>}
</Panel>;
}
type V2Node = TriggerProgramNode;
type TriggerEventBindingLike = NonNullable<TriggerDefinition['events']>[number];
let triggerProgramClipboard = '';
function isTextEditingTarget(target: EventTarget | null): boolean {
return target instanceof Element && !!target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"],[role="dialog"]');
}
function TriggerProgramWorkspace({ value, program, catalog, writable, owner, zh, onChange }: {
value: TriggerDefinition; program: TriggerProgramV2; catalog: TriggerCatalog; writable: boolean;
owner: boolean; zh: boolean; onChange: (value: TriggerDefinition) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const [query, setQuery] = useState('');
const [kind, setKind] = useState('ALL');
const [selectedId, setSelectedId] = useState(program.events[0]?.nodeId ?? '');
const [pasteTarget, setPasteTarget] = useState<TriggerPasteTarget>();
const [clipboardMessage, setClipboardMessage] = useState('');
const [renameTo, setRenameTo] = useState('');
const [templateId, setTemplateId] = useState('');
const history = useRef<TriggerProgramV2[]>([]);
const future = useRef<TriggerProgramV2[]>([]);
const current = useRef(program);
current.current = program;
const clipboardContext = useRef('');
clipboardContext.current = `${value.id}:${selectedId}:${JSON.stringify(pasteTarget)}`;
useEffect(() => {
history.current = [];
future.current = [];
setSelectedId(program.events[0]?.nodeId ?? '');
setPasteTarget(undefined);
setClipboardMessage('');
}, [value.id, value.revision]);
const selectNode = (id: string) => { setSelectedId(id); setPasteTarget(undefined); };
const selectBranch = (target: TriggerPasteTarget) => { setSelectedId(target.parentId); setPasteTarget(target); };
const apply = (next: TriggerProgramV2, remember = true) => {
if (!writable) return;
if (remember) {
history.current = [...history.current.slice(-99), structuredClone(current.current)];
future.current = [];
}
const first = next.events[0];
onChange({ ...value, schemaVersion: 2, events: next.events, declarations: next.declarations,
functions: next.functions, statements: next.statements,
event: { type: first.type, configuration: first.configuration,
variables: next.declarations.map(v2VariableProjection) } });
};
const undo = () => {
const previous = history.current.at(-1);
if (!previous) return;
history.current = history.current.slice(0, -1);
future.current = [structuredClone(current.current), ...future.current.slice(0, 99)];
apply(previous, false);
setPasteTarget(undefined);
if (!findV2Node(previous, selectedId)) setSelectedId(previous.events[0]?.nodeId ?? '');
setClipboardMessage(l('已撤销。', 'Undone.'));
};
const redo = () => {
const next = future.current[0];
if (!next) return;
future.current = future.current.slice(1);
history.current = [...history.current.slice(-99), structuredClone(current.current)];
apply(next, false);
setPasteTarget(undefined);
if (!findV2Node(next, selectedId)) setSelectedId(next.events[0]?.nodeId ?? '');
setClipboardMessage(l('已重做。', 'Redone.'));
};
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (!writable || !(event.ctrlKey || event.metaKey) || !['z', 'y'].includes(event.key.toLowerCase())) return;
if (isTextEditingTarget(event.target)) return;
event.preventDefault();
if (event.key.toLowerCase() === 'y' || event.shiftKey) redo(); else undo();
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
});
const selected = findV2Node(program, selectedId);
useEffect(() => {
setRenameTo(selected?.category === 'declaration'
? (selected.value as TriggerVariableDeclarationV2).name : '');
}, [selectedId]);
const descriptors = (catalog.descriptors ?? []).filter((descriptor) => {
if (kind !== 'ALL' && descriptor.kind !== kind) return false;
if (!owner && descriptor.risk === 'OWNER') return false;
const text = `${descriptor.id} ${descriptor.displayName} ${descriptor.description} ${descriptorName(descriptor, catalog, zh)} ${descriptorDescription(descriptor, catalog, zh)}`.toLowerCase();
return text.includes(query.trim().toLowerCase());
});
const addDescriptor = (descriptor: NonNullable<TriggerCatalog['descriptors']>[number]) => {
if (!writable) return;
if (descriptor.kind === 'EVENT') {
const event = { nodeId: newNodeId(), type: descriptor.id, configuration: defaultV2EventConfiguration(descriptor.id) };
apply({ ...program, events: [...program.events, event] });
selectNode(event.nodeId);
} else if (descriptor.kind === 'ACTION') {
const statement = actionStatement(descriptor);
apply({ ...program, statements: [...program.statements, statement] });
selectNode(statement.nodeId);
}
};
const updateSelected = (replacement: V2Node['value']) => {
if (!selected) return;
apply(replaceV2Node(program, selectedId, replacement));
setPasteTarget(undefined);
};
const removeSelected = () => {
if (!selected || selected.category === 'event' && program.events.length === 1) return;
const next = removeV2Node(program, selectedId);
apply(next);
selectNode(next.events[0]?.nodeId ?? next.statements[0]?.nodeId ?? '');
};
const pasteNode = (source: string, target: TriggerPasteTarget | null | undefined = pasteTarget) => {
if (!writable) return;
const node = parseTriggerNode(source);
if (!node) { setClipboardMessage(l('剪贴板中没有有效的 V2 程序节点。', 'No valid V2 program node on the clipboard.')); return; }
const result = insertTriggerNode(program, node, selectedId, target ?? undefined);
const ownerOnly = (entry: TriggerStatement) => entry.kind === 'ACTION' && (commandActions.has(entry.name)
|| !!catalog.descriptors?.some((descriptor) => descriptor.kind === 'ACTION' && descriptor.id === entry.name && descriptor.risk === 'OWNER'));
if (!owner && [...result.program.statements, ...result.program.functions.flatMap((entry) => entry.statements)]
.some((entry) => statementTreeContains(entry, ownerOnly))) {
setClipboardMessage(l('无法粘贴:节点包含仅 Owner 可配置的动作。', 'Cannot paste: the node contains Owner-only actions.')); return;
}
if (!triggerProgramFitsLimits(result.program, catalog.budgets?.nodes ?? 4096, catalog.budgets?.nestingDepth ?? 32)) {
setClipboardMessage(l('无法粘贴:程序结构无效,或超出事件数、节点数、嵌套深度上限。',
'Cannot paste: invalid program structure or event, node, or nesting limit exceeded.')); return;
}
apply(result.program); selectNode(result.nodeId);
setClipboardMessage(l('已粘贴节点,包含其全部子节点;可撤销。', 'Node pasted with all its children; undo is available.'));
};
const copyNode = () => {
if (!selected) return '';
triggerProgramClipboard = serializeTriggerNode(selected);
setClipboardMessage(l('已复制节点及其子节点,按 Ctrl/Cmd+V 粘贴。', 'Node and children copied. Press Ctrl/Cmd+V to paste.'));
return triggerProgramClipboard;
};
const copyToClipboard = async () => {
const source = copyNode(); if (!source) return;
try { await navigator.clipboard?.writeText(source); } catch { /* The in-page paste button still works. */ }
};
const pasteFromClipboard = async () => {
const beforeRead = current.current;
const beforeContext = clipboardContext.current;
let source = triggerProgramClipboard;
try { if (navigator.clipboard?.readText) source = await navigator.clipboard.readText(); }
catch { /* Non-secure mod consoles may only support the internal clipboard. */ }
if (current.current !== beforeRead || clipboardContext.current !== beforeContext) return;
pasteNode(source);
};
const duplicateSelected = () => { if (selected) pasteNode(serializeTriggerNode(selected), null); };
useEffect(() => {
const copy = (event: ClipboardEvent) => {
if (event.defaultPrevented || !selected || isTextEditingTarget(event.target)
|| window.getSelection()?.toString() || !event.clipboardData) return;
event.clipboardData.setData('text/plain', copyNode());
event.preventDefault();
};
const paste = (event: ClipboardEvent) => {
if (event.defaultPrevented || !writable || isTextEditingTarget(event.target) || !event.clipboardData) return;
const source = event.clipboardData.getData('text/plain');
if (!source.includes('xfesm-trigger-node-v2')) return; // Leave ordinary text and other editors alone.
event.preventDefault(); pasteNode(source);
};
window.addEventListener('copy', copy); window.addEventListener('paste', paste);
return () => { window.removeEventListener('copy', copy); window.removeEventListener('paste', paste); };
});
const addControl = (statementKind: TriggerStatement['kind']) => {
const statement = defaultV2Statement(statementKind, program);
apply({ ...program, statements: [...program.statements, statement] });
selectNode(statement.nodeId);
};
const applyTemplate = () => {
const template = (catalog.templates ?? []).find((entry) => entry.id === templateId);
if (!template || !window.confirm(l('模板会替换当前 V2 程序,是否继续?',
'The template replaces the current V2 program. Continue?'))) return;
const next = regenerateNodeIds(template.program) as TriggerProgramV2;
apply(next);
selectNode(next.events[0].nodeId);
};
const renameVariable = () => {
if (!selected || selected.category !== 'declaration') return;
const declaration = selected.value as TriggerVariableDeclarationV2;
const normalized = renameTo.trim();
if (!/^[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test(normalized)
|| program.declarations.some((entry) => entry.nodeId !== declaration.nodeId && entry.name === normalized)) return;
apply(renameV2Variable(program, declaration.name, normalized));
};
const references = selected?.category === 'declaration'
? variableReferenceCount(program, (selected.value as TriggerVariableDeclarationV2).name) : 0;
const dependencies = v2Dependencies(program);
return <div className="galaxy-editor" onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const id = event.dataTransfer.getData('application/x-xfesm-trigger-descriptor');
const descriptor = (catalog.descriptors ?? []).find((entry) => `${entry.kind}:${entry.id}` === id);
if (descriptor) addDescriptor(descriptor);
}}>
<section className="galaxy-pane galaxy-library" aria-label={l('触发器库', 'Trigger library')}>
<div className="galaxy-pane__heading"><strong>{l('库与预设', 'Library & presets')}</strong>
<Badge>{catalog.catalogRevision ?? 0}</Badge></div>
<Input aria-label={l('搜索触发器目录', 'Search trigger catalog')} value={query}
placeholder={l('搜索事件、函数、动作、类型', 'Search events, functions, actions, types')}
onChange={(event) => setQuery(event.target.value)} />
<Select aria-label={l('目录分类', 'Catalog category')} value={kind} onChange={(event) => setKind(event.target.value)}>
<option value="ALL">{label(catalogKindLabels.ALL, zh, 'ALL')}</option>
{['EVENT', 'EVENT_RESPONSE', 'VALUE_FUNCTION', 'CONDITION_FUNCTION', 'ACTION', 'TYPE'].map((entry) =>
<option key={entry} value={entry}>{label(catalogKindLabels[entry], zh, entry.replace('_', ' '))}</option>)}</Select>
<div className="galaxy-catalog-list">{descriptors.slice(0, 250).map((descriptor) =>
<button type="button" key={`${descriptor.kind}:${descriptor.id}`} draggable={writable}
onDragStart={(event) => event.dataTransfer.setData('application/x-xfesm-trigger-descriptor',
`${descriptor.kind}:${descriptor.id}`)}
disabled={!writable || !['EVENT', 'ACTION'].includes(descriptor.kind)}
onClick={() => addDescriptor(descriptor)} title={descriptorDescription(descriptor, catalog, zh)}>
<span><strong>{descriptorName(descriptor, catalog, zh)}</strong>
<small className="galaxy-catalog-list__description">{descriptorDescription(descriptor, catalog, zh)}</small>
<code>{descriptor.id}</code></span>
<Badge tone={descriptor.risk === 'OWNER' ? 'danger' : descriptor.risk === 'ADMINISTRATOR' ? 'warn' : 'good'}>
{label(catalogKindLabels[descriptor.kind], zh, descriptor.kind)}</Badge></button>)}</div>
{(catalog.templates ?? []).length > 0 && <div className="galaxy-template-picker">
<Select aria-label={l('官方模板', 'Official template')} value={templateId}
onChange={(event) => setTemplateId(event.target.value)}><option value="">{l('选择官方模板', 'Choose template')}</option>
{(catalog.templates ?? []).map((template) => <option key={template.id} value={template.id}>
{label(templateLabels[template.id], zh, template.name)}</option>)}</Select>
<Button type="button" variant="secondary" disabled={!writable || !templateId} onClick={applyTemplate}>
{l('应用', 'Apply')}</Button>
{templateId && <small className="galaxy-template-picker__description">{label(templateDescriptions[templateId], zh,
(catalog.templates ?? []).find((entry) => entry.id === templateId)?.description ?? '')}</small>}</div>}
</section>
<div className="galaxy-workbench">
<section className="galaxy-pane galaxy-tree" aria-label={l('程序树', 'Program tree')}>
<div className="galaxy-pane__heading"><strong>{l('程序层级', 'Program hierarchy')}</strong>
<span className="button-row"><Button type="button" variant="ghost" disabled={!selected}
onClick={() => void copyToClipboard()} title="Ctrl/Cmd+C"><Copy size={14} />{l('复制', 'Copy')}</Button>
<Button type="button" variant="ghost" disabled={!writable} title="Ctrl/Cmd+V"
onClick={() => void pasteFromClipboard()}><ClipboardPaste size={14} />{l('粘贴', 'Paste')}</Button>
<Button type="button" variant="ghost" disabled={!writable || !history.current.length}
onClick={undo}>{l('撤销', 'Undo')}</Button><Button type="button" variant="ghost"
onClick={redo} disabled={!writable || !future.current.length}>{l('重做', 'Redo')}</Button></span></div>
<p className="galaxy-tree__clipboard-help">{l('Ctrl/Cmd+C 复制 · Ctrl/Cmd+V 粘贴。选中语句:同级插入;选中分支:插入分支内。',
'Ctrl/Cmd+C to copy · Ctrl/Cmd+V to paste. Select a statement to insert after it; select a branch to insert inside.')}</p>
{clipboardMessage && <p className="galaxy-tree__clipboard-status" role="status">{clipboardMessage}</p>}
<ProgramTreeSection title={l('事件绑定', 'Event bindings')} items={program.events}
selectedId={selectedId} onSelect={selectNode} renderLabel={(entry) => eventLabel(entry.type, zh)} onAdd={() => {
const event = { nodeId: newNodeId(), type: 'custom', configuration: {} };
apply({ ...program, events: [...program.events, event] }); selectNode(event.nodeId);
}} />
<ProgramTreeSection title={l('变量声明', 'Declarations')} items={program.declarations}
selectedId={selectedId} onSelect={selectNode} onAdd={() => {
const declaration: TriggerVariableDeclarationV2 = { nodeId: newNodeId(),
name: uniqueVariableName(program, 'value'), valueType: 'integer',
initialValue: literalExpression(0, 'integer'), visibility: 'trigger', storage: 'trigger',
lifetime: 'SESSION', ttlSeconds: null };
apply({ ...program, declarations: [...program.declarations, declaration] });
selectNode(declaration.nodeId);
}} />
<ProgramTreeSection title={l('函数/过程', 'Functions / procedures')} items={program.functions}
selectedId={selectedId} onSelect={selectNode} onAdd={() => {
const fn: TriggerFunctionDeclaration = { nodeId: newNodeId(),
name: uniqueFunctionName(program, 'procedure'), parameters: [], returnType: 'void', locals: [], statements: [] };
apply({ ...program, functions: [...program.functions, fn] }); selectNode(fn.nodeId);
}} renderChildren={(entry) =>
<StatementTree statements={(entry as TriggerFunctionDeclaration).statements}
selectedId={selectedId} onSelect={selectNode} depth={1} catalog={catalog} zh={zh}
pasteTarget={pasteTarget} onSelectBranch={selectBranch} />} />
<div className="galaxy-tree__section"><div className="galaxy-tree__title"><strong>{l('语句树', 'Statement tree')}</strong>
<span className="button-row"><Button type="button" variant="ghost" disabled={!writable}
onClick={() => addControl('IF')}>{l('条件', 'If')}</Button><Button type="button" variant="ghost" disabled={!writable}
onClick={() => addControl('REPEAT')}>{l('循环', 'loop')}</Button><Button type="button" variant="ghost"
disabled={!writable} onClick={() => addControl('TRY')}>{l('错误处理', 'Try')}</Button></span></div>
<StatementTree statements={program.statements} selectedId={selectedId} onSelect={selectNode} depth={0}
catalog={catalog} zh={zh} pasteTarget={pasteTarget} onSelectBranch={selectBranch} />
{!program.statements.length && <div className="trigger-empty">{l('拖入动作或添加控制流。', 'Drop an action or add control flow.')}</div>}</div>
{dependencies.length > 0 && <div className="galaxy-dependencies"><strong>{l('依赖图', 'Dependency graph')}</strong>
{dependencies.map((edge) => <code key={edge}>{edge}</code>)}</div>}
</section>
<section className="galaxy-pane galaxy-inspector" aria-label={l('节点属性', 'Node inspector')}>
<div className="galaxy-pane__heading"><strong>{l('节点属性', 'Node inspector')}</strong>
{selected && <Badge>{label({ event: ['事件', 'Event'], declaration: ['变量', 'Variable'],
function: ['函数', 'Function'], statement: ['语句', 'Statement'] }[selected.category] as [string, string], zh, selected.category)}</Badge>}</div>
{!selected && <div className="trigger-empty">{l('选择一个节点进行编辑。', 'Select a node to edit.')}</div>}
{selected?.category === 'event' && <EventBindingInspector value={selected.value as TriggerEventBindingLike}
catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)} />}
{selected?.category === 'declaration' && <VariableDeclarationInspector
value={selected.value as TriggerVariableDeclarationV2} catalog={catalog} zh={zh}
onChange={(next) => updateSelected(next)} />}
{selected?.category === 'function' && <FunctionInspector value={selected.value as TriggerFunctionDeclaration}
catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)} />}
{selected?.category === 'statement' && <StatementInspector value={selected.value as TriggerStatement}
program={program} catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)}
onAppend={(branch, child) => apply(appendV2Child(program, selectedId, branch, child))} />}
{selected?.category === 'declaration' && <div className="galaxy-rename"><Field
label={`${l('安全重命名', 'Safe rename')} · ${references} ${l('处引用', 'references')}`}>
<Input value={renameTo} onChange={(event) => setRenameTo(event.target.value)} /></Field>
<Button type="button" variant="secondary" onClick={renameVariable} disabled={!writable}>{l('重命名', 'Rename')}</Button></div>}
{selected && <><JsonNodeEditor key={selectedId} value={selected.value} zh={zh}
onCommit={(next) => updateSelected({ ...next, nodeId: selectedId } as V2Node['value'])} />
<div className="button-row"><Button type="button" variant="ghost" title="Ctrl/Cmd+C"
onClick={() => void copyToClipboard()}><Copy size={14} />{l('复制节点', 'Copy node')}</Button>
<Button type="button" variant="ghost" disabled={!writable}
onClick={duplicateSelected}><CopyPlus size={14} />{l('创建副本', 'Duplicate')}</Button>
<Button type="button" variant="ghost" disabled={!writable
|| selected.category === 'event' && program.events.length === 1} onClick={removeSelected}>
<Trash2 size={14} />{l('删除节点', 'Delete')}</Button></div></>}
<div className="galaxy-budget"><span>{l('节点', 'Nodes')}: {countV2Nodes(program)}/{catalog.budgets?.nodes ?? 4096}</span>
<span>{l('函数', 'Functions')}: {program.functions.length}</span><span>{l('事件', 'Events')}: {program.events.length}</span></div>
</section>
</div>
</div>;
}
function ProgramTreeSection<T extends { nodeId: string; name?: string; type?: string }>({ title, items, selectedId,
onSelect, onAdd, renderChildren, renderLabel }: { title: string; items: T[]; selectedId: string; onSelect: (id: string) => void;
onAdd?: () => void; renderChildren?: (item: T) => ReactNode; renderLabel?: (item: T) => string }) {
return <div className="galaxy-tree__section"><div className="galaxy-tree__title"><strong>{title}</strong><Badge>{items.length}</Badge>
{onAdd && <Button type="button" variant="ghost" onClick={onAdd}><Plus size={12} /></Button>}</div>
{items.map((item) => <div key={item.nodeId}><button type="button"
className={`galaxy-tree__node ${selectedId === item.nodeId ? 'is-active' : ''}`} aria-pressed={selectedId === item.nodeId}
onClick={() => onSelect(item.nodeId)}><span>{renderLabel?.(item) ?? item.name ?? item.type ?? item.nodeId}</span>
<small>{item.nodeId.slice(0, 8)}</small></button>{renderChildren?.(item)}</div>)}</div>;
}
function StatementTree({ statements, selectedId, onSelect, depth, catalog, zh, pasteTarget, onSelectBranch }: {
statements: TriggerStatement[]; selectedId: string; onSelect: (id: string) => void; depth: number;
catalog: TriggerCatalog; zh: boolean;
pasteTarget?: TriggerPasteTarget; onSelectBranch?: (target: TriggerPasteTarget) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-statement-tree">
{statements.map((statement) => {
const summary = statementPreview(statement, catalog, zh);
const branches: Array<{ label: string; target: TriggerPasteTarget; statements: TriggerStatement[] }> = [];
if (['IF', 'REPEAT', 'WHILE', 'FOREACH', 'TRY'].includes(statement.kind) || statement.statements.length) {
branches.push({ label: statement.kind === 'IF' ? l('条件成立时', 'Then') : statement.kind === 'TRY'
? l('尝试执行', 'Try body') : ['REPEAT', 'WHILE', 'FOREACH'].includes(statement.kind)
? l('循环体', 'Loop body') : l('子语句', 'Child statements'),
target: { parentId: statement.nodeId, branch: 'statements' }, statements: statement.statements });
}
statement.cases.forEach((branch) => branches.push({
label: `${l('匹配', 'Case')} ${expressionPreview(branch.match, catalog, zh)}`,
target: { parentId: statement.nodeId, branch: 'cases', caseId: branch.nodeId }, statements: branch.statements,
}));
if (['IF', 'SWITCH', 'TRY'].includes(statement.kind) || statement.elseStatements.length) {
branches.push({ label: statement.kind === 'TRY' ? l('发生错误时', 'On error') : statement.kind === 'SWITCH'
? l('默认分支(均不匹配)', 'Default (no match)') : l('否则(条件不成立)', 'Else (condition not met)'),
target: { parentId: statement.nodeId, branch: 'elseStatements' }, statements: statement.elseStatements });
}
const isCollapsed = collapsed.has(statement.nodeId);
return <div className="galaxy-statement" key={statement.nodeId}>
<div className="galaxy-statement__row">
{branches.length > 0 ? <button type="button" className="galaxy-statement__toggle"
aria-label={`${isCollapsed ? l('展开', 'Expand') : l('折叠', 'Collapse')} ${summary.title}`}
aria-expanded={!isCollapsed} onClick={() => setCollapsed((previous) => {
const next = new Set(previous); if (isCollapsed) next.delete(statement.nodeId); else next.add(statement.nodeId);
return next;
})}>{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}</button>
: <span className="galaxy-statement__leaf" aria-hidden="true" />}
<button type="button" aria-pressed={selectedId === statement.nodeId && !pasteTarget}
className={`galaxy-tree__node galaxy-tree__node--statement ${selectedId === statement.nodeId && !pasteTarget ? 'is-active' : ''}`}
onClick={() => onSelect(statement.nodeId)}>
<Badge>{label(statementKindLabels[statement.kind], zh, statement.kind)}</Badge>
<span className="galaxy-tree__summary"><span className="galaxy-tree__summary-title">{summary.title}</span>
{summary.details.map((detail, index) => <span className="galaxy-tree__summary-detail" key={index}>{detail}</span>)}</span>
<small>{statement.kind === 'ACTION' ? statement.name : statement.nodeId.slice(0, 8)}</small>
</button>
</div>
{!isCollapsed && branches.map((branch) => <div className="galaxy-tree__branch" role="group"
aria-label={branch.label} key={branch.target.caseId ?? branch.target.branch}>
<button type="button" className={`galaxy-tree__branch-label ${pasteTarget?.parentId === statement.nodeId
&& pasteTarget.branch === branch.target.branch && pasteTarget.caseId === branch.target.caseId ? 'is-active' : ''}`}
title={l('选择此分支,粘贴语句到分支末尾', 'Select this branch to paste statements at its end')}
onClick={() => onSelectBranch?.(branch.target)}>
<span>{branch.label}</span><small>{branch.statements.length} {l('条', 'statements')}</small>
</button>
<StatementTree statements={branch.statements} selectedId={selectedId} onSelect={onSelect}
depth={depth + 1} catalog={catalog} zh={zh} pasteTarget={pasteTarget} onSelectBranch={onSelectBranch} />
{!branch.statements.length && <span className="galaxy-tree__empty-branch">{l('暂无语句', 'No statements')}</span>}
</div>)}
</div>;
})}</div>;
}
function EventBindingInspector({ value, catalog, zh, onChange }: { value: TriggerEventBindingLike;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerEventBindingLike) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const custom = value.type === 'custom' || value.type.startsWith('custom.');
return <div className="galaxy-inspector__fields"><Field label={l('事件类型', 'Event type')}>
<Select value={custom ? 'custom' : value.type} onChange={(event) => onChange({ ...value,
type: event.target.value, configuration: defaultV2EventConfiguration(event.target.value) })}>
{catalog.events.map((entry) => <option key={entry} value={entry}>{eventLabel(entry, zh)}</option>)}</Select></Field>
{custom && <Field label={l('自定义事件 ID', 'Custom event ID')}
hint={l('填写发布模组的命名空间与事件名,例如 custom.mymod.quest_completed。',
'Enter the publishing mod namespace and event name, for example custom.mymod.quest_completed.')}>
<Input value={value.type === 'custom' ? '' : value.type} placeholder="custom.mymod.event"
onChange={(event) => onChange({ ...value, type: event.target.value.trim().toLowerCase() || 'custom' })} /></Field>}
<p className="galaxy-context-help"><strong>{eventLabel(value.type, zh)}</strong>
<span>{eventDescription(value.type, zh)}</span><code>{value.type}</code></p>
<V2EventConfigurationFields value={value} zh={zh} onChange={onChange} />
<details className="galaxy-json"><summary>{l('高级事件配置 JSON', 'Advanced event configuration JSON')}</summary>
<JsonValueEditor label={l('事件配置', 'Event configuration')} value={value.configuration}
onCommit={(configuration) => onChange({ ...value, configuration: configuration as Record<string, string> })} /></details></div>;
}
function V2EventConfigurationFields({ value, zh, onChange }: { value: TriggerEventBindingLike;
zh: boolean; onChange: (value: TriggerEventBindingLike) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const update = (name: string, next: string) => onChange({ ...value,
configuration: { ...value.configuration, [name]: next } });
const shape = value.configuration.shape ?? 'cuboid';
if (value.type === 'schedule.daily') return <div className="galaxy-event-fields form-grid">
<Field label={l('每天时间', 'Daily time')} hint={l('按下方时区每天执行一次。', 'Runs once per day in the time zone below.')}>
<Input type="time" value={value.configuration.time ?? '08:00'} onChange={(event) => update('time', event.target.value)} /></Field>
<Field label={l('时区', 'Time zone')} hint="Asia/Shanghai · UTC · Europe/London">
<Input value={value.configuration.timezone ?? 'Asia/Shanghai'} onChange={(event) => update('timezone', event.target.value)} /></Field></div>;
if (value.type === 'schedule.interval') return <IntervalFields totalSeconds={value.configuration.seconds ?? '60'}
zh={zh} onChange={(seconds) => onChange({ ...value, configuration: { seconds } })} />;
if (value.type.startsWith('protection.')) return <ProtectionEventFields
event={{ type: value.type, configuration: value.configuration }} zh={zh}
onChange={(event) => onChange({ ...value, configuration: event.configuration })} />;
if (value.type === 'player.command_trigger') return <div className="galaxy-event-fields">
<Field label={l('自定义命令', 'Custom command')}
hint={l('不含 /,只能使用小写字母、数字、下划线和连字符。', 'Omit /; use lowercase letters, numbers, underscores, and hyphens.')}>
<Input value={value.configuration.command ?? ''} placeholder="rules" pattern="[a-z][a-z0-9_-]{0,31}"
onChange={(event) => update('command', event.target.value.trim().toLowerCase())} /></Field>
<Field label={l('参数名称(兼容模式)', 'Argument names (compatibility mode)')}
hint={l('用空格分隔,将生成 args.<名称> 事件响应;复杂类型参数请继续使用旧版参数树。',
'Separate names with spaces to expose args.<name>; keep using the legacy argument tree for complex typed arguments.')}>
<Input value={value.configuration.arguments ?? ''} placeholder="target amount"
onChange={(event) => update('arguments', event.target.value.toLowerCase())} /></Field></div>;
if (value.type.startsWith('region.')) return <div className="galaxy-event-fields">
<Field label={l('区域 ID', 'Region ID')} hint={l('同一个 ID 的几何定义必须完全一致。', 'Geometry must be identical for every binding with the same ID.')}>
<Input value={value.configuration.regionId ?? ''} placeholder="spawn.safe_zone" pattern="[a-z][a-z0-9_.-]{1,63}"
onChange={(event) => update('regionId', event.target.value.trim().toLowerCase())} /></Field>
<div className="form-grid"><Field label={l('区域形状', 'Region shape')}><Select value={shape}
onChange={(event) => onChange({ ...value, configuration: { ...regionConfiguration(event.target.value),
regionId: value.configuration.regionId ?? '', dimension: value.configuration.dimension ?? 'minecraft:overworld' } })}>
<option value="cuboid">{l('长方体', 'Cuboid')} · cuboid</option><option value="sphere">{l('球体', 'Sphere')} · sphere</option>
<option value="cylinder">{l('圆柱体', 'Cylinder')} · cylinder</option></Select></Field>
<Field label={l('维度', 'Dimension')}><Input value={value.configuration.dimension ?? 'minecraft:overworld'}
onChange={(event) => update('dimension', event.target.value)} /></Field></div>
{shape === 'cuboid' ? <><div className="coordinate-grid">{['minX', 'minY', 'minZ'].map((name) => <Field key={name}
label={`${l('最小', 'Min')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div>
<div className="coordinate-grid">{['maxX', 'maxY', 'maxZ'].map((name) => <Field key={name}
label={`${l('最大', 'Max')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div></>
: <><div className="coordinate-grid">{['centerX', ...(shape === 'sphere' ? ['centerY'] : []), 'centerZ'].map((name) => <Field key={name}
label={`${l('中心', 'Center')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div>
<Field label={l('半径', 'Radius')}><Input type="number" min={0.01} value={value.configuration.radius ?? '8'}
onChange={(event) => update('radius', event.target.value)} /></Field>
{shape === 'cylinder' && <div className="form-grid"><Field label={l('最低 Y', 'Minimum Y')}><Input type="number"
value={value.configuration.minY ?? '0'} onChange={(event) => update('minY', event.target.value)} /></Field>
<Field label={l('最高 Y', 'Maximum Y')}><Input type="number" value={value.configuration.maxY ?? '320'}
onChange={(event) => update('maxY', event.target.value)} /></Field></div>}</>}
<Field label={l('检测频率(游戏刻)', 'Check frequency (ticks)')}
hint={l('20 游戏刻约等于 1 秒;数值越小检测越频繁。', '20 ticks is about one second; lower values check more often.')}>
<Input type="number" min={1} max={72000} value={value.configuration.frequencyTicks ?? '20'}
onChange={(event) => update('frequencyTicks', event.target.value)} /></Field></div>;
return <p className="field__hint">{l('此事件不需要额外配置。', 'This event needs no additional configuration.')}</p>;
}
function VariableDeclarationInspector({ value, catalog, zh, onChange }: { value: TriggerVariableDeclarationV2;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerVariableDeclarationV2) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-inspector__fields"><Field label={l('变量名', 'Variable name')}><Input value={value.name}
readOnly title={l('请使用下方安全重命名', 'Use safe rename below')} /></Field>
<Field label={l('类型', 'Type')} hint={label(triggerTypeDescriptions[value.valueType], zh,
l(`技术类型:${value.valueType}`, `Technical type: ${value.valueType}`))}><Input list="v2-trigger-types" value={value.valueType}
onChange={(event) => onChange({ ...value, valueType: event.target.value })} /></Field>
<datalist id="v2-trigger-types">{(catalog.triggerVariableTypes ?? []).map((entry) =>
<option key={entry} value={entry}>{label(triggerTypeLabels[entry], zh, entry)}</option>)}</datalist>
<Field label={l('可见性', 'Visibility')}><Select value={value.visibility}
onChange={(event) => onChange({ ...value, visibility: event.target.value as 'trigger' | 'global' })}>
<option value="trigger">{label(visibilityLabels.trigger, zh, 'trigger')} · trigger</option>
<option value="global">{label(visibilityLabels.global, zh, 'global')} · global</option></Select></Field>
<Field label={l('存储域', 'Storage')}><Select value={value.storage}
onChange={(event) => onChange({ ...value, storage: event.target.value as TriggerVariableDeclarationV2['storage'] })}>
{(catalog.variableStorageScopes ?? []).map((entry) => <option key={entry} value={entry}>
{label(storageLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
<Field label={l('生命周期', 'Lifetime')}><Select value={value.lifetime}
onChange={(event) => { const lifetime = event.target.value as TriggerVariableDeclarationV2['lifetime'];
onChange({ ...value, lifetime, ttlSeconds: lifetime === 'TTL' ? value.ttlSeconds ?? 3600 : null }); }}>
<option value="SESSION">{label(lifetimeLabels.SESSION, zh, 'SESSION')} · SESSION</option>
<option value="TTL">{label(lifetimeLabels.TTL, zh, 'TTL')} · TTL</option>
<option value="PERSISTENT">{label(lifetimeLabels.PERSISTENT, zh, 'PERSISTENT')} · PERSISTENT</option></Select></Field>
{value.lifetime === 'TTL' && <Field label={l('有效期(秒)', 'TTL (seconds)')}><Input type="number" min={1} max={31536000}
value={value.ttlSeconds ?? 3600} onChange={(event) => onChange({ ...value, ttlSeconds: Number(event.target.value) })} /></Field>}
{value.initialValue && <ExpressionEditor value={value.initialValue} zh={zh}
onChange={(initialValue) => onChange({ ...value, initialValue })} />}</div>;
}
function FunctionInspector({ value, catalog, zh, onChange }: { value: TriggerFunctionDeclaration;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerFunctionDeclaration) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-inspector__fields"><Field label={l('函数名', 'Function name')}><Input value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('返回类型', 'Return type')} hint={label(triggerTypeDescriptions[value.returnType], zh,
l(`技术类型:${value.returnType}`, `Technical type: ${value.returnType}`))}><Input list="v2-trigger-types" value={value.returnType}
onChange={(event) => onChange({ ...value, returnType: event.target.value })} /></Field>
<JsonValueEditor label={l('参数(IN/OUT/INOUT)', 'Parameters (IN/OUT/INOUT)')} value={value.parameters}
onCommit={(parameters) => onChange({ ...value, parameters: parameters as TriggerFunctionDeclaration['parameters'] })} />
<JsonValueEditor label={l('局部变量', 'Local variables')} value={value.locals}
onCommit={(locals) => onChange({ ...value, locals: locals as TriggerVariableDeclarationV2[] })} />
<small>{l('函数体在中间树中显示;可通过高级 JSON 或复制语句维护。',
'The function body is shown in the tree; use advanced JSON or duplicated statements to maintain it.')}</small></div>;
}
function StatementInspector({ value, program, catalog, zh, onChange, onAppend }: { value: TriggerStatement;
program: TriggerProgramV2; catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerStatement) => void;
onAppend: (branch: 'statements' | 'elseStatements', child: TriggerStatement) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const action = (catalog.descriptors ?? []).find((entry) => entry.kind === 'ACTION' && entry.id === value.name);
const needsExpression = ['SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'RETURN'].includes(value.kind);
return <div className="galaxy-inspector__fields"><Field label={l('语句类型', 'Statement kind')}><Select value={value.kind}
onChange={(event) => onChange({ ...defaultV2Statement(event.target.value as TriggerStatement['kind'], program),
nodeId: value.nodeId })}>{['ACTION', 'SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'CALL', 'RETURN', 'TRY']
.map((entry) => <option key={entry} value={entry}>{label(statementKindLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
{value.kind === 'ACTION' ? <Field label={l('动作', 'Action')}><Select value={value.name}
onChange={(event) => { const descriptor = (catalog.descriptors ?? []).find((entry) =>
entry.kind === 'ACTION' && entry.id === event.target.value); if (descriptor) onChange({ ...actionStatement(descriptor), nodeId: value.nodeId }); }}>
{(catalog.descriptors ?? []).filter((entry) => entry.kind === 'ACTION').map((entry) =>
<option key={entry.id} value={entry.id}>{descriptorName(entry, catalog, zh)} · {entry.id}</option>)}</Select></Field>
: ['SET', 'FOREACH', 'CALL'].includes(value.kind) && <Field label={value.kind === 'SET'
? l('目标变量', 'Target variable') : value.kind === 'FOREACH' ? l('元素变量', 'Item variable') : l('函数', 'Function')}>
{value.kind === 'SET' ? <Select value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })}>
{program.declarations.map((entry) => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</Select>
: value.kind === 'CALL' ? <Select value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })}>
{program.functions.map((entry) => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</Select>
: <Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />}</Field>}
{needsExpression && value.expression && <ExpressionEditor value={value.expression} zh={zh}
onChange={(expression) => onChange({ ...value, expression })} />}
{value.kind === 'ACTION' && action && <p className="galaxy-context-help"><strong>{descriptorName(action, catalog, zh)}</strong>
<span>{descriptorDescription(action, catalog, zh)}</span><code>{action.id}</code></p>}
{value.kind === 'ACTION' && <div className="galaxy-expression-list">{(action?.parameters ?? []).map((parameter) => {
const expression = value.inputs[parameter.name] ?? literalExpression(parameter.defaultValue ?? '', 'string');
return <ExpressionEditor key={parameter.name} label={`${actionParameterLabel(parameter.name, zh)} · ${parameter.name}`}
hint={parameter.description || actionParameterDescription(parameter.name, zh)} value={expression} zh={zh}
onChange={(next) => onChange({ ...value, inputs: { ...value.inputs, [parameter.name]: next } })} />;
})}</div>}
{['IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'TRY'].includes(value.kind) && <div className="button-row">
<Button type="button" variant="secondary" onClick={() => onAppend('statements',
actionStatement(firstActionDescriptor(catalog)))}>{l('添加子动作', 'Add child action')}</Button>
{['IF', 'SWITCH', 'TRY'].includes(value.kind) && <Button type="button" variant="secondary"
onClick={() => onAppend('elseStatements', actionStatement(firstActionDescriptor(catalog)))}>
{value.kind === 'TRY' ? l('添加错误分支', 'Add error branch') : l('添加否则分支', 'Add else branch')}</Button>}</div>}
</div>;
}
function ExpressionEditor({ value, label: editorLabel, hint, zh, onChange }: { value: TriggerExpression; label?: string; hint?: string; zh: boolean;
onChange: (value: TriggerExpression) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-expression"><strong>{editorLabel ?? l('表达式', 'Expression')}</strong>
<div className="form-grid"><Field label={l('种类', 'Kind')}><Select value={value.kind}
onChange={(event) => onChange(expressionOfKind(event.target.value as TriggerExpression['kind'], value.nodeId))}>
{['LITERAL', 'REFERENCE', 'UNARY', 'BINARY', 'FUNCTION', 'INDEX', 'COALESCE', 'CONVERT'].map((entry) =>
<option key={entry} value={entry}>{label(expressionKindLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
<Field label={l('结果类型', 'Value type')} hint={label(triggerTypeDescriptions[value.valueType], zh,
l(`技术类型:${value.valueType}`, `Technical type: ${value.valueType}`))}><Input list="v2-trigger-types" value={value.valueType}
onChange={(event) => onChange({ ...value, valueType: event.target.value })} /></Field></div>
{hint && <small className="galaxy-expression__help">{hint}</small>}
{value.kind === 'LITERAL' ? <LiteralEditor value={value.literal} valueType={value.valueType} zh={zh}
onChange={(literal) => onChange({ ...value, literal })} /> : <Field label={l('名称/操作符/引用', 'Name/operator/reference')}>
<Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>}
{value.kind !== 'LITERAL' && <JsonValueEditor label={l('子表达式', 'Arguments')} value={value.arguments}
onCommit={(argumentsList) => onChange({ ...value, arguments: argumentsList as TriggerExpression[] })} />}</div>;
}
function LiteralEditor({ value, valueType, zh, onChange }: { value: unknown; valueType: string; zh: boolean;
onChange: (value: unknown) => void }) {
if (valueType === 'bool') return <Select value={String(value)}
onChange={(event) => onChange(event.target.value === 'true')}><option value="true">{zh ? '是 · true' : 'True · true'}</option>
<option value="false">{zh ? '否 · false' : 'False · false'}</option></Select>;
if (valueType === 'integer' || valueType === 'float') return <Input type="number" value={String(value ?? 0)}
onChange={(event) => onChange(valueType === 'integer' ? Number.parseInt(event.target.value || '0', 10)
: Number(event.target.value || '0'))} />;
if (valueType === 'list' || /^(?:array|set|dictionary|record)</.test(valueType)) return <JsonValueEditor
label="JSON" value={value} onCommit={onChange} />;
return <Input value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} />;
}
function JsonValueEditor({ label, value, onCommit }: { label: string; value: unknown; onCommit: (value: unknown) => void }) {
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
const [invalid, setInvalid] = useState(false);
useEffect(() => { setDraft(JSON.stringify(value, null, 2)); setInvalid(false); }, [value]);
const commit = () => { try { onCommit(JSON.parse(draft)); setInvalid(false); } catch { setInvalid(true); } };
return <Field label={label}><Textarea value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
onBlur={commit} /><Button type="button" variant="ghost" onClick={commit}>{invalid ? 'JSON ✕' : 'JSON ✓'}</Button></Field>;
}
function JsonNodeEditor({ value, zh, onCommit }: { value: object; zh: boolean; onCommit: (value: Record<string, unknown>) => void }) {
return <details className="galaxy-json"><summary>{zh ? '高级节点 JSON' : 'Advanced node JSON'}</summary>
<JsonValueEditor label="JSON" value={value} onCommit={(next) => {
if (next && typeof next === 'object' && !Array.isArray(next)) onCommit(next as Record<string, unknown>);
}} /></details>;
}
function StepTitle({ number, title, detail, action }: { number: string; title: string; detail: string; action?: ReactNode }) {
return <div className="trigger-step-heading"><span className="trigger-step-number">{number}</span><span><strong>{title}</strong><small>{detail}</small></span>{action}</div>;
}
interface IntervalParts { hours: string; minutes: string; seconds: string }
function IntervalFields({ totalSeconds, zh, onChange }: {
totalSeconds: string; zh: boolean; onChange: (seconds: string) => void;
}) {
const [parts, setParts] = useState<IntervalParts>(() => splitIntervalSeconds(totalSeconds));
const lastEmittedTotal = useRef(totalSeconds);
useEffect(() => {
if (totalSeconds === lastEmittedTotal.current) return;
lastEmittedTotal.current = totalSeconds;
setParts(splitIntervalSeconds(totalSeconds));
}, [totalSeconds]);
const updatePart = (part: keyof IntervalParts, nextValue: string) => {
const next = { ...parts, [part]: nextValue };
setParts(next);
const serialized = joinIntervalSeconds(next);
lastEmittedTotal.current = serialized;
onChange(serialized);
};
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="trigger-interval-editor" role="group" aria-label={l('执行间隔', 'Execution interval')}>
<div className="coordinate-grid">
<Field label={l('小时', 'Hours')}><Input type="number" inputMode="numeric" min={0} max={8760} step={1}
aria-label={l('间隔小时', 'Interval hours')} value={parts.hours}
onChange={(event) => updatePart('hours', event.target.value)} /></Field>
<Field label={l('分钟', 'Minutes')}><Input type="number" inputMode="numeric" min={0} max={59} step={1}
aria-label={l('间隔分钟', 'Interval minutes')} value={parts.minutes}
onChange={(event) => updatePart('minutes', event.target.value)} /></Field>
<Field label={l('秒', 'Seconds')}><Input type="number" inputMode="numeric" min={0} max={59} step={1}
aria-label={l('间隔秒', 'Interval seconds')} value={parts.seconds}
onChange={(event) => updatePart('seconds', event.target.value)} /></Field>
</div>
<p className="field__hint">{l(
'总间隔为 1 秒至 1 年,并按服务器本地时间边界对齐。例如服务器时间 12:58、间隔 15 分钟时,下一次执行为 13:00。',
'The total interval can be 1 second to 1 year and aligns to server-local time boundaries. For example, at 12:58 with a 15-minute interval, the next run is at 13:00.')}</p>
</div>;
}
function ProtectionEventFields({ event, zh, onChange }: {
event: TriggerEventSpec; zh: boolean; onChange: (event: TriggerEventSpec) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const update = (name: string, value: string) => onChange({ ...event,
configuration: { ...event.configuration, [name]: value } });
const countEvent = ['protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow'].includes(event.type);
const unit = event.type === 'protection.slow_tick' ? l('毫秒', 'milliseconds')
: event.type === 'protection.memory_pressure' ? l('百分比', 'percent') : l('数量', 'count');
return <div className="trigger-protection-editor">
<Field label={l(`触发阈值(${unit})`, `Trigger threshold (${unit})`)}
hint={l('只有当前测量值严格大于此数值时才触发。', 'Runs only when the observed value is greater than this number.')}>
<Input type="number" inputMode="numeric" step={1}
min={event.type === 'protection.memory_pressure' || event.type === 'protection.slow_tick' ? 50 : 1}
max={event.type === 'protection.memory_pressure' ? 99 : 2147483647}
aria-label={l('防护事件阈值', 'Protection event threshold')}
value={event.configuration.threshold ?? ''}
onChange={(change) => update('threshold', change.target.value)} />
</Field>
{countEvent && <Field label={l('统计范围', 'Counting scope')}>
<Select aria-label={l('防护统计范围', 'Protection counting scope')}
value={event.configuration.scope ?? 'dimension'}
onChange={(change) => update('scope', change.target.value)}>
<option value="dimension">{l('整个维度', 'Entire dimension')}</option>
<option value="chunk">{l('单个区块峰值', 'Peak single chunk')}</option>
</Select>
</Field>}
{event.type === 'protection.mod_entity_overflow' && <Field
label={l('模组命名空间', 'Mod namespace')} hint="create · minecraft · modid">
<Input value={event.configuration.namespace ?? ''} pattern="[a-z0-9_.-]+" spellCheck={false}
aria-label={l('实体模组命名空间', 'Entity mod namespace')}
onChange={(change) => update('namespace', change.target.value.trim().toLowerCase())} />
</Field>}
{event.type === 'protection.slow_tick' && <Field label={l('连续慢刻数', 'Consecutive slow ticks')}>
<Input type="number" inputMode="numeric" min={1} max={1200} step={1}
aria-label={l('连续慢刻数', 'Consecutive slow ticks')}
value={event.configuration.consecutive ?? '1'}
onChange={(change) => update('consecutive', change.target.value)} />
</Field>}
<Field label={l('重复触发冷却(秒)', 'Repeat cooldown (seconds)')}
hint={l('同一维度和范围在冷却期间只执行一次。', 'The same dimension and scope run once during the cooldown.')}>
<Input type="number" inputMode="numeric" min={1} max={86400} step={1}
aria-label={l('防护重复触发冷却', 'Protection repeat cooldown')}
value={event.configuration.cooldownSeconds ?? '60'}
onChange={(change) => update('cooldownSeconds', change.target.value)} />
</Field>
<p className="field__hint">{l(
'该阈值控制触发器何时执行;系统设置中的硬限制负责自动清理、阻止生成和熔断命令方块,两者可以分别配置。',
'This threshold controls trigger execution. Hard limits in server settings independently clean items, block spawns, and trip command-block circuit breakers.')}</p>
</div>;
}
function CommandTriggerFields({ event, catalog, zh, onChange }: {
event: TriggerEventSpec; catalog: TriggerCatalog; zh: boolean; onChange: (event: TriggerEventSpec) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const command = event.configuration.command ?? '';
const argumentTree = commandArgumentTree(event);
const argumentCount = countCommandArgumentNodes(argumentTree);
const updateArguments = (next: TriggerCommandArgument[]) => {
const configuration = { ...event.configuration };
delete configuration.arguments;
onChange({ ...event, configuration, arguments: next });
};
return <div className="trigger-command-editor">
<Field label={l('自定义指令', 'Custom command')}
hint={l('仅填写指令根,不含 /;使用小写字母、数字、_ 或 -,最多 32 个字符。',
'Enter the command root without /; use lowercase letters, numbers, _ or -, up to 32 characters.')}>
<Input aria-label={l('自定义指令根', 'Custom command root')} value={command}
pattern="[a-z][a-z0-9_-]{0,31}" maxLength={32} spellCheck={false}
aria-invalid={!commandPartPattern.test(command)} placeholder="rules"
onChange={(change) => onChange({ ...event,
configuration: { ...event.configuration, command: change.target.value } })} />
</Field>
<CommandArgumentTreeEditor arguments={argumentTree} catalog={catalog} zh={zh} depth={0}
path={[]} totalNodes={argumentCount} root onChange={updateArguments} />
<div className="trigger-command-preview"><span>{l('玩家输入预览', 'Player input preview')}</span>
<code>/{command || 'command'}{commandArgumentPreview(argumentTree)}</code></div>
</div>;
}
function CommandArgumentTreeEditor({ arguments: values, catalog, zh, depth, path, totalNodes, ordinalOffset = 0,
duplicateNames, root = false, onChange }: {
arguments: TriggerCommandArgument[]; catalog: TriggerCatalog; zh: boolean; depth: number; path: number[];
totalNodes: number; ordinalOffset?: number; duplicateNames?: Set<string>; root?: boolean;
onChange: (value: TriggerCommandArgument[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const duplicates = duplicateNames ?? duplicateCommandArgumentNames(values);
const add = () => onChange([...values, defaultCommandArgument(totalNodes + 1)]);
return <div className={`trigger-argument-tree${root ? ' trigger-argument-tree--root' : ''}`}>
<div className="trigger-command-arguments__heading"><span><strong>{root
? l('指令参数树', 'Command argument tree') : l('下一层参数', 'Next argument level')}</strong>
<small>{root ? l('同层参数是候选分支,内部参数是下一输入层级;每个值可作为 args 变量使用。',
'Siblings are alternative branches and contained arguments form the next input level; each value becomes an args variable.')
: l('只有当前参数匹配后,才会解析这里的参数。', 'These arguments are parsed only after this argument matches.')}</small></span>
<div className="button-row"><Button type="button" variant="ghost"
title={root ? l('粘贴为同级根参数', 'Paste as a root sibling') : l('粘贴为同级下层参数', 'Paste as a child sibling')}
aria-label={root ? l('粘贴根参数', 'Paste root argument') : l('粘贴下层参数', 'Paste child argument')}
onClick={() => void pasteTriggerModule<TriggerCommandArgument>('argument').then((argument) => {
if (argument) onChange([...values, argument]);
})}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost"
onClick={add}><Plus size={14} />{root ? l('添加参数', 'Add argument') : l('添加下级参数', 'Add child argument')}</Button></div></div>
<div className="trigger-argument-list">{values.map((argument, index) => {
const currentPath = [...path, index + 1];
const ordinal = ordinalOffset + 1 + values.slice(0, index)
.reduce((total, item) => total + 1 + countCommandArgumentNodes(item.children ?? []), 0);
return <CommandArgumentCard key={index} value={argument} catalog={catalog} zh={zh}
path={currentPath} ordinal={ordinal} duplicateNames={duplicates} position={index} count={values.length} depth={depth + 1} totalNodes={totalNodes}
onChange={(next) => onChange(replace(values, index, next))}
onMove={(offset) => onChange(move(values, index, offset))}
onDelete={() => onChange(remove(values, index))} />;
})}
{!values.length && <div className="trigger-empty">{root
? l('没有参数;该指令仅匹配指令根。', 'No arguments; this command matches the command root only.')
: l('没有后续参数。', 'No following argument.')}</div>}
</div>
</div>;
}
function CommandArgumentCard({ value, catalog, zh, path, ordinal, duplicateNames, position, count, depth, totalNodes,
onChange, onMove, onDelete }: {
value: TriggerCommandArgument; catalog: TriggerCatalog; zh: boolean; path: number[]; ordinal: number;
duplicateNames: Set<string>; position: number;
count: number; depth: number; totalNodes: number; onChange: (value: TriggerCommandArgument) => void;
onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const name = l(`参数 ${path.join('.')}`, `Argument ${path.join('.')}`);
const types = catalog.commandArgumentTypes ?? fallbackCatalog.commandArgumentTypes ?? [];
return <article className="trigger-argument-card" aria-label={name}>
<div className="trigger-action-card__head"><GripVertical size={14} /><strong>{name}</strong>
<div className="button-row"><ModuleCopyButton kind="argument" value={value} zh={zh} name={name} />
<ActionMoveButtons zh={zh} name={name} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-argument-card__body">
<Field label={l('变量名称', 'Variable name')} hint={`{args.${value.name || 'arg'}}`}><Input
aria-label={zh ? `指令参数 ${ordinal}` : `Command argument ${ordinal}`} value={value.name} pattern="[a-z][a-z0-9_-]{0,31}"
aria-invalid={!commandPartPattern.test(value.name) || duplicateNames.has(value.name)}
maxLength={32} spellCheck={false} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('参数类型', 'Argument type')}><Select aria-label={`${name} ${l('类型', 'type')}`}
value={value.type} onChange={(event) => onChange({ ...value, type: event.target.value })}>
{types.map((type) => <option key={type} value={type}>{commandArgumentTypeLabel(type, zh)}</option>)}</Select></Field>
{value.type === 'literal' && <Field label={l('固定值', 'Literal value')}><Input value={value.literal}
maxLength={128} onChange={(event) => onChange({ ...value, literal: event.target.value })} /></Field>}
{['integer', 'long', 'float', 'double', 'time'].includes(value.type) && <><Field label={l('最小值(可空)', 'Minimum (optional)')}>
<Input inputMode="decimal" value={value.minimum} onChange={(event) => onChange({ ...value, minimum: event.target.value })} /></Field>
<Field label={l('最大值(可空)', 'Maximum (optional)')}><Input inputMode="decimal" value={value.maximum}
onChange={(event) => onChange({ ...value, maximum: event.target.value })} /></Field></>}
<Field label={l('候选值(中英文逗号分隔)', 'Suggestions (comma separated)')}><SuggestionListInput
value={value.suggestions}
placeholder={['player', 'players'].includes(value.type) ? l('在线玩家会由游戏自动补全', 'Online players are suggested by the game') : ''}
onChange={(suggestions) => onChange({ ...value, suggestions })} /></Field>
<Field label={l('输入错误提示', 'Invalid-value message')}><Input value={value.errorMessage} maxLength={256}
placeholder={l('留空则使用默认的类型错误提示', 'Leave empty to use the default type error')}
onChange={(event) => onChange({ ...value, errorMessage: event.target.value })} /></Field>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={value.optional}
onChange={(event) => onChange({ ...value, optional: event.target.checked })} /><span><strong>{l('此层级可选', 'Optional at this level')}</strong>
<small>{l('允许在输入该参数前结束指令。', 'Allows the command to end before this argument.')}</small></span></label>
</div>
<CommandArgumentTreeEditor arguments={value.children} catalog={catalog} zh={zh} depth={depth}
path={path} totalNodes={totalNodes} ordinalOffset={ordinal}
duplicateNames={duplicateNames}
onChange={(children) => onChange({ ...value, children })} />
</article>;
}
function SuggestionListInput({ value, placeholder, onChange }: {
value: string[]; placeholder?: string; onChange: (value: string[]) => void;
}) {
const signature = value.join('\u0000');
const lastEmitted = useRef(signature);
const [draft, setDraft] = useState(value.join(', '));
useEffect(() => {
if (signature === lastEmitted.current) return;
lastEmitted.current = signature;
setDraft(value.join(', '));
}, [signature, value]);
const parse = (raw: string) => raw.split(/[,,]/u).map((item) => item.trim()).filter(Boolean);
return <Input value={draft} placeholder={placeholder} onChange={(event) => {
const raw = event.target.value;
const suggestions = parse(raw);
setDraft(raw);
lastEmitted.current = suggestions.join('\u0000');
onChange(suggestions);
}} onBlur={() => setDraft(parse(draft).join(', '))} />;
}
function TriggerVariablesEditor({ values, liveValues, types, zh, onChange }: {
values: TriggerStateVariableDefinition[]; liveValues: Record<string, unknown>; types: string[]; zh: boolean;
onChange: (values: TriggerStateVariableDefinition[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const add = (visibility: 'trigger' | 'global') => {
let index = values.length + 1;
let name = index === 1 ? 'value' : `value${index}`;
while (values.some((entry) => entry.name === name)) name = `value${++index}`;
onChange([...values, { name, type: 'string', initialValue: '', visibility,
storage: visibility === 'global' ? 'server' : 'trigger', lifetime: 'session', revision: 0 }]);
};
const renderGroup = (visibility: 'trigger' | 'global') => {
const groupValues = values.map((variable, index) => ({ variable, index }))
.filter(({ variable }) => (variable.visibility ?? 'trigger') === visibility);
const title = visibility === 'global' ? l('全局变量', 'Global variables') : l('触发器变量', 'Trigger variables');
const description = visibility === 'global'
? l('通过 {global.名称} 在所有触发器中访问;声明名称在整个服务器中唯一。',
'Use {global.name} from every trigger; declaration names are unique across the server.')
: l('通过 {var.名称} 访问,仅属于当前触发器;旧版变量会自动迁移到这里。',
'Use {var.name} in this trigger only; legacy variables migrate here automatically.');
const paste = async () => {
const variable = await pasteTriggerModule<TriggerStateVariableDefinition>('variable');
if (!variable) return;
onChange([...values, { ...variable, visibility,
storage: variable.storage ?? (visibility === 'global' ? 'server' : 'trigger'),
lifetime: variable.lifetime ?? 'session', revision: variable.revision ?? 0 }]);
};
return <section className="trigger-variable-group" key={visibility}>
<div className="trigger-command-arguments__heading"><span><strong>{title}</strong><small>{description}</small></span>
<div className="button-row"><Button type="button" variant="ghost"
title={l(`粘贴${title}`, `Paste ${title}`)} aria-label={l(`粘贴${title}`, `Paste ${title}`)}
onClick={() => void paste()}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost" onClick={() => add(visibility)}>
<Plus size={14} />{l('添加变量', 'Add variable')}</Button></div></div>
<div className="trigger-variable-list">{groupValues.map(({ variable, index }) => {
const duplicate = values.some((entry, current) => current !== index
&& (entry.visibility ?? 'trigger') === visibility && entry.name === variable.name);
const key = visibility === 'global' ? `global.${variable.name}` : variable.name;
const template = visibility === 'global' ? `{global.${variable.name || 'name'}}` : `{var.${variable.name || 'name'}}`;
const current = Object.prototype.hasOwnProperty.call(liveValues, key)
? liveValues[key] : variable.initialValue;
return <article className="trigger-variable-card" key={index}>
<div className="trigger-variable-card__fields">
<Field label={l('变量名称', 'Variable name')} hint={template}><Input
value={variable.name} maxLength={48} pattern="[A-Za-z_][A-Za-z0-9_-]{0,47}"
aria-invalid={!triggerVariableNameIsValid(variable.name) || duplicate}
onChange={(event) => onChange(replace(values, index, { ...variable, name: event.target.value }))} /></Field>
<Field label={l('值类型(支持嵌套泛型)', 'Value type (nested generics supported)')}>
<VariableTypeEditor value={variable.type} types={types} zh={zh} onChange={(type) =>
onChange(replace(values, index, { ...variable, type, initialValue: defaultTriggerVariableValue(type) }))} />
</Field>
<Field label={l('存储位置', 'Storage scope')}><Select value={variable.storage ?? (visibility === 'global' ? 'server' : 'trigger')}
onChange={(event) => onChange(replace(values, index, { ...variable,
storage: event.target.value as TriggerStateVariableDefinition['storage'] }))}>
<option value="server">{l('服务器:单一实例', 'Server: one instance')}</option>
<option value="player">{l('触发玩家:每名玩家独立', 'Event player: per player')}</option>
<option value="dimension">{l('维度:每个维度独立', 'Dimension: per dimension')}</option>
<option value="trigger">{l('执行触发器:每个触发器独立', 'Executing trigger: per trigger')}</option>
<option value="execution">{l('执行实例:每次运行独立', 'Execution: per run')}</option>
<option value="chunk">{l('区块:每个区块独立', 'Chunk: per chunk')}</option>
<option value="entity">{l('实体:每个 UUID 独立', 'Entity: per UUID')}</option>
</Select></Field>
<Field label={l('生命周期', 'Lifetime')}><Select value={variable.lifetime ?? 'session'}
onChange={(event) => onChange(replace(values, index, { ...variable,
lifetime: event.target.value as TriggerStateVariableDefinition['lifetime'],
ttlSeconds: event.target.value === 'ttl' ? (variable.ttlSeconds ?? 3_600) : null }))}>
<option value="session">{l('会话:重启后重置', 'Session: reset on restart')}</option>
<option value="ttl">{l('TTL:到期后重置', 'TTL: reset after expiry')}</option>
<option value="persistent">{l('持久:保留至修改', 'Persistent: retain until changed')}</option>
</Select></Field>
{(variable.lifetime ?? 'session') === 'ttl' && <Field label={l('有效期(秒)', 'TTL seconds')}>
<Input type="number" min={1} max={31_536_000} value={variable.ttlSeconds ?? 3_600}
onChange={(event) => onChange(replace(values, index, { ...variable,
ttlSeconds: Number(event.target.value) }))} /></Field>}
<Field label={l('初始值', 'Initial value')} hint={variableTypeIsContainer(variable.type)
? l('数组使用 JSON [],字典使用 JSON {};嵌套值按泛型严格校验。',
'Use JSON [] for arrays and {} for dictionaries; nested values are strictly typed.') : undefined}>
{variableTypeIsContainer(variable.type) ? <Textarea value={variable.initialValue} spellCheck={false}
onChange={(event) => onChange(replace(values, index, { ...variable, initialValue: event.target.value }))} />
: <Input value={variable.initialValue} inputMode={['integer', 'float'].includes(variable.type) ? 'decimal' : undefined}
onChange={(event) => onChange(replace(values, index, { ...variable, initialValue: event.target.value }))} />}
</Field>
<div className="trigger-variable-current"><span>{l('当前值', 'Current value')}</span>
<code title={formatLiveVariable(current)}>{formatLiveVariable(current)}</code></div>
</div>
<div className="button-row"><ModuleCopyButton kind="variable" value={variable} zh={zh} name={`${title} ${variable.name}`} />
<Button type="button" variant="ghost" onClick={() => onChange(remove(values, index))}
aria-label={l(`删除变量 ${variable.name}`, `Delete variable ${variable.name}`)}><Trash2 size={14} /></Button></div>
</article>;
})}
{!groupValues.length && <div className="trigger-empty">{l('此分类还没有变量。', 'No variables in this category.')}</div>}
</div>
</section>;
};
return <div className="trigger-variable-editor">
<p className="field__hint">{l('当前值每秒刷新;持久与 TTL 写入在后台有界批处理,并使用修订号防止覆盖并发修改。',
'Live values refresh every second. Persistent and TTL writes are bounded, batched off-thread, and revision protected.')}</p>
{renderGroup('global')}{renderGroup('trigger')}
</div>;
}
interface VariableTypeNode { name: string; arguments: VariableTypeNode[] }
function parseVariableTypeNode(value: string): VariableTypeNode {
let position = 0;
const parse = (): VariableTypeNode => {
while (/\s/.test(value[position] ?? '')) position += 1;
const start = position;
while (/[A-Za-z0-9_]/.test(value[position] ?? '')) position += 1;
const name = value.slice(start, position) || 'string';
const args: VariableTypeNode[] = [];
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === '<') {
position += 1; args.push(parse());
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === ',') { position += 1; args.push(parse()); }
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === '>') position += 1;
}
return { name, arguments: args };
};
try { return parse(); } catch { return { name: 'string', arguments: [] }; }
}
function serializeVariableTypeNode(value: VariableTypeNode): string {
return value.arguments.length ? `${value.name}<${value.arguments.map(serializeVariableTypeNode).join(',')}>` : value.name;
}
function VariableTypeEditor({ value, types, zh, onChange, depth = 0, allowContainers = true }: {
value: string; types: string[]; zh: boolean; depth?: number; allowContainers?: boolean; onChange: (value: string) => void;
}) {
const node = parseVariableTypeNode(value);
const scalarTypes = [...new Set(types.filter((type) => !['array', 'set', 'optional', 'dictionary', 'list'].includes(type) && !type.includes('<')))];
const choices = !allowContainers ? scalarTypes
: [...scalarTypes, 'array', 'set', 'optional', 'dictionary', ...(depth === 0 && types.includes('list') ? ['list'] : [])];
const changeName = (name: string) => {
const next: VariableTypeNode = ['array', 'set', 'optional'].includes(name)
? { name, arguments: [{ name: 'string', arguments: [] }] }
: name === 'dictionary' ? { name, arguments: [{ name: 'string', arguments: [] }, { name: 'string', arguments: [] }] }
: { name, arguments: [] };
onChange(serializeVariableTypeNode(next));
};
const changeArgument = (index: number, next: string) => {
const args = [...node.arguments]; args[index] = parseVariableTypeNode(next);
onChange(serializeVariableTypeNode({ ...node, arguments: args }));
};
return <div className="trigger-variable-type-editor">
<Select aria-label={zh ? `泛型第 ${depth + 1} 层类型` : `Generic type level ${depth + 1}`}
value={node.name} onChange={(event) => changeName(event.target.value)}>
{choices.map((type) => <option value={type} key={type}>{triggerVariableTypeLabel(type, zh)}</option>)}</Select>
{['array', 'set', 'optional'].includes(node.name) && <div className="trigger-variable-generic"><span>
{node.name === 'optional' ? (zh ? '内部值' : 'Inner value') : (zh ? '元素' : 'Element')}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[0] ?? { name: 'string', arguments: [] })}
types={types} zh={zh} depth={depth + 1} onChange={(next) => changeArgument(0, next)} /></div>}
{node.name === 'dictionary' && <div className="trigger-variable-generic-grid"><span>{zh ? '键' : 'Key'}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[0] ?? { name: 'string', arguments: [] })}
types={scalarTypes} zh={zh} depth={depth + 1} allowContainers={false} onChange={(next) => changeArgument(0, next)} />
<span>{zh ? '值' : 'Value'}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[1] ?? { name: 'string', arguments: [] })}
types={types} zh={zh} depth={depth + 1} onChange={(next) => changeArgument(1, next)} /></div>}
</div>;
}
const triggerVariableTypeLabels: Record<string, [string, string]> = {
bool: ['布尔值', 'Boolean'], integer: ['整型', 'Integer'], float: ['浮点数(兼容整型)', 'Float'],
string: ['字符串', 'String'], coordinate: ['坐标', 'Coordinates'], uuid: ['UUID', 'UUID'],
player: ['玩家名称', 'Player name'], resource_location: ['资源位置', 'Resource location'],
block_state: ['方块状态', 'Block state'], item_stack: ['物品堆', 'Item stack'],
component: ['文本组件', 'Text component'], nbt: ['NBT', 'NBT'], list: ['旧版字符串列表', 'Legacy string list'],
position: ['位置', 'Position'], rotation: ['旋转', 'Rotation'], duration: ['时长', 'Duration'],
instant: ['时间点', 'Instant'], region_ref: ['区域引用', 'Region reference'],
player_ref: ['玩家引用', 'Player reference'], entity_ref: ['实体引用', 'Entity reference'],
block_ref: ['方块引用', 'Block reference'], item_ref: ['物品引用', 'Item reference'],
record: ['记录', 'Record'], array: ['数组', 'Array'], set: ['集合', 'Set'],
optional: ['可选值', 'Optional'], dictionary: ['字典', 'Dictionary'],
};
function triggerVariableTypeLabel(type: string, zh: boolean): string {
return `${label(triggerVariableTypeLabels[type], zh, type)} · ${type}`;
}
function triggerVariableNameIsValid(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test(value);
}
function defaultTriggerVariableValue(type: string): string {
if (type.startsWith('array<') || type.startsWith('set<') || type === 'array' || type === 'set') return '[]';
if (type.startsWith('optional<') || type === 'optional') return 'null';
if (type.startsWith('dictionary<') || type === 'dictionary') return '{}';
if (type === 'bool') return 'false';
if (type === 'integer' || type === 'float') return '0';
if (['uuid', 'player_ref', 'entity_ref'].includes(type)) return '00000000-0000-0000-0000-000000000000';
if (type === 'coordinate') return '0 0 0';
if (type === 'rotation') return '0 0';
if (type === 'position' || type === 'block_ref') return 'minecraft:overworld 0 0 0';
if (type === 'duration') return 'PT0S';
if (type === 'instant') return '1970-01-01T00:00:00Z';
if (type === 'player') return 'Player';
if (['resource_location', 'region_ref', 'item_ref'].includes(type)) return 'minecraft:stone';
if (type === 'block_state') return 'minecraft:stone';
if (type === 'item_stack') return 'minecraft:stone';
if (type === 'component') return '{"text":""}';
if (type === 'nbt' || type === 'record') return '{}';
return '';
}
function variableTypeIsContainer(type: string): boolean {
return /^(?:array|set|optional|dictionary)</.test(type) || ['array', 'set', 'optional', 'dictionary', 'record'].includes(type);
}
function formatLiveVariable(value: unknown): string {
if (Array.isArray(value)) return value.join(', ');
if (value !== null && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
function sampleSimulationContext(event: TriggerEventSpec, catalog: TriggerCatalog): Record<string, unknown> {
const result: Record<string, unknown> = { 'event.type': event.type };
(catalog.variables ?? []).filter((variable) => variable.sensitive !== true
&& variable.conditionAllowed !== false && variableAppliesToEvent(variable, event.type))
.forEach((variable) => {
if (variable.sampleValue === undefined) return;
const key = normalizedVariableKey(variable.key);
const sample = variable.sampleValue;
if (variable.type === 'bool') result[key] = sample === 'true';
else if (['integer', 'float', 'number'].includes(variable.type ?? '') && Number.isFinite(Number(sample))) {
result[key] = Number(sample);
} else result[key] = sample;
});
return result;
}
function ConditionRow({ value, catalog, eventType, position, count, zh, onChange, onMove, onDelete }: {
value: TriggerCondition; catalog: TriggerCatalog; eventType: string; position: number; count: number; zh: boolean;
onChange: (value: TriggerCondition) => void; onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
const number = position + 1;
const prefix = zh ? `条件 ${number}` : `Condition ${number}`;
return <div className="trigger-condition-row" role="group" aria-label={prefix}><GripVertical size={14} />
<ConditionFields value={value} catalog={catalog} eventType={eventType} zh={zh} prefix={prefix} onChange={onChange} />
<div className="trigger-row-actions">
<ModuleCopyButton kind="condition" value={value} zh={zh} name={prefix} />
<Button type="button" variant="ghost" disabled={position === 0} onClick={() => onMove(-1)}
aria-label={zh ? `上移条件 ${number}` : `Move condition ${number} up`}><ArrowUp size={14} /></Button>
<Button type="button" variant="ghost" disabled={position === count - 1} onClick={() => onMove(1)}
aria-label={zh ? `下移条件 ${number}` : `Move condition ${number} down`}><ArrowDown size={14} /></Button>
<Button type="button" variant="ghost" onClick={onDelete}
aria-label={zh ? `删除条件 ${number}` : `Delete condition ${number}`}><Trash2 size={14} /></Button>
</div></div>;
}
function ConditionFields({ value, catalog, eventType, zh, prefix, onChange }: {
value: TriggerCondition; catalog: TriggerCatalog; eventType: string; zh: boolean; prefix: string;
onChange: (value: TriggerCondition) => void;
}) {
const operatorGroups = groupConditionOperators(catalog.operators);
const valueKind = conditionValueKind(value.operator);
const fieldDescription = conditionFieldLabel(value.field, zh, catalog);
const fieldWarning = conditionFieldWarning(value.field, value.operator, eventType, catalog, zh);
const warningId = `${prefix.replace(/\s+/g, '-')}-field-warning`;
return <><div className="field"><Input aria-label={`${prefix} ${zh ? '字段' : 'field'}`}
aria-describedby={fieldWarning ? `trigger-condition-help ${warningId}` : 'trigger-condition-help'}
aria-invalid={fieldWarning ? true : undefined} list="trigger-fields" value={value.field}
placeholder={zh ? '选择或输入事件字段' : 'Choose or enter an event field'}
title={`${fieldDescription} · ${value.field}`} onChange={(event) => onChange({ ...value, field: event.target.value })} />
{fieldWarning && <span className="form-error" id={warningId} role="status">{fieldWarning}</span>}</div>
<Select aria-label={`${prefix} ${zh ? '运算符' : 'operator'}`} value={value.operator}
aria-describedby="trigger-condition-help"
onChange={(event) => onChange({ ...value, operator: event.target.value })}>{operatorGroups.map((group) =>
<optgroup key={group.category} label={label(group.labels, zh, group.category)}>{group.operators.map((operator) =>
<option value={operator} key={operator}>{conditionOperatorLabel(operator, zh)}</option>)}</optgroup>)}</Select>
{valueKind !== 'none' && <Input aria-label={`${prefix} ${zh ? '值' : 'value'}`}
aria-describedby="trigger-condition-help" value={value.value}
placeholder={conditionValuePlaceholder(value.operator, value.field, zh)}
onChange={(event) => onChange({ ...value, value: event.target.value })} />}</>;
}
function ActionTreeEditor({ actions, catalog, eventType, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
depth, path, totalNodes, root = false, onChange }: {
actions: TriggerAction[]; catalog: TriggerCatalog; eventType: string; canExecuteCommands: boolean;
defaultSenderName: string; triggerChoices: Array<{ id: string; name: string }>;
menuChoices: Array<{ id: string; name: string }>;
variables: string[]; variableCatalog: readonly TriggerVariableDefinition[];
zh: boolean; depth: number; path: number[];
totalNodes: number; root?: boolean; onChange: (actions: TriggerAction[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const containerName = root ? l('根层', 'root') : l(`条件操作 ${path.join('.')} 内`, `condition ${path.join('.')}`);
const appendAction = () => {
const type = actionCompatibleWithEvent('send_player', eventType) ? 'send_player' : 'broadcast';
onChange([...actions, defaultAction(type)]);
};
return <div className={`trigger-action-tree${root ? ' trigger-action-tree--root' : ''}`}>
<div className="trigger-action-tree__toolbar"><span>{root
? l('根层操作', 'Root actions') : l('条件通过后执行', 'Runs when this condition matches')}</span>
<div className="button-row">
<Button type="button" variant="ghost" title={l(`粘贴操作到${containerName}`, `Paste action into ${containerName}`)}
aria-label={l(`粘贴操作到${containerName}`, `Paste action into ${containerName}`)}
onClick={() => void pasteTriggerModule<TriggerAction>('action').then((action) => {
if (action) onChange([...actions, action]);
})}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost" onClick={appendAction}
aria-label={l(`在${containerName}添加操作`, `Add action to ${containerName}`)}><Plus size={14} />{l('添加操作', 'Add action')}</Button>
<Button type="button" variant="ghost"
onClick={() => onChange([...actions, defaultConditionAction()])}
aria-label={l(`在${containerName}添加条件`, `Add condition to ${containerName}`)}><Plus size={14} />{l('添加条件', 'Add condition')}</Button>
</div>
</div>
<div className="trigger-block-list">{actions.map((action, index) => {
const nodePath = [...path, index + 1];
const common = { value: action, catalog, eventType, canExecuteCommands, defaultSenderName,
triggerChoices, menuChoices, variables, variableCatalog, zh, path: nodePath, position: index, count: actions.length,
onChange: (next: TriggerAction) => onChange(replace(actions, index, next)),
onMove: (offset: -1 | 1) => onChange(move(actions, index, offset)),
onDelete: () => onChange(remove(actions, index)) };
return action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE
? <ConditionActionCard key={index} {...common} depth={depth + 1} totalNodes={totalNodes} />
: <ActionCard key={index} {...common} />;
})}
{!actions.length && <div className="trigger-empty is-danger">{root
? l('至少需要一个可执行操作。', 'At least one executable action is required.')
: l('该条件至少需要一个内部操作。', 'This condition needs at least one contained action.')}</div>}
</div>
</div>;
}
interface ActionNodeEditorProps {
value: TriggerAction; catalog: TriggerCatalog; eventType: string; canExecuteCommands: boolean; zh: boolean;
path: number[]; position: number; count: number; defaultSenderName: string; variables: string[];
triggerChoices: Array<{ id: string; name: string }>;
menuChoices: Array<{ id: string; name: string }>;
variableCatalog: readonly TriggerVariableDefinition[];
onChange: (value: TriggerAction) => void; onMove: (offset: -1 | 1) => void; onDelete: () => void;
}
function ConditionActionCard({ value, catalog, eventType, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
path, position, count, depth, totalNodes, onChange, onMove, onDelete }: ActionNodeEditorProps & {
depth: number; totalNodes: number;
}) {
const pathLabel = path.join('.');
const prefix = zh ? `条件操作 ${pathLabel}` : `Action condition ${pathLabel}`;
const condition: TriggerCondition = { field: value.parameters.field ?? '',
operator: value.parameters.operator ?? '', value: value.parameters.value ?? '' };
const children = actionChildren(value);
return <article className="trigger-action-condition" aria-label={prefix}>
<div className="trigger-action-condition__head"><GripVertical size={14} /><span><strong>{prefix}</strong>
<small>{zh ? '判断通过后才执行内部节点' : 'Contained nodes run only when this matches'}</small></span>
<div className="button-row"><ModuleCopyButton kind="action" value={value} zh={zh} name={prefix} />
<ActionMoveButtons zh={zh} name={prefix} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-action-condition__fields">
<ConditionFields value={condition} catalog={catalog} eventType={eventType} zh={zh} prefix={prefix}
onChange={(next) => onChange({ ...value, type: CONDITION_ACTION_TYPE,
parameters: { field: next.field, operator: next.operator, value: next.value }, children })} />
</div>
<ActionTreeEditor actions={children} catalog={catalog} eventType={eventType}
canExecuteCommands={canExecuteCommands} defaultSenderName={defaultSenderName}
triggerChoices={triggerChoices} menuChoices={menuChoices}
variables={variables} variableCatalog={variableCatalog} zh={zh} depth={depth} path={path} totalNodes={totalNodes}
onChange={(next) => onChange({ ...value, children: next })} />
</article>;
}
function ActionCard({ value, catalog, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
path, position, count, onChange, onMove, onDelete }: ActionNodeEditorProps) {
const pathLabel = path.join('.');
const parameters = catalog.actionParameters[value.type] ?? fallbackCatalog.actionParameters[value.type] ?? ['message'];
const visibleParameters = parameters.filter((parameter) => {
if (value.type === 'variable') {
if (['name', 'operation'].includes(parameter)) return true;
const operation = value.parameters.operation ?? 'set';
if (containsWellFormedVariable(operation)) return ['value', 'extra'].includes(parameter);
if (['increment', 'decrement', 'trim', 'upper', 'lower', 'escape_json', 'escape_command',
'escape_regex', 'toggle', 'clear'].includes(operation)) return false;
return parameter === 'value' || (parameter === 'extra'
&& ['replace', 'regex_replace', 'array_insert', 'array_set', 'dictionary_put'].includes(operation));
}
if (value.type !== 'wait') return true;
if (['mode', 'timeout', 'pollTicks'].includes(parameter)) return true;
if (containsWellFormedVariable(value.parameters.mode ?? '')) {
return ['value', 'field', 'operator', 'expected'].includes(parameter);
}
return (value.parameters.mode ?? 'duration') === 'condition'
? ['field', 'operator', 'expected'].includes(parameter) : parameter === 'value';
});
const actionTypes = catalog.actions.includes(value.type) ? catalog.actions : [value.type, ...catalog.actions];
const changeType = (type: string) => onChange({ type, parameters: defaultAction(type).parameters });
const name = zh ? `操作 ${pathLabel}` : `Action ${pathLabel}`;
const parameterHint = ['{player.name}', '{server.online}', '{date}', ...variables].join(' · ');
const declaredVariables = variables.filter((entry) => /^\{(?:var|global)\.[A-Za-z_][A-Za-z0-9_-]*}$/.test(entry))
.map((entry) => ({ value: entry.startsWith('{global.') ? entry.slice(1, -1) : entry.slice(5, -1), token: entry }));
const setParameter = (parameter: string, next: string) => onChange({ ...value,
parameters: { ...value.parameters, [parameter]: next } });
return <article className="trigger-action-card" aria-label={name}>
<div className="trigger-action-card__head"><GripVertical size={14} /><Select aria-label={`${name} ${zh ? '类型' : 'type'}`}
value={value.type} onChange={(event) => changeType(event.target.value)}>
{actionTypes.filter((action) => canExecuteCommands || !commandActions.has(action) || action === value.type)
.map((action) => <option value={action} key={action} disabled={!canExecuteCommands && commandActions.has(action)}>
{label(actionLabels[action], zh, action)}{!canExecuteCommands && commandActions.has(action) ? ` · Owner` : ''}
</option>)}</Select>
<div className="button-row"><ModuleCopyButton kind="action" value={value} zh={zh} name={name} />
<ActionMoveButtons zh={zh} name={name} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-action-card__body">{visibleParameters.map((parameter) => <Field key={parameter} label={actionParameterLabel(parameter, zh)}
hint={parameter === 'message' || parameter === 'command' ? parameterHint : undefined}>
{parameter === 'message' && value.type !== 'log' ? <RichTextEditor compact value={value.parameters[parameter] ?? ''}
defaultSenderName={defaultSenderName} variables={variables} variableCatalog={variableCatalog}
onChange={(next) => setParameter(parameter, next)} />
: value.type === 'variable' && parameter === 'name' ? <Select value={value.parameters.name ?? ''}
onChange={(event) => setParameter('name', event.target.value)}><option value="">{zh ? '选择已声明变量' : 'Select a declared variable'}</option>
{declaredVariables.map((entry) => <option value={entry.value} key={entry.token}>{entry.token}</option>)}</Select>
: value.type === 'variable' && parameter === 'operation' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-operation`} parameter={parameter} zh={zh}
value={value.parameters.operation ?? 'set'} variables={variables} variableCatalog={variableCatalog}
choices={variableOperations.map((operation) => ({ value: operation, label: variableOperationLabel(operation, zh) }))}
onValueChange={(next) => setParameter('operation', next)} />
: value.type === 'wait' && parameter === 'mode' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-mode`} parameter={parameter} zh={zh}
value={value.parameters.mode ?? 'duration'} variables={variables} variableCatalog={variableCatalog}
choices={waitModes.map((mode) => ({ value: mode, label: waitModeLabel(mode, zh) }))}
onValueChange={(next) => setParameter('mode', next)} />
: value.type === 'wait' && parameter === 'operator' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-operator`} parameter={parameter} zh={zh}
value={value.parameters.operator ?? 'eq'} variables={variables} variableCatalog={variableCatalog}
choices={catalog.operators.map((operator) => ({ value: operator, label: conditionOperatorLabel(operator, zh) }))}
onValueChange={(next) => setParameter('operator', next)} />
: value.type === 'run_trigger' && parameter === 'triggerId' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-trigger`} parameter={parameter} zh={zh}
value={value.parameters.triggerId ?? ''} variables={variables} variableCatalog={variableCatalog}
choices={triggerChoices.filter((trigger) => trigger.id)
.map((trigger) => ({ value: trigger.id, label: `${trigger.name} · ${trigger.id}` }))}
onValueChange={(next) => setParameter('triggerId', next)} />
: value.type === 'open_menu' && parameter === 'menuId' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-menu`} parameter={parameter} zh={zh}
value={value.parameters.menuId ?? ''} variables={variables} variableCatalog={variableCatalog}
choices={menuChoices.filter((menu) => menu.id)
.map((menu) => ({ value: menu.id, label: `${menu.name} · ${menu.id}` }))}
onValueChange={(next) => setParameter('menuId', next)} />
: value.type === 'run_trigger' && parameter === 'waitForCompletion' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-completion`} parameter={parameter} zh={zh}
value={value.parameters.waitForCompletion ?? 'false'} variables={variables} variableCatalog={variableCatalog}
choices={[{ value: 'false', label: zh ? '启动后继续' : 'Continue after starting' },
{ value: 'true', label: zh ? '等待执行完成' : 'Wait for completion' }]}
onValueChange={(next) => setParameter('waitForCompletion', next)} />
: ['server_command', 'player_command'].includes(value.type) && parameter === 'showFeedback'
? <VariableChoiceInput id={`trigger-choice-${path.join('-')}-feedback`}
parameter={parameter} zh={zh} value={value.parameters.showFeedback ?? 'false'}
variables={variables} variableCatalog={variableCatalog}
choices={[{ value: 'false', label: zh ? '关闭(推荐)' : 'Off (recommended)' },
{ value: 'true', label: zh ? '显示' : 'Show' }]}
onValueChange={(next) => setParameter('showFeedback', next)} />
: value.type === 'title' && parameter === 'numberPrecision' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-number-precision`} parameter={parameter} zh={zh}
value={value.parameters.numberPrecision ?? '2'} variables={variables}
variableCatalog={variableCatalog}
choices={Array.from({ length: 11 }, (_, digits) => ({
value: String(digits), label: `${digits} ${zh ? '位小数' : 'fraction digits'}`,
}))}
onValueChange={(next) => setParameter('numberPrecision', next)} />
: <VariableTextInput parameter={parameter} zh={zh}
list={parameter === 'currency' ? 'trigger-economy-currencies' : undefined}
inputMode={['volume', 'pitch'].includes(parameter) ? 'decimal'
: parameter === 'amount' ? 'decimal'
: ['fadeIn', 'stay', 'fadeOut', 'count', 'maxCount', 'duration', 'amplifier'].includes(parameter)
? 'numeric' : undefined}
spellCheck={!['command', 'destination', 'item', 'sound', 'effect', 'player', 'source', 'target'].includes(parameter)}
value={value.parameters[parameter] ?? ''} variables={variables} variableCatalog={variableCatalog}
onValueChange={(next) => setParameter(parameter, next)} />}
</Field>)}</div>
</article>;
}
function VariableChoiceInput({ id, value, parameter, choices, variables, variableCatalog, zh, onValueChange }: {
id: string; value: string; parameter: string; choices: Array<{ value: string; label: string }>;
variables: string[]; variableCatalog: readonly TriggerVariableDefinition[]; zh: boolean;
onValueChange: (value: string) => void;
}) {
return <><VariableTextInput list={id} parameter={parameter} value={value} variables={variables}
variableCatalog={variableCatalog} zh={zh} onValueChange={onValueChange} />
<datalist id={id}>{choices.map((choice) => <option key={choice.value}
value={choice.value} label={choice.label} />)}</datalist></>;
}
function ActionMoveButtons({ zh, name, position, count, onMove, onDelete }: {
zh: boolean; name: string; position: number; count: number;
onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
return <div className="trigger-row-actions">
<Button type="button" variant="ghost" disabled={position === 0} onClick={() => onMove(-1)}
aria-label={zh ? `上移${name}` : `Move ${name} up`}><ArrowUp size={14} /></Button>
<Button type="button" variant="ghost" disabled={position === count - 1} onClick={() => onMove(1)}
aria-label={zh ? `下移${name}` : `Move ${name} down`}><ArrowDown size={14} /></Button>
<Button type="button" variant="ghost" onClick={onDelete}
aria-label={zh ? `删除${name}` : `Delete ${name}`}><Trash2 size={14} /></Button>
</div>;
}
function regionConfiguration(shape: string): Record<string, string> {
const shared = { regionId: 'example.region', shape, dimension: 'minecraft:overworld', frequencyTicks: '20' };
if (shape === 'sphere') return { ...shared, centerX: '0', centerY: '64', centerZ: '0', radius: '8' };
if (shape === 'cylinder') return { ...shared, centerX: '0', centerZ: '0', radius: '8', minY: '0', maxY: '320' };
return { ...shared, shape: 'cuboid', minX: '-8', minY: '0', minZ: '-8', maxX: '8', maxY: '320', maxZ: '8' };
}
function defaultV2EventConfiguration(type: string): Record<string, string> {
if (type.startsWith('region.')) return regionConfiguration('cuboid');
return defaultEvent(type).configuration;
}
function defaultEvent(type: string): TriggerEventSpec {
if (type === 'schedule.daily') return { type, configuration: { time: '08:00', timezone: 'Asia/Shanghai' } };
if (type === 'schedule.interval') return { type, configuration: { seconds: '60' } };
if (type.startsWith('protection.')) {
const thresholds: Record<string, string> = {
'protection.item_overflow': '2000', 'protection.mob_overflow': '2000',
'protection.entity_overflow': '5000', 'protection.mod_entity_overflow': '512',
'protection.spawn_burst': '400', 'protection.command_block_rate': '100',
'protection.slow_tick': '200', 'protection.loaded_chunk_overflow': '8000',
'protection.memory_pressure': '90',
};
const configuration: Record<string, string> = {
threshold: thresholds[type] ?? '1', cooldownSeconds: '60',
};
if (['protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow'].includes(type)) configuration.scope = 'dimension';
if (type === 'protection.mod_entity_overflow') configuration.namespace = 'create';
if (type === 'protection.slow_tick') configuration.consecutive = '3';
return { type, configuration };
}
if (type === 'player.command_trigger') return { type, configuration: { command: '' }, arguments: [] };
return { type, configuration: {} };
}
function defaultConditionAction(): TriggerAction {
return { type: CONDITION_ACTION_TYPE,
parameters: { field: 'player.name', operator: 'eq', value: '' }, children: [] };
}
function actionChildren(action: TriggerAction): TriggerAction[] {
return Array.isArray(action.children) ? action.children : [];
}
function actionLeaves(actions: TriggerAction[]): TriggerAction[] {
return actions.flatMap((action) => action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE
? actionLeaves(actionChildren(action)) : [action, ...actionLeaves(actionChildren(action))]);
}
function countActionNodes(actions: TriggerAction[]): number {
return actions.reduce((total, action) => total + 1 + countActionNodes(actionChildren(action)), 0);
}
function mapActionTree(actions: TriggerAction[], transform: (action: TriggerAction) => TriggerAction): TriggerAction[] {
return actions.map((action) => {
if (action.type.trim().toLowerCase() !== CONDITION_ACTION_TYPE) return transform(action);
return { ...action, children: mapActionTree(actionChildren(action), transform) };
});
}
function commandArgumentNames(event: TriggerEventSpec): string[] {
if (event.type.trim().toLowerCase() !== 'player.command_trigger') return [];
if ((event.arguments?.length ?? 0) > 0) return flattenCommandArguments(event.arguments ?? []).map((item) => item.name);
const raw = event.configuration.arguments ?? '';
return raw.trim() ? raw.trim().split(/\s+/) : [];
}
function commandArgumentTree(event: TriggerEventSpec): TriggerCommandArgument[] {
if ((event.arguments?.length ?? 0) > 0) return event.arguments ?? [];
const legacy = (event.configuration.arguments ?? '').trim();
if (!legacy) return [];
let children: TriggerCommandArgument[] = [];
legacy.split(/\s+/).reverse().forEach((name) => {
children = [{ ...defaultCommandArgument(1), name, children }];
});
return children;
}
function flattenCommandArguments(values: TriggerCommandArgument[]): TriggerCommandArgument[] {
return values.flatMap((value) => [value, ...flattenCommandArguments(value.children ?? [])]);
}
function duplicateCommandArgumentNames(values: TriggerCommandArgument[]): Set<string> {
const counts = new Map<string, number>();
flattenCommandArguments(values).forEach((value) => counts.set(value.name, (counts.get(value.name) ?? 0) + 1));
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name));
}
function countCommandArgumentNodes(values: TriggerCommandArgument[]): number {
return values.reduce((total, value) => total + 1 + countCommandArgumentNodes(value.children ?? []), 0);
}
function defaultCommandArgument(index: number): TriggerCommandArgument {
return { name: index === 1 ? 'arg' : `arg${index}`, type: 'word', literal: '', optional: false,
errorMessage: '', minimum: '', maximum: '', suggestions: [], children: [] };
}
function commandArgumentPreview(values: TriggerCommandArgument[]): string {
if (!values.length) return '';
return ` ${values.map((value) => value.type === 'literal' ? value.literal || 'literal'
: `${value.optional ? '[' : '<'}${value.name || 'arg'}${value.type === 'word' ? '' : `:${value.type}`}${value.optional ? ']' : '>'}`
+ commandArgumentPreview(value.children ?? [])).join(' | ')}`;
}
const commandArgumentTypeLabels: Record<string, [string, string]> = {
literal: ['固定字面量', 'Literal'], bool: ['布尔值', 'Boolean'], integer: ['整型', 'Integer'],
long: ['长整型', 'Long integer'],
float: ['浮点数(兼容整型)', 'Float (accepts integers)'], double: ['双精度浮点数', 'Double'],
coordinate: ['单个坐标', 'Coordinate'], block_pos: ['方块坐标', 'Block position'],
column_pos: ['二维方块坐标', 'Column position'], vec2: ['二维坐标', '2D vector'], vec3: ['三维坐标', '3D vector'],
rotation: ['旋转角度', 'Rotation'], angle: ['角度', 'Angle'], string: ['可引号字符串', 'Quoted string'],
word: ['单词字符串', 'Single word'], greedy_string: ['剩余字符串', 'Greedy string'],
player: ['单个玩家', 'Single player'], players: ['多个玩家', 'Players'], entity: ['单个实体', 'Single entity'],
entities: ['多个实体', 'Entities'], game_profile: ['游戏档案', 'Game profile'],
block_state: ['方块状态', 'Block state'], block_predicate: ['方块条件', 'Block predicate'],
item_stack: ['物品堆', 'Item stack'], item_predicate: ['物品条件', 'Item predicate'],
color: ['聊天颜色', 'Chat color'], component: ['文本组件', 'Text component'], message: ['聊天消息', 'Message'],
nbt: ['NBT 复合标签', 'NBT compound'], nbt_tag: ['NBT 标签', 'NBT tag'], compound_tag: ['NBT 复合标签', 'Compound NBT tag'], nbt_path: ['NBT 路径', 'NBT path'],
objective: ['计分板目标', 'Objective'], objective_criteria: ['计分准则', 'Objective criteria'],
operation: ['计分操作', 'Score operation'], score_holder: ['计分持有者', 'Score holder'],
scoreboard_slot: ['计分板显示槽', 'Scoreboard slot'], swizzle: ['坐标轴组合', 'Axes/swizzle'], team: ['队伍', 'Team'],
int_range: ['整数区间', 'Integer range'], float_range: ['浮点区间', 'Float range'],
particle: ['粒子', 'Particle'], resource_location: ['资源位置', 'Resource location'], resource: ['注册表资源', 'Registry resource'],
resource_key: ['资源键', 'Resource key'], resource_or_tag: ['资源或标签', 'Resource or tag'],
resource_or_tag_key: ['资源键或标签键', 'Resource/tag key'], dimension: ['维度', 'Dimension'],
gamemode: ['游戏模式', 'Game mode'], time: ['时间', 'Time'], uuid: ['UUID', 'UUID'], function: ['函数', 'Function'],
entity_anchor: ['实体锚点', 'Entity anchor'], enchantment: ['附魔', 'Enchantment'], mob_effect: ['状态效果', 'Mob effect'],
item_slot: ['物品栏槽位', 'Item slot'], item_slots: ['物品栏槽位集合', 'Item slots'], template_mirror: ['结构镜像', 'Template mirror'],
template_rotation: ['结构旋转', 'Template rotation'], heightmap: ['高度图', 'Heightmap'],
loot_table: ['战利品表', 'Loot table'], loot_predicate: ['战利品条件', 'Loot predicate'],
loot_modifier: ['战利品修改器', 'Loot modifier'], biome: ['生物群系', 'Biome'], structure: ['结构', 'Structure'],
advancement: ['进度', 'Advancement'], recipe: ['配方', 'Recipe'],
};
function commandArgumentTypeLabel(type: string, zh: boolean): string {
return `${label(commandArgumentTypeLabels[type], zh, type)} · ${type}`;
}
function eventFieldSuggestions(event: TriggerEventSpec, catalog: TriggerCatalog): string[] {
const dynamic = commandArgumentNames(event).filter((argument) => commandPartPattern.test(argument))
.map((argument) => `args.${argument}`);
const triggerVariables = (event.variables ?? []).filter((variable) => triggerVariableNameIsValid(variable.name))
.map((variable) => (variable.visibility ?? 'trigger') === 'global'
? `global.${variable.name}` : `var.${variable.name}`);
if (!catalog.variables) return [...new Set([...baseFieldSuggestions, ...dynamic, ...triggerVariables])];
const catalogFields = catalog.variables
.filter((variable) => variable.sensitive !== true && variable.conditionAllowed !== false
&& ordinaryContextKey(variable.key) && variableAppliesToEvent(variable, event.type))
.map((variable) => normalizedVariableKey(variable.key));
return [...new Set([...catalogFields, ...dynamic, ...triggerVariables])];
}
function defaultAction(type: string): TriggerAction {
if (type === 'title') return { type, parameters: {
title: 'Welcome!', subtitle: '', fadeIn: '10', stay: '70', fadeOut: '20' } };
if (type === 'sound') return { type, parameters: { sound: 'minecraft:entity.experience_orb.pickup', volume: '1', pitch: '1' } };
if (type === 'server_command' || type === 'player_command') {
return { type, parameters: { command: '', showFeedback: 'false' } };
}
if (type === 'teleport') return { type, parameters: { destination: '0 80 0' } };
if (type === 'give_item') return { type, parameters: { item: 'minecraft:bread', count: '1' } };
if (type === 'clear_inventory') return { type, parameters: { item: '', maxCount: '' } };
if (type === 'set_gamemode') return { type, parameters: { gamemode: 'survival' } };
if (type === 'add_effect') return { type, parameters: { effect: 'minecraft:speed', duration: '30', amplifier: '0' } };
if (['remove_effects', 'heal', 'feed', 'whitelist_add', 'whitelist_remove', 'pardon'].includes(type)) return { type, parameters: {} };
if (type === 'set_time') return { type, parameters: { time: 'day' } };
if (type === 'set_weather') return { type, parameters: { weather: 'clear', duration: '300' } };
if (type === 'ban') return { type, parameters: { reason: '' } };
if (type === 'log') return { type, parameters: { message: '', level: 'info' } };
if (type === 'variable') return { type, parameters: { name: '', operation: 'set', value: '', extra: '' } };
if (type === 'wait') return { type, parameters: { mode: 'duration', value: '1', timeout: '0', pollTicks: '20',
field: 'var.value', operator: 'eq', expected: 'true' } };
if (type === 'run_trigger') return { type, parameters: { triggerId: '', waitForCompletion: 'false' } };
if (type === 'open_menu') return { type, parameters: { menuId: '' } };
if (type === 'close_menu') return { type, parameters: {} };
if (['economy_deposit', 'economy_withdraw'].includes(type)) return { type, parameters: { currency: 'coins', amount: '1', reason: 'Trigger economy operation' } };
if (type === 'economy_set_balance') return { type, parameters: { currency: 'coins', amount: '0', reason: 'Trigger economy operation' } };
if (type === 'economy_transfer') return { type, parameters: { currency: 'coins', amount: '1', target: '', reason: 'Trigger economy transfer' } };
if (['economy_deposit_player', 'economy_withdraw_player'].includes(type)) return { type,
parameters: { player: '', currency: 'coins', amount: '1', reason: 'Trigger global economy operation' } };
if (type === 'economy_set_player_balance') return { type,
parameters: { player: '', currency: 'coins', amount: '0', reason: 'Trigger global economy operation' } };
if (type === 'economy_transfer_players') return { type,
parameters: { source: '', target: '', currency: 'coins', amount: '1', reason: 'Trigger global economy transfer' } };
return { type, parameters: { message: '' } };
}
function eventLabel(event: string, zh: boolean): string { return label(eventLabels[event], zh, event); }
function eventDescription(event: string, zh: boolean): string {
return label(eventDescriptions[event], zh, zh
? `当“${eventLabel(event, true)}”发生时执行此触发器;可用字段取决于事件上下文。`
: `Runs when “${eventLabel(event, false)}” occurs; available fields depend on the event context.`);
}
function actionLabel(action: string, zh: boolean): string { return label(actionLabels[action], zh, action); }
// Describe the program, never evaluate it: references and templates remain visible until execution.
function expressionPreview(expression: TriggerExpression | undefined, catalog: TriggerCatalog, zh: boolean, depth = 0): string {
const l = (chinese: string, english: string) => zh ? chinese : english;
if (!expression) return l('未设置表达式', 'Expression not set');
if (depth > 32) return l('…(嵌套过深)', '… (nesting limit)');
const argumentsList = expression.arguments ?? [];
const argument = (index: number) => expressionPreview(argumentsList[index], catalog, zh, depth + 1);
const reference = (name: string) => {
const key = normalizedVariableKey(name);
const known = catalog.variables?.some((entry) => normalizedVariableKey(entry.key) === key)
|| conditionFieldLabels[key] || key.startsWith('args.');
return known ? `${conditionFieldLabel(key, zh, catalog)} {${key}}` : `{${key}}`;
};
const literal = (value: unknown): string => {
if (typeof value === 'string') return value === '' ? l('空文本 ""', 'Empty text ""')
: `“${richTextPlainText(value)}”`;
if (value == null) return l('空值(null)', 'Null');
if (typeof value === 'boolean') return value ? l('是(true)', 'True') : l('否(false)', 'False');
return typeof value === 'object' ? JSON.stringify(value) : String(value);
};
const name = expression.name.toLowerCase();
switch (expression.kind) {
case 'LITERAL': return literal(expression.literal);
case 'REFERENCE': return reference(expression.name);
case 'UNARY': return ['not', '!'].includes(name) ? `${l('非', 'Not')} (${argument(0)})`
: ['negate', '-'].includes(name) ? `−(${argument(0)})` : `${expression.name}(${argument(0)})`;
case 'BINARY': {
const operator = ({ and: l('并且', 'AND'), '&&': l('并且', 'AND'), or: l('或者', 'OR'), '||': l('或者', 'OR'),
add: '+', subtract: '−', multiply: '×', divide: '÷', mod: '%',
equals: l('等于', 'equals'), '==': l('等于', 'equals'), not_equals: l('不等于', 'does not equal'),
'!=': l('不等于', 'does not equal'), '>': l('大于', 'is greater than'), '>=': l('大于或等于', 'is at least'),
'<': l('小于', 'is less than'), '<=': l('小于或等于', 'is at most'), in: l('属于', 'is in'),
} as Record<string, string>)[name] ?? expression.name;
return `(${argument(0)} ${operator} ${argument(1)})`;
}
case 'FUNCTION': {
const legacy = argumentsList[0]?.kind === 'LITERAL' ? argumentsList[0].literal : undefined;
if (name === 'legacy_condition' && legacy && typeof legacy === 'object'
&& 'field' in legacy && 'operator' in legacy) {
const condition = legacy as { field: string; operator: string; value?: unknown };
const operator = String(condition.operator);
return `${reference(String(condition.field))} ${conditionOperatorLabel(operator, zh)}`
+ (conditionValueKind(operator) === 'none' ? '' : ` ${literal(condition.value ?? '')}`);
}
const descriptor = catalog.descriptors?.find((entry) => ['VALUE_FUNCTION', 'CONDITION_FUNCTION'].includes(entry.kind)
&& entry.id === expression.name);
const title = descriptor ? descriptorName(descriptor, catalog, zh) : label(functionLabels[name], zh, expression.name);
return `${title}(${argumentsList.map((_, index) => argument(index)).join(', ')})`;
}
case 'INDEX': return `${argument(0)}[${argument(1)}]`;
case 'COALESCE': return `${l('首个非空值', 'First non-null')}(${argumentsList.map((_, index) => argument(index)).join(', ')})`;
case 'CONVERT': return `${l('转换为', 'Convert to')} ${label(triggerTypeLabels[expression.name], zh, expression.name)}(${argument(0)})`;
default: return expression.name || l('未知表达式', 'Unknown expression');
}
}
function statementPreview(statement: TriggerStatement, catalog: TriggerCatalog, zh: boolean): { title: string; details: string[] } {
const l = (chinese: string, english: string) => zh ? chinese : english;
const expression = expressionPreview(statement.expression, catalog, zh);
const parameters = () => Object.entries(statement.inputs).map(([name, value]) =>
`${statement.kind === 'ACTION' ? actionParameterLabel(name, zh) : name}:${expressionPreview(value, catalog, zh)}`);
switch (statement.kind) {
case 'ACTION': {
const descriptor = catalog.descriptors?.find((entry) => entry.kind === 'ACTION' && entry.id === statement.name);
return { title: descriptor ? descriptorName(descriptor, catalog, zh) : actionLabel(statement.name, zh), details: parameters() };
}
case 'IF': return { title: `${l('如果', 'If')} ${expression}`, details: [] };
case 'SWITCH': return { title: `${l('按此值匹配分支', 'Match branches against')}:${expression}`, details: [] };
case 'REPEAT': return { title: zh ? `重复 ${expression} 次` : `Repeat ${expression} times`, details: [] };
case 'WHILE': return { title: `${l('当以下条件成立时循环', 'Repeat while')}:${expression}`, details: [] };
case 'FOREACH': return { title: `${statement.name} ← ${l('逐项遍历', 'each item in')} ${expression}`, details: [] };
case 'SET': return { title: `${statement.name} ← ${expression}`, details: [] };
case 'CALL': return { title: `${l('调用', 'Call')} ${statement.name}`, details: parameters() };
case 'RETURN': return { title: statement.expression ? `${l('返回', 'Return')} ${expression}` : l('结束当前过程', 'End this procedure'), details: [] };
case 'TRY': return { title: l('尝试执行;失败时转入错误分支', 'Try the body; run the error branch on failure'), details: [] };
case 'BREAK': return { title: l('退出当前循环', 'Exit the current loop'), details: [] };
case 'CONTINUE': return { title: l('跳过本轮,继续下一轮循环', 'Skip to the next loop iteration'), details: [] };
}
}
function actionDescription(action: string, zh: boolean): string {
return label(actionDescriptions[action], zh, zh
? `执行“${actionLabel(action, true)}”并记录到本次执行追踪。`
: `Performs “${actionLabel(action, false)}” and records it in the execution trace.`);
}
function label(value: [string, string] | undefined, zh: boolean, fallback: string): string { return value ? value[zh ? 0 : 1] : fallback; }
function executionStatusLabel(status: string, zh: boolean): string {
return label(executionStatusLabels[status.toUpperCase()], zh, status);
}
function traceKindLabel(kind: string, zh: boolean): string {
return label(traceKindLabels[kind.toUpperCase()], zh, kind);
}
type TriggerCatalogDescriptor = NonNullable<TriggerCatalog['descriptors']>[number];
function responseVariable(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog) {
return catalog.variables?.find((variable) => `response.${variable.key.toLowerCase()
.replace(/[^a-z0-9_.:_-]/g, '_')}` === descriptor.id);
}
function descriptorName(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog, zh: boolean): string {
const localized = descriptor.metadata?.[zh ? 'displayNameZh' : 'displayNameEn'];
if (typeof localized === 'string' && localized.trim()) return localized;
if (descriptor.kind === 'EVENT') return eventLabel(descriptor.id, zh);
if (descriptor.kind === 'ACTION') return actionLabel(descriptor.id, zh);
if (descriptor.kind === 'EVENT_RESPONSE') {
const variable = responseVariable(descriptor, catalog);
if (variable) return zh ? variable.nameZh : variable.nameEn;
}
if (descriptor.kind === 'TYPE') return label(triggerTypeLabels[descriptor.id], zh, descriptor.displayName);
if (descriptor.kind === 'CONDITION_FUNCTION' && descriptor.id.startsWith('compare.')) {
return conditionOperatorLabel(descriptor.id.slice('compare.'.length), zh);
}
if (descriptor.kind === 'VALUE_FUNCTION' || descriptor.kind === 'CONDITION_FUNCTION') {
return label(functionLabels[descriptor.id], zh, descriptor.displayName);
}
return descriptor.displayName;
}
function descriptorDescription(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog, zh: boolean): string {
const localized = descriptor.metadata?.[zh ? 'descriptionZh' : 'descriptionEn'];
if (typeof localized === 'string' && localized.trim()) return localized;
if (descriptor.kind === 'EVENT') return eventDescription(descriptor.id, zh);
if (descriptor.kind === 'ACTION') return actionDescription(descriptor.id, zh);
if (descriptor.kind === 'EVENT_RESPONSE') {
const variable = responseVariable(descriptor, catalog);
if (variable) return zh ? variable.descriptionZh : variable.descriptionEn;
}
if (descriptor.kind === 'TYPE') return label(triggerTypeDescriptions[descriptor.id], zh, zh
? `${descriptorName(descriptor, catalog, true)}类型;技术类型名为 ${descriptor.id}。`
: `${descriptorName(descriptor, catalog, false)} value type; technical type name: ${descriptor.id}.`);
if (descriptor.kind === 'CONDITION_FUNCTION' && descriptor.id.startsWith('compare.')) return zh
? `使用强类型“${descriptorName(descriptor, catalog, true)}”条件比较两个表达式。`
: `Compares two expressions with the typed “${descriptorName(descriptor, catalog, false)}” condition.`;
if (descriptor.kind === 'VALUE_FUNCTION' || descriptor.kind === 'CONDITION_FUNCTION') {
return label(functionDescriptions[descriptor.id], zh, zh
? `在不可变事件快照上计算“${descriptorName(descriptor, catalog, true)}”,不会修改世界。`
: `Calculates “${descriptorName(descriptor, catalog, false)}” on an immutable event snapshot without changing the world.`);
}
return descriptor.description;
}
function conditionFieldLabel(field: string, zh: boolean, catalog?: TriggerCatalog): string {
const catalogVariable = catalog?.variables?.find((variable) =>
normalizedVariableKey(variable.key) === field.trim());
if (catalogVariable) return zh ? catalogVariable.nameZh : catalogVariable.nameEn;
const known = conditionFieldLabels[field];
if (known) return label(known, zh, field);
if (field.startsWith('args.') && field.length > 'args.'.length) {
const argument = field.slice('args.'.length);
return zh ? `指令参数 ${argument}` : `Command argument ${argument}`;
}
return zh ? '扩展事件字段' : 'Extension event field';
}
function normalizedVariableKey(key: string): string {
const trimmed = key.trim();
return trimmed.startsWith('{') && trimmed.endsWith('}') ? trimmed.slice(1, -1).trim() : trimmed;
}
function ordinaryContextKey(key: string): boolean {
return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(normalizedVariableKey(key));
}
function conditionFieldWarning(field: string, operator: string, eventType: string,
catalog: TriggerCatalog, zh: boolean): string | undefined {
const normalized = field.trim();
if (!normalized) return undefined;
if (normalized.startsWith('args.')) {
// An args field outside a command trigger is unambiguously unavailable. A command trigger may
// deliberately receive additional argument keys from an extension, so it remains unrestricted.
if (eventType.trim().toLowerCase() !== 'player.command_trigger') return zh
? '指令参数仅在“玩家输入自定义指令”事件中可用;此字段仍会保留并允许保存。'
: 'Command arguments are only available to the custom player-command event. This field is retained and may still be saved.';
}
const known = catalog.variables?.find((variable) => normalizedVariableKey(variable.key) === normalized);
const formattedTimeTemplate = /^(?:server|event)\.time:/i.test(normalized);
if (formattedTimeTemplate || known?.conditionAllowed === false || (known && !ordinaryContextKey(known.key))) return zh
? '这是消息模板变量,不能作为条件字段;请选择对应的普通字段(例如 server.time)。'
: 'This is a message-template variable, not a condition field. Choose its ordinary field instead (for example, server.time).';
if (!known || variableAppliesToEvent(known, eventType)) return undefined;
const legacyNegative = ['neq', 'not_contains', 'not_in'].includes(operator.trim().toLowerCase());
if (zh) return legacyNegative
? '此字段在当前事件中通常不存在;为兼容旧触发器,该负向判断仍可能成立。建议先添加“字段存在”条件。仍可保存供模组扩展使用。'
: '此字段在当前事件中通常不可用。仍可保存供模组扩展上下文使用。';
return legacyNegative
? 'This field is normally absent from the selected event. For legacy compatibility this negative test may still match; add a “Field exists” condition first. It may still be saved for mod extensions.'
: 'This field is not normally available to the selected event. It may still be saved for a mod-provided context.';
}
function conditionValueKind(operator: string): ConditionValueKind {
return conditionOperatorMetadata[operator.trim().toLowerCase()]?.valueKind ?? 'text';
}
function conditionOperatorLabel(operator: string, zh: boolean): string {
const normalized = operator.trim().toLowerCase();
const metadata = conditionOperatorMetadata[normalized];
if (metadata) return label(metadata.labels, zh, operator);
const readable = normalized.split(/[_-]+/).filter(Boolean).join(' ') || operator;
if (zh) return `扩展判断:${readable}`;
return `Extension operator: ${readable.replace(/\b\w/g, (character) => character.toUpperCase())}`;
}
function groupConditionOperators(operators: string[]) {
return conditionOperatorCategories.map((group) => ({ ...group,
operators: operators.filter((operator) =>
(conditionOperatorMetadata[operator.trim().toLowerCase()]?.category ?? 'extension') === group.category),
})).filter((group) => group.operators.length > 0);
}
function conditionValuePlaceholder(operator: string, field: string, zh: boolean): string {
const kind = conditionValueKind(operator);
if (kind === 'number') return zh ? '输入有限数字,例如 10' : 'Enter a finite number, for example 10';
if (kind === 'range') return zh
? '下限, 上限(含边界),例如 1, 10'
: 'Lower bound, upper bound (inclusive), for example 1, 10';
if (kind === 'list') return zh
? '用英文逗号分隔,例如 Alex, Steve'
: 'Separate values with commas, for example Alex, Steve';
if (kind === 'regex') return zh
? '输入 Java 正则表达式,例如 ^Alex$'
: 'Enter a Java regular expression, for example ^Alex$';
if (booleanConditionFields.has(field)) return zh ? '布尔值:true 或 false' : 'Boolean value: true or false';
if (numericConditionFields.has(field)) return zh ? '输入数值,例如 10' : 'Enter a number, for example 10';
if (field === 'date') return zh ? '日期,例如 2026-09-04' : 'Date, for example 2026-09-04';
return zh ? '输入要比较的值' : 'Enter the value to compare';
}
function eventProvidesPlayerContext(type: string): boolean {
const normalized = type.trim().toLowerCase();
return normalized.startsWith('player.') || normalized === 'block.break' || normalized === 'block.place'
|| normalized === 'block.tool_modify' || normalized.startsWith('custom.') || normalized.startsWith('menu.')
|| normalized.startsWith('economy.');
}
function actionCompatibleWithEvent(action: string, event: string): boolean {
const normalizedAction = action.trim().toLowerCase();
const normalizedEvent = event.trim().toLowerCase();
if (playerContextActions.has(normalizedAction) && !eventProvidesPlayerContext(normalizedEvent)) return false;
return normalizedEvent !== 'player.leave' || !onlinePlayerActions.has(normalizedAction);
}
function triggerUsesCommandAction(value: TriggerDefinition): boolean {
return actionLeaves(value.actions).some((action) => commandActions.has(action.type.trim().toLowerCase()))
|| [...(value.statements ?? []), ...(value.functions ?? []).flatMap((entry) => entry.statements)]
.some((statement) => statementTreeContains(statement,
(entry) => entry.kind === 'ACTION' && commandActions.has(entry.name.trim().toLowerCase())))
|| (value.mode === 'code' && /^\s*do\s+(?:server_command|player_command)\b/im.test(value.script));
}
function triggerIsSavable(value: TriggerDefinition, canExecuteCommands: boolean, catalog: TriggerCatalog): boolean {
const name = value.name.trim();
if (!name || name.length > 120 || value.description.trim().length > 500) return false;
if (!canExecuteCommands && triggerUsesCommandAction(value)) return false;
const v2 = triggerV2Program(value);
if (v2) return v2.events.length > 0 && v2.events.length <= 32
&& v2.events.every((event) => Boolean(event.nodeId && event.type.trim()))
&& uniqueNodeIds(v2).size === countV2Nodes(v2);
if (value.mode === 'code') return Boolean(value.script.trim()) && value.script.length <= 65_536;
const eventType = value.event.type.trim().toLowerCase();
const builtInEvent = catalog.events.some((event) => event.toLowerCase() === eventType)
&& eventType !== 'custom';
const eventValid = eventType.length > 0 && eventType.length <= 80
&& (builtInEvent || /^custom\.[a-z0-9_.-]+$/.test(eventType));
return eventValid && eventConfigurationIsValid(value.event)
&& value.conditions.every((condition) => conditionIsValid(condition, catalog))
&& actionTreeIsValid(value.actions, value.event.type, catalog);
}
function eventConfigurationIsValid(event: TriggerEventSpec): boolean {
const variables = event.variables ?? [];
if (variables.some((variable, index) => !triggerVariableNameIsValid(variable.name)
|| variables.some((other, current) => current !== index && other.name === variable.name
&& (other.visibility ?? 'trigger') === (variable.visibility ?? 'trigger'))
|| !['trigger', 'global'].includes(variable.visibility ?? 'trigger')
|| !['server', 'player', 'dimension', 'trigger', 'execution', 'chunk', 'entity'].includes(variable.storage
?? ((variable.visibility ?? 'trigger') === 'global' ? 'server' : 'trigger'))
|| !['session', 'ttl', 'persistent'].includes(variable.lifetime ?? 'session')
|| (variable.lifetime ?? 'session') === 'ttl'
&& (!Number.isInteger(variable.ttlSeconds ?? 0) || (variable.ttlSeconds ?? 0) < 1
|| (variable.ttlSeconds ?? 0) > 31_536_000)
|| !variableTypeIsValid(variable.type)
|| !triggerVariableInitialValueIsValid(variable))) return false;
const eventType = event.type.trim().toLowerCase();
const keys = Object.keys(event.configuration);
if (keys.length > 16 || Object.entries(event.configuration).some(([key, entry]) =>
!key.trim() || key.trim().length > 80 || entry.length > 4_096)) return false;
if (eventType === 'schedule.daily') {
if (keys.some((key) => !['time', 'timezone'].includes(key))) return false;
const time = event.configuration.time ?? '';
const timezone = event.configuration.timezone ?? 'UTC';
return /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/.test(time)
&& timeZoneIsValid(timezone);
}
if (eventType === 'schedule.interval') {
return keys.every((key) => key === 'seconds')
&& integerInRange(event.configuration.seconds ?? '0', 1, 31_536_000);
}
if (eventType.startsWith('protection.')) {
const countEvents = ['protection.item_overflow', 'protection.mob_overflow', 'protection.entity_overflow'];
const accepted = eventType === 'protection.mod_entity_overflow'
? ['threshold', 'namespace', 'cooldownSeconds']
: eventType === 'protection.slow_tick'
? ['threshold', 'consecutive', 'cooldownSeconds']
: countEvents.includes(eventType) ? ['threshold', 'scope', 'cooldownSeconds']
: ['threshold', 'cooldownSeconds'];
if (keys.some((key) => !accepted.includes(key))) return false;
const minimum = eventType === 'protection.memory_pressure' || eventType === 'protection.slow_tick' ? 50 : 1;
const maximum = eventType === 'protection.memory_pressure' ? 99 : 2_147_483_647;
if (!integerInRange(event.configuration.threshold ?? '', minimum, maximum)
|| !integerInRange(event.configuration.cooldownSeconds ?? '60', 1, 86_400)) return false;
if (countEvents.includes(eventType) && !['dimension', 'chunk'].includes(event.configuration.scope ?? 'dimension')) return false;
if (eventType === 'protection.mod_entity_overflow'
&& !/^[a-z0-9_.-]{1,64}$/.test(event.configuration.namespace ?? '')) return false;
return eventType !== 'protection.slow_tick'
|| integerInRange(event.configuration.consecutive ?? '1', 1, 1_200);
}
if (eventType === 'player.command_trigger') {
if (keys.some((key) => !['command', 'arguments'].includes(key))) return false;
const command = event.configuration.command ?? '';
const rawArguments = (event.configuration.arguments ?? '').trim();
if (!commandPartPattern.test(command)) return false;
if ((event.arguments?.length ?? 0) > 0) {
if (rawArguments) return false;
return commandArgumentTreeIsValid(event.arguments ?? []);
}
const argumentsList = rawArguments ? rawArguments.split(/\s+/) : [];
return argumentsList.every((argument) => commandPartPattern.test(argument))
&& new Set(argumentsList).size === argumentsList.length;
}
return keys.length === 0;
}
function conditionIsValid(condition: TriggerCondition, catalog: TriggerCatalog): boolean {
const field = condition.field.trim();
const operator = condition.operator.trim().toLowerCase();
if (!field || field.length > 120 || !catalog.operators.some((item) => item.toLowerCase() === operator)
|| condition.value.length > 4_096) return false;
if (['matches', 'not_matches'].includes(operator)) return safeRegexIsValid(condition.value);
if (['gt', 'gte', 'lt', 'lte'].includes(operator)) return javaDecimalIsValid(condition.value);
if (['between', 'not_between'].includes(operator)) return numericRangeIsValid(condition.value);
return true;
}
function actionTreeIsValid(actions: TriggerAction[], eventType: string, catalog: TriggerCatalog): boolean {
let leaves = 0;
const visit = (action: TriggerAction): boolean => {
const actionType = action.type.trim().toLowerCase();
const children = actionChildren(action);
if (actionType === CONDITION_ACTION_TYPE) {
const parameterKeys = Object.keys(action.parameters);
if (children.length === 0
|| parameterKeys.some((key) => !['field', 'operator', 'value'].includes(key))) return false;
const condition = { field: action.parameters.field ?? '', operator: action.parameters.operator ?? '',
value: action.parameters.value ?? '' };
return conditionIsValid(condition, catalog) && children.every((child) => visit(child));
}
leaves += 1;
return children.length === 0 && actionCompatibleWithEvent(actionType, eventType)
&& actionIsValid(action, catalog);
};
return actions.length > 0 && actions.every((action) => visit(action)) && leaves > 0;
}
function actionIsValid(action: TriggerAction, catalog: TriggerCatalog): boolean {
const actionType = action.type.trim().toLowerCase();
if (!catalog.actions.some((item) => item.toLowerCase() === actionType)) return false;
const parameters = Object.entries(action.parameters);
const catalogType = Object.keys(catalog.actionParameters)
.find((item) => item.toLowerCase() === actionType) ?? actionType;
const accepted = catalog.actionParameters[catalogType] ?? fallbackCatalog.actionParameters[actionType];
if (!accepted || parameters.length > 16 || parameters.some(([key, entry]) =>
!key.trim() || key.trim().length > 80 || !accepted.includes(key) || entry.length > 65_536)) return false;
if (parameters.some(([, entry]) => !templateTimeFormatsAreValid(entry))) return false;
const primary = actionPrimaryParameter(actionType);
if (primary && !(action.parameters[primary] ?? '').trim()) return false;
if (['send_player', 'broadcast', 'actionbar', 'kick'].includes(actionType)
&& !richTextIsValid(action.parameters.message ?? '')) return false;
switch (actionType) {
case 'server_command':
case 'player_command': {
const command = action.parameters.command ?? '';
return command.length <= 32_768 && !/[\r\n]/.test(command)
&& templateOr(action.parameters.showFeedback ?? 'false',
(entry) => ['true', 'false'].includes(entry.trim().toLowerCase()));
}
case 'sound':
return templateOr(action.parameters.sound ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.volume ?? '1', (entry) => finiteNumberInRange(entry, 0, 1_000))
&& templateOr(action.parameters.pitch ?? '1', (entry) => finiteNumberInRange(entry, 0, 2));
case 'title':
return templateOr(action.parameters.fadeIn ?? '10', (entry) => integerInRange(entry, 0, 12_000))
&& templateOr(action.parameters.stay ?? '70', (entry) => integerInRange(entry, 0, 12_000))
&& templateOr(action.parameters.fadeOut ?? '20', (entry) => integerInRange(entry, 0, 12_000));
case 'teleport': {
const destination = action.parameters.destination ?? '';
return destination.trim().length <= 256 && !/[\r\n]/.test(destination);
}
case 'give_item':
return templateOr(action.parameters.item ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.count ?? '1', (entry) => integerInRange(entry, 1, 6_400));
case 'clear_inventory': {
const item = (action.parameters.item ?? '').trim();
const maximum = (action.parameters.maxCount ?? '').trim();
return (!item || templateOr(item, resourceIdentifierIsValid))
&& (!maximum || templateOr(maximum, (entry) => integerInRange(entry, 0, 2_147_483_647)));
}
case 'set_gamemode':
return templateOr(action.parameters.gamemode ?? '', (entry) =>
['survival', 'creative', 'adventure', 'spectator'].includes(entry.trim().toLowerCase()));
case 'add_effect':
return templateOr(action.parameters.effect ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.duration ?? '30', (entry) => integerInRange(entry, 1, 1_000_000))
&& templateOr(action.parameters.amplifier ?? '0', (entry) => integerInRange(entry, 0, 255));
case 'set_time': {
const time = (action.parameters.time ?? '').trim().toLowerCase();
return templateOr(time, (entry) => ['day', 'night', 'noon', 'midnight'].includes(entry)
|| integerInRange(entry, 0, 24_000));
}
case 'set_weather':
return templateOr(action.parameters.weather ?? '', (entry) =>
['clear', 'rain', 'thunder'].includes(entry.trim().toLowerCase()))
&& templateOr(action.parameters.duration ?? '300', (entry) => integerInRange(entry, 1, 1_000_000));
case 'log':
return templateOr(action.parameters.level ?? 'info', (entry) =>
['debug', 'info', 'warn', 'error'].includes(entry.trim().toLowerCase()));
case 'economy_deposit':
case 'economy_withdraw':
case 'economy_set_balance':
case 'economy_transfer':
case 'economy_deposit_player':
case 'economy_withdraw_player':
case 'economy_set_player_balance':
case 'economy_transfer_players': {
const setOperation = ['economy_set_balance', 'economy_set_player_balance'].includes(actionType);
const identityParameters = actionType === 'economy_transfer_players' ? ['source', 'target']
: actionType === 'economy_transfer' ? ['target']
: ['economy_deposit_player', 'economy_withdraw_player', 'economy_set_player_balance'].includes(actionType)
? ['player'] : [];
const identityValid = identityParameters.every((parameter) => {
const identity = action.parameters[parameter] ?? '';
return Boolean(identity.trim()) && identity.length <= 128 && !/[\r\n]/.test(identity);
});
const reason = action.parameters.reason ?? 'Trigger economy operation';
return templateOr(action.parameters.currency ?? '', (entry) =>
/^[a-z][a-z0-9_]{0,31}$/.test(entry) || uuidIsValid(entry))
&& templateOr(action.parameters.amount ?? '', (entry) => economyAmountIsValid(entry, setOperation))
&& reason.trim().length > 0 && reason.length <= 512 && identityValid;
}
case 'variable':
return /^(?:global\.)?[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test((action.parameters.name ?? '').trim())
&& templateOr(action.parameters.operation ?? 'set', (entry) => variableOperations.includes(entry.trim().toLowerCase()));
case 'wait': {
const mode = (action.parameters.mode ?? 'duration').trim().toLowerCase();
const dynamicMode = containsWellFormedVariable(mode);
return (dynamicMode || waitModes.includes(mode))
&& templateOr(action.parameters.timeout ?? '0', (entry) => integerInRange(entry, 0, 86_400))
&& templateOr(action.parameters.pollTicks ?? '20', (entry) => integerInRange(entry, 1, 72_000))
&& (mode === 'condition' || dynamicMode
? Boolean((action.parameters.field ?? '').trim())
&& templateOr(action.parameters.operator ?? 'eq', (entry) => catalog.operators.includes(entry))
&& (!dynamicMode || templateOr(action.parameters.value ?? '0',
(entry) => finiteNumberInRange(entry, 0, 86_400_000)))
: templateOr(action.parameters.value ?? '0', (entry) => mode === 'duration'
? finiteNumberInRange(entry, 0, 86_400_000)
: integerInRange(entry, 0, mode === 'game_time' ? 2_147_483_647 : 86_400_000)));
}
case 'run_trigger':
return templateOr(action.parameters.triggerId ?? '', (entry) => uuidIsValid(entry.trim()))
&& templateOr(action.parameters.waitForCompletion ?? 'false',
(entry) => ['true', 'false'].includes(entry.trim().toLowerCase()));
case 'open_menu':
return templateOr(action.parameters.menuId ?? '', (entry) => uuidIsValid(entry.trim()));
default:
return true;
}
}
function containsWellFormedVariable(value: string): boolean {
// Keep this in lock-step with TriggerEvaluator.TEMPLATE_VARIABLE/hasTemplateVariable.
const matcher = /\{([A-Za-z0-9_.-]+)(?::([^{}\r\n]{1,64}))?\}/g;
let match: RegExpExecArray | null;
while ((match = matcher.exec(value)) !== null) {
if (match[2] === undefined || match[1] === 'server.time' || match[1] === 'event.time') return true;
}
return false;
}
function templateOr(value: string, literalValidator: (value: string) => boolean): boolean {
return containsWellFormedVariable(value) || literalValidator(value);
}
function economyAmountIsValid(value: string, allowZeroOrNegative: boolean): boolean {
const normalized = value.trim();
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized)) return false;
return allowZeroOrNegative || Number(normalized) > 0;
}
function actionPrimaryParameter(type: string): string | undefined {
if (['clear_inventory', 'remove_effects', 'heal', 'feed', 'whitelist_add', 'whitelist_remove',
'ban', 'pardon'].includes(type)) return undefined;
if (['server_command', 'player_command'].includes(type)) return 'command';
if (type === 'sound') return 'sound';
if (type === 'title') return 'title';
if (type === 'teleport') return 'destination';
if (type === 'give_item') return 'item';
if (type === 'set_gamemode') return 'gamemode';
if (type === 'add_effect') return 'effect';
if (type === 'set_time') return 'time';
if (type === 'set_weather') return 'weather';
if (type === 'variable') return 'name';
if (type === 'wait') return 'mode';
if (type === 'run_trigger') return 'triggerId';
if (type === 'open_menu') return 'menuId';
if (type.startsWith('economy_')) return 'currency';
return 'message';
}
function resourceIdentifierIsValid(value: string | undefined): boolean {
return /^(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+$/.test((value ?? '').trim());
}
function uuidIsValid(value: string): boolean {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(value)
|| value === '00000000-0000-0000-0000-000000000000';
}
function triggerVariableInitialValueIsValid(variable: TriggerStateVariableDefinition): boolean {
const value = variable.initialValue;
const structured = variableTypeIsContainer(variable.type);
if (value.length > (structured ? 65_536 : 8_192)) return false;
if (structured) {
try { return structuredVariableValueIsValid(parseVariableTypeNode(variable.type), JSON.parse(value), 0); }
catch { return false; }
}
switch (variable.type) {
case 'bool': return ['true', 'false'].includes(value.toLowerCase());
case 'integer': return /^[+-]?\d+$/.test(value.trim()) && Number.isSafeInteger(Number(value));
case 'float': return javaDecimalIsValid(value);
case 'uuid':
case 'player_ref':
case 'entity_ref': return uuidIsValid(value.trim());
case 'player': return /^[A-Za-z0-9_]{1,16}$/.test(value.trim());
case 'resource_location': return resourceIdentifierIsValid(value);
case 'region_ref':
case 'item_ref': return resourceIdentifierIsValid(value);
case 'block_state':
case 'item_stack': return /^(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+(?:\[[^\r\n]*])?(?:\{[^\r\n]*})?$/.test(value.trim());
case 'component': return /^(?:\{.*}|\[.*]|".*")$/.test(value.trim());
case 'nbt': return /^\{.*}$/.test(value.trim());
case 'coordinate': return /^(?:[~^](?:-?(?:\d+(?:\.\d*)?|\.\d+))?|-?(?:\d+(?:\.\d*)?|\.\d+))(?:\s+(?:[~^](?:-?(?:\d+(?:\.\d*)?|\.\d+))?|-?(?:\d+(?:\.\d*)?|\.\d+))){0,2}$/.test(value.trim());
case 'rotation': return /^-?(?:\d+(?:\.\d*)?|\.\d+)\s+-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim());
case 'position':
case 'block_ref': return /^[a-z0-9_.-]+:[a-z0-9_./-]+(?:\s+-?(?:\d+(?:\.\d*)?|\.\d+)){3}$/.test(value.trim());
case 'duration': return /^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/.test(value.trim());
case 'instant': return /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value.trim())
&& Number.isFinite(Date.parse(value.trim()));
default: return !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value);
}
}
function variableTypeIsValid(type: string): boolean {
if (type === 'list' || (fallbackCatalog.triggerVariableTypes ?? []).includes(type)) return true;
try {
const node = parseVariableTypeNode(type);
const canonical = serializeVariableTypeNode(node);
if (canonical !== type || !['array', 'set', 'optional', 'dictionary'].includes(node.name)) return false;
const visit = (entry: VariableTypeNode): boolean =>
['array', 'set', 'optional'].includes(entry.name) ? entry.arguments.length === 1 && visit(entry.arguments[0])
: entry.name === 'dictionary' ? entry.arguments.length === 2 && !['array', 'set', 'optional', 'dictionary'].includes(entry.arguments[0].name)
&& visit(entry.arguments[0]) && visit(entry.arguments[1])
: entry.arguments.length === 0 && (fallbackCatalog.triggerVariableTypes ?? []).includes(entry.name)
&& !['array', 'set', 'optional', 'dictionary', 'list'].includes(entry.name);
return visit(node);
} catch { return false; }
}
function structuredVariableValueIsValid(type: VariableTypeNode, value: unknown, depth: number): boolean {
if (type.name === 'optional') return value === null
|| structuredVariableValueIsValid(type.arguments[0], value, depth + 1);
if (type.name === 'array' || type.name === 'set') return Array.isArray(value)
&& value.every((entry) => structuredVariableValueIsValid(type.arguments[0], entry, depth + 1));
if (type.name === 'dictionary') return value !== null && !Array.isArray(value) && typeof value === 'object'
&& Object.entries(value).every(([key, entry]) =>
scalarVariableValueIsValid(type.arguments[0].name, key)
&& structuredVariableValueIsValid(type.arguments[1], entry, depth + 1));
if (type.name === 'record') return value !== null && !Array.isArray(value) && typeof value === 'object';
return scalarVariableValueIsValid(type.name, value);
}
function scalarVariableValueIsValid(type: string, value: unknown): boolean {
if (type === 'bool') return typeof value === 'boolean';
if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
if (type === 'float') return typeof value === 'number' && Number.isFinite(value);
if (typeof value !== 'string') return false;
return triggerVariableInitialValueIsValid({ name: 'value', type, initialValue: value });
}
function commandArgumentTreeIsValid(values: TriggerCommandArgument[]): boolean {
const names = new Set<string>();
const visit = (items: TriggerCommandArgument[]): boolean => {
const branches = new Set<string>();
return items.every((item) => {
const branch = item.type === 'literal' ? `literal:${item.literal}` : `argument:${item.name}`;
if (!commandPartPattern.test(item.name) || names.has(item.name)
|| branches.has(branch) || !(fallbackCatalog.commandArgumentTypes ?? []).includes(item.type)
|| item.errorMessage.length > 256 || item.suggestions.length > 64
|| item.suggestions.some((entry) => !entry || entry.length > 128)
|| item.type === 'literal' && !item.literal.trim()
|| ['greedy_string', 'message'].includes(item.type) && item.children.length > 0) return false;
names.add(item.name); branches.add(branch);
if (['integer', 'long', 'float', 'double', 'time'].includes(item.type)) {
if (item.minimum && !javaDecimalIsValid(item.minimum)) return false;
if (item.maximum && !javaDecimalIsValid(item.maximum)) return false;
if (item.minimum && item.maximum && Number(item.minimum) > Number(item.maximum)) return false;
}
return visit(item.children ?? []);
});
};
return visit(values);
}
function integerInRange(value: string, minimum: number, maximum: number): boolean {
const normalized = value.trim();
if (!/^[+-]?\d+$/.test(normalized)) return false;
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum;
}
function splitIntervalSeconds(value: string): IntervalParts {
if (!/^\d+$/.test(value.trim())) return { hours: '', minutes: '', seconds: '' };
const total = Number(value.trim());
if (!Number.isSafeInteger(total) || total < 0) return { hours: '', minutes: '', seconds: '' };
return {
hours: String(Math.floor(total / 3_600)),
minutes: String(Math.floor((total % 3_600) / 60)),
seconds: String(total % 60),
};
}
function joinIntervalSeconds(parts: IntervalParts): string {
const limits: Record<keyof IntervalParts, number> = { hours: 8_760, minutes: 59, seconds: 59 };
const parsed = {} as Record<keyof IntervalParts, number>;
for (const key of Object.keys(limits) as Array<keyof IntervalParts>) {
if (!/^\d+$/.test(parts[key])) return '';
const entry = Number(parts[key]);
if (!Number.isSafeInteger(entry) || entry < 0 || entry > limits[key]) return '';
parsed[key] = entry;
}
const total = parsed.hours * 3_600 + parsed.minutes * 60 + parsed.seconds;
return total <= 31_536_000 ? String(total) : '';
}
function finiteNumberInRange(value: string, minimum: number, maximum: number): boolean {
const normalized = value.trim();
if (!javaDecimalIsValid(normalized)) return false;
const parsed = Number(normalized.replace(/[fFdD]$/, ''));
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum;
}
function javaDecimalIsValid(value: string): boolean {
const normalized = value.trim();
// The visual editor deliberately accepts Java's ordinary decimal/scientific forms while
// excluding Java-only hexadecimal floats that an HTML number input cannot faithfully edit.
return /^[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)[fFdD]?$/.test(normalized)
&& Number.isFinite(Number(normalized.replace(/[fFdD]$/, '')));
}
function numericRangeIsValid(value: string): boolean {
const entries = value.split(',');
if (entries.length !== 2 || !entries.every(javaDecimalIsValid)) return false;
const [lower, upper] = entries.map((entry) => Number(entry.trim().replace(/[fFdD]$/, '')));
return lower <= upper;
}
function timeZoneIsValid(value: string): boolean {
// Browsers and java.time.ZoneId do not expose identical zone vocabularies. Keep the synchronous
// check non-destructive and let validateVisualTrigger provide the authoritative Java verdict.
return Boolean(value) && value === value.trim() && value.length <= 4_096;
}
function safeRegexIsValid(expression: string): boolean {
if (expression.length > 256) return false;
// JavaScript-only Unicode code-point spelling is not accepted by java.util.regex.
// Other dialect differences are caught by the authoritative server preflight before save.
if (/\\u\{/.test(expression)) return false;
let variableRepetitions = 0;
let repetitionBudget = 1;
let escaped = false;
let characterClass = false;
let groupDepth = 0;
let previousWasGroup = false;
for (let index = 0; index < expression.length; index += 1) {
const value = expression[index];
if (escaped) {
if (!characterClass && /\d/.test(value)) return false;
escaped = false;
previousWasGroup = false;
continue;
}
if (value === '\\') { escaped = true; continue; }
if (value === '[' && !characterClass) { characterClass = true; previousWasGroup = false; continue; }
if (value === ']' && characterClass) { characterClass = false; continue; }
if (characterClass) continue;
if (value === '(' && expression[index + 1] === '?') return false;
if (value === '(') { groupDepth += 1; previousWasGroup = false; continue; }
if (value === ')') {
if (groupDepth === 0) return false;
groupDepth -= 1;
previousWasGroup = true;
continue;
}
if (value === '*' || value === '+' || value === '?') {
if (previousWasGroup) return false;
variableRepetitions += 1;
repetitionBudget *= value === '?' ? 2 : 1_025;
previousWasGroup = false;
continue;
}
if (value === '{') {
const close = expression.indexOf('}', index + 1);
if (close >= 0) {
if (previousWasGroup) return false;
const bounds = expression.slice(index + 1, close).split(',');
if (bounds.length <= 2 && /^\d+$/.test(bounds[0])
&& (bounds.length === 1 || bounds[1] === '' || /^\d+$/.test(bounds[1]))) {
const minimum = Number(bounds[0]);
const maximum = bounds.length === 1 ? minimum : bounds[1] === '' ? 1_024 : Number(bounds[1]);
if (maximum < minimum || maximum > 1_024) return false;
if (maximum !== minimum) {
variableRepetitions += 1;
repetitionBudget *= maximum - minimum + 1;
}
index = close;
previousWasGroup = false;
continue;
}
}
}
previousWasGroup = false;
}
return !escaped && !characterClass && groupDepth === 0
&& variableRepetitions <= 8 && repetitionBudget <= 1_100_000;
}
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, current) => current === index ? value : item); }
function remove<T>(items: T[], index: number): T[] { return items.filter((_, current) => current !== index); }
function move<T>(items: T[], index: number, offset: -1 | 1): T[] {
const destination = index + offset;
if (destination < 0 || destination >= items.length) return items;
const result = [...items];
[result[index], result[destination]] = [result[destination], result[index]];
return result;
}
function groupFingerprint(value: TriggerGroup): string {
return JSON.stringify({ name: value.name, description: value.description, enabled: value.enabled });
}
function triggerFingerprint(value: TriggerDefinition): string {
return JSON.stringify({
groupId: value.groupId, name: value.name, description: value.description, enabled: value.enabled,
mode: value.mode, event: value.event, conditionMode: value.conditionMode,
conditions: value.conditions, actions: value.actions, script: value.script,
schemaVersion: value.schemaVersion, events: value.events, declarations: value.declarations,
functions: value.functions, statements: value.statements,
});
}
function newNodeId(): string {
return globalThis.crypto?.randomUUID?.() ?? 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (entry) => {
const value = Math.floor(Math.random() * 16);
return (entry === 'x' ? value : value & 0x3 | 0x8).toString(16);
});
}
function literalExpression(value: unknown, valueType = 'string'): TriggerExpression {
return { nodeId: newNodeId(), kind: 'LITERAL', valueType, literal: value, name: '', arguments: [] };
}
function expressionOfKind(kind: TriggerExpression['kind'], nodeId = newNodeId()): TriggerExpression {
if (kind === 'LITERAL') return { ...literalExpression('', 'string'), nodeId };
if (kind === 'REFERENCE') return { nodeId, kind, valueType: 'string', name: 'event.player.name', arguments: [] };
const arity = kind === 'BINARY' || kind === 'INDEX' ? 2 : kind === 'COALESCE' ? 2 : 1;
const name = kind === 'UNARY' ? 'not' : kind === 'BINARY' ? 'eq' : kind === 'FUNCTION' ? 'len' : '';
return { nodeId, kind, valueType: kind === 'UNARY' || kind === 'BINARY' ? 'bool' : 'string', name,
arguments: Array.from({ length: arity }, () => literalExpression('', 'string')) };
}
function triggerV2Program(value: TriggerDefinition): TriggerProgramV2 | undefined {
if (value.schemaVersion !== 2 || !value.events || !value.declarations || !value.functions || !value.statements) return undefined;
return { schemaVersion: 2, events: value.events, declarations: value.declarations,
functions: value.functions, statements: value.statements };
}
function v2VariableProjection(value: TriggerVariableDeclarationV2): TriggerStateVariableDefinition {
return { name: value.name, type: value.valueType,
initialValue: value.initialValue?.kind === 'LITERAL' ? String(value.initialValue.literal ?? '') : '',
visibility: value.visibility, storage: value.storage,
lifetime: value.lifetime.toLowerCase() as TriggerStateVariableDefinition['lifetime'],
ttlSeconds: value.ttlSeconds };
}
function actionStatement(descriptor: NonNullable<TriggerCatalog['descriptors']>[number]): TriggerStatement {
return { nodeId: newNodeId(), kind: 'ACTION', name: descriptor.id,
inputs: Object.fromEntries(descriptor.parameters.map((parameter) =>
[parameter.name, literalExpression(parameter.defaultValue ?? '', 'string')])),
cases: [], statements: [], elseStatements: [] };
}
function firstActionDescriptor(catalog: TriggerCatalog): NonNullable<TriggerCatalog['descriptors']>[number] {
return (catalog.descriptors ?? []).find((entry) => entry.kind === 'ACTION') ?? {
id: 'log', kind: 'ACTION', displayName: 'log', description: 'Trigger action', parameters: [
{ name: 'message', valueType: 'expression<any>', required: true, defaultValue: '' },
{ name: 'level', valueType: 'expression<any>', required: true, defaultValue: 'info' },
], returnType: 'void', purity: 'SIDE_EFFECT', threadAffinity: 'SERVER', risk: 'SAFE',
supportedPlatforms: [], applicableEvents: ['*'],
};
}
function defaultV2Statement(kind: TriggerStatement['kind'], program: TriggerProgramV2): TriggerStatement {
const base = { nodeId: newNodeId(), kind, name: '', inputs: {}, cases: [], statements: [], elseStatements: [] };
switch (kind) {
case 'ACTION': return { ...base, name: 'log', inputs: {
message: literalExpression('', 'string'), level: literalExpression('info', 'string') } };
case 'SET': return { ...base, name: program.declarations[0]?.name ?? 'value', expression: literalExpression('', 'string') };
case 'IF': return { ...base, expression: literalExpression(true, 'bool') };
case 'SWITCH': return { ...base, expression: literalExpression('', 'string') };
case 'REPEAT': return { ...base, expression: literalExpression(1, 'integer') };
case 'WHILE': return { ...base, expression: literalExpression(false, 'bool') };
case 'FOREACH': return { ...base, name: 'item', expression: literalExpression([], 'list') };
case 'CALL': return { ...base, name: program.functions[0]?.name ?? 'procedure' };
case 'RETURN': return base;
case 'BREAK': case 'CONTINUE': case 'TRY': return base;
}
}
function findV2Node(program: TriggerProgramV2, nodeId: string): V2Node | undefined {
const event = program.events.find((entry) => entry.nodeId === nodeId);
if (event) return { category: 'event', value: event };
const declaration = program.declarations.find((entry) => entry.nodeId === nodeId)
?? program.functions.flatMap((entry) => entry.locals).find((entry) => entry.nodeId === nodeId);
if (declaration) return { category: 'declaration', value: declaration };
const fn = program.functions.find((entry) => entry.nodeId === nodeId);
if (fn) return { category: 'function', value: fn };
const findStatement = (values: TriggerStatement[]): TriggerStatement | undefined => {
for (const statement of values) {
if (statement.nodeId === nodeId) return statement;
const nested = findStatement([...statement.statements,
...statement.cases.flatMap((branch) => branch.statements), ...statement.elseStatements]);
if (nested) return nested;
}
return undefined;
};
const statement = findStatement([...program.statements, ...program.functions.flatMap((entry) => entry.statements)]);
return statement ? { category: 'statement', value: statement } : undefined;
}
function replaceV2Node(program: TriggerProgramV2, nodeId: string, replacement: V2Node['value']): TriggerProgramV2 {
const replaceStatements = (values: TriggerStatement[]): TriggerStatement[] => values.map((statement) =>
statement.nodeId === nodeId ? replacement as TriggerStatement : { ...statement,
statements: replaceStatements(statement.statements),
cases: statement.cases.map((branch) => ({ ...branch, statements: replaceStatements(branch.statements) })),
elseStatements: replaceStatements(statement.elseStatements) });
return { ...program,
events: program.events.map((entry) => entry.nodeId === nodeId ? replacement as TriggerEventBindingLike : entry),
declarations: program.declarations.map((entry) => entry.nodeId === nodeId
? replacement as TriggerVariableDeclarationV2 : entry),
functions: program.functions.map((entry) => entry.nodeId === nodeId
? replacement as TriggerFunctionDeclaration : { ...entry,
locals: entry.locals.map((local) => local.nodeId === nodeId
? replacement as TriggerVariableDeclarationV2 : local),
statements: replaceStatements(entry.statements) }),
statements: replaceStatements(program.statements) };
}
function removeV2Node(program: TriggerProgramV2, nodeId: string): TriggerProgramV2 {
const removeStatements = (values: TriggerStatement[]): TriggerStatement[] => values.filter((entry) =>
entry.nodeId !== nodeId).map((statement) => ({ ...statement,
statements: removeStatements(statement.statements),
cases: statement.cases.map((branch) => ({ ...branch, statements: removeStatements(branch.statements) })),
elseStatements: removeStatements(statement.elseStatements) }));
return { ...program, events: program.events.filter((entry) => entry.nodeId !== nodeId),
declarations: program.declarations.filter((entry) => entry.nodeId !== nodeId),
functions: program.functions.filter((entry) => entry.nodeId !== nodeId).map((entry) => ({ ...entry,
locals: entry.locals.filter((local) => local.nodeId !== nodeId),
statements: removeStatements(entry.statements) })), statements: removeStatements(program.statements) };
}
function appendV2Child(program: TriggerProgramV2, nodeId: string,
branch: 'statements' | 'elseStatements', child: TriggerStatement): TriggerProgramV2 {
const append = (values: TriggerStatement[]): TriggerStatement[] => values.map((statement) => {
if (statement.nodeId === nodeId) return { ...statement, [branch]: [...statement[branch], child] };
return { ...statement, statements: append(statement.statements),
cases: statement.cases.map((entry) => ({ ...entry, statements: append(entry.statements) })),
elseStatements: append(statement.elseStatements) };
});
return { ...program, statements: append(program.statements),
functions: program.functions.map((entry) => ({ ...entry, statements: append(entry.statements) })) };
}
function regenerateNodeIds<T>(value: T): T {
const clone = structuredClone(value) as unknown;
const visit = (entry: unknown) => {
if (Array.isArray(entry)) { entry.forEach(visit); return; }
if (!entry || typeof entry !== 'object') return;
const object = entry as Record<string, unknown>;
if (typeof object.nodeId === 'string') object.nodeId = newNodeId();
Object.values(object).forEach(visit);
};
visit(clone);
return clone as T;
}
function uniqueVariableName(program: TriggerProgramV2, requested: string): string {
const names = new Set(program.declarations.map((entry) => entry.name));
let result = requested.slice(0, 48);
let suffix = 2;
while (names.has(result)) result = `${requested.slice(0, 43)}_${suffix++}`;
return result;
}
function uniqueFunctionName(program: TriggerProgramV2, requested: string): string {
const names = new Set(program.functions.map((entry) => entry.name));
let result = requested.slice(0, 128);
let suffix = 2;
while (names.has(result)) result = `${requested.slice(0, 122)}_${suffix++}`;
return result;
}
function statementTreeContains(statement: TriggerStatement, predicate: (value: TriggerStatement) => boolean): boolean {
return predicate(statement) || [...statement.statements, ...statement.elseStatements,
...statement.cases.flatMap((branch) => branch.statements)].some((child) => statementTreeContains(child, predicate));
}
function mapV2Expression(value: TriggerExpression | undefined,
transform: (value: TriggerExpression) => TriggerExpression): TriggerExpression | undefined {
if (!value) return undefined;
return transform({ ...value, arguments: value.arguments.map((entry) => mapV2Expression(entry, transform)!) });
}
function mapV2Statements(values: TriggerStatement[], transform: (value: TriggerStatement) => TriggerStatement): TriggerStatement[] {
return values.map((value) => transform({ ...value,
inputs: Object.fromEntries(Object.entries(value.inputs).map(([name, expression]) =>
[name, mapV2Expression(expression, (entry) => entry)!])),
expression: mapV2Expression(value.expression, (entry) => entry),
statements: mapV2Statements(value.statements, transform),
cases: value.cases.map((branch) => ({ ...branch,
match: mapV2Expression(branch.match, (entry) => entry)!, statements: mapV2Statements(branch.statements, transform) })),
elseStatements: mapV2Statements(value.elseStatements, transform) }));
}
function renameV2Variable(program: TriggerProgramV2, oldName: string, newName: string): TriggerProgramV2 {
const renameExpression = (value: TriggerExpression) => ({ ...value,
name: value.kind === 'REFERENCE' && [oldName, `var.${oldName}`, `global.${oldName}`, `local.${oldName}`].includes(value.name)
? value.name.includes('.') ? `${value.name.split('.')[0]}.${newName}` : newName : value.name });
const renameStatements = (statements: TriggerStatement[]) => mapV2Statements(statements, (statement) => ({ ...statement,
name: statement.kind === 'SET' && statement.name === oldName ? newName : statement.name,
inputs: Object.fromEntries(Object.entries(statement.inputs).map(([name, expression]) =>
[name, mapV2Expression(expression, renameExpression)!])),
expression: mapV2Expression(statement.expression, renameExpression),
cases: statement.cases.map((branch) => ({ ...branch, match: mapV2Expression(branch.match, renameExpression)! })) }));
return { ...program,
declarations: program.declarations.map((entry) => entry.name === oldName ? { ...entry, name: newName } : entry),
statements: renameStatements(program.statements),
functions: program.functions.map((fn) => {
const shadowed = fn.parameters.some((entry) => entry.name === oldName) || fn.locals.some((entry) => entry.name === oldName);
return shadowed ? fn : { ...fn, statements: renameStatements(fn.statements),
locals: fn.locals.map((local) => ({ ...local,
initialValue: mapV2Expression(local.initialValue, renameExpression) })) };
}) };
}
function variableReferenceCount(program: TriggerProgramV2, name: string): number {
const serialized = JSON.stringify({ statements: program.statements, functions: program.functions });
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (serialized.match(new RegExp(`(?:var\\.|global\\.|local\\.)?${escaped}`, 'g')) ?? []).length;
}
function v2Dependencies(program: TriggerProgramV2): string[] {
const result = new Set<string>();
const visit = (statement: TriggerStatement) => {
if (statement.kind === 'CALL') result.add(`program → function:${statement.name}`);
if (statement.kind === 'ACTION' && statement.name === 'run_trigger') {
const target = statement.inputs.triggerId;
if (target?.kind === 'LITERAL') result.add(`program → trigger:${String(target.literal)}`);
}
[...statement.statements, ...statement.elseStatements,
...statement.cases.flatMap((branch) => branch.statements)].forEach(visit);
};
[...program.statements, ...program.functions.flatMap((entry) => entry.statements)].forEach(visit);
return [...result];
}
function visitV2NodeIds(program: TriggerProgramV2, visitor: (id: string) => void): void {
const expression = (value?: TriggerExpression) => { if (!value) return; visitor(value.nodeId); value.arguments.forEach(expression); };
const statements = (values: TriggerStatement[]) => values.forEach((statement) => {
visitor(statement.nodeId); Object.values(statement.inputs).forEach(expression); expression(statement.expression);
statement.cases.forEach((branch) => { visitor(branch.nodeId); expression(branch.match); statements(branch.statements); });
statements(statement.statements); statements(statement.elseStatements);
});
program.events.forEach((entry) => visitor(entry.nodeId));
const declaration = (entry: TriggerVariableDeclarationV2) => { visitor(entry.nodeId); expression(entry.initialValue); };
program.declarations.forEach(declaration);
program.functions.forEach((entry) => { visitor(entry.nodeId); entry.locals.forEach(declaration); statements(entry.statements); });
statements(program.statements);
}
function uniqueNodeIds(program: TriggerProgramV2): Set<string> {
const result = new Set<string>();
visitV2NodeIds(program, (id) => result.add(id));
return result;
}
function countV2Nodes(program: TriggerProgramV2): number {
let count = 0;
visitV2NodeIds(program, () => { count += 1; });
return count;
}
function toScript(value: TriggerDefinition): string {
const quote = (input: string) => `"${input.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"`;
const lines = [`# XFE Script v2`, `on ${value.event.type}`];
Object.entries(value.event.configuration).forEach(([key, entry]) => lines.push(`set ${key}=${quote(entry)}`));
(value.event.variables ?? []).forEach((variable) => lines.push(
`var ${variable.name} ${variable.type} initial=${quote(variable.initialValue)} visibility=${quote(variable.visibility ?? 'trigger')} storage=${quote(variable.storage ?? ((variable.visibility ?? 'trigger') === 'global' ? 'server' : 'trigger'))} lifetime=${quote(variable.lifetime ?? 'session')}${(variable.lifetime ?? 'session') === 'ttl' ? ` ttlSeconds=${variable.ttlSeconds ?? 3600}` : ''}`));
const appendArguments = (argumentsList: TriggerCommandArgument[], parent = '') => argumentsList.forEach((argument) => {
const fields = [parent && `parent=${quote(parent)}`, argument.literal && `literal=${quote(argument.literal)}`,
argument.optional && 'optional=true', argument.errorMessage && `error=${quote(argument.errorMessage)}`,
argument.minimum && `min=${quote(argument.minimum)}`, argument.maximum && `max=${quote(argument.maximum)}`,
argument.suggestions.length > 0 && `suggestions=${quote(argument.suggestions.join(','))}`].filter(Boolean).join(' ');
lines.push(`arg ${argument.name} ${argument.type}${fields ? ` ${fields}` : ''}`);
appendArguments(argument.children ?? [], argument.name);
});
appendArguments(value.event.arguments ?? []);
lines.push(`match ${value.conditionMode}`);
value.conditions.forEach((condition) => lines.push(`when ${condition.field} ${condition.operator}${conditionValueKind(condition.operator) === 'none' ? '' : ` ${quote(condition.value)}`}`));
const appendActions = (actions: TriggerAction[], depth: number) => actions.forEach((action) => {
const indentation = ' '.repeat(depth);
if (action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE) {
const field = action.parameters.field ?? '';
const operator = action.parameters.operator ?? '';
const conditionValue = action.parameters.value ?? '';
lines.push(`${indentation}if ${field} ${operator}${conditionValueKind(operator) === 'none' ? '' : ` ${quote(conditionValue)}`}`);
appendActions(actionChildren(action), depth + 1);
lines.push(`${indentation}end`);
return;
}
const parameters = Object.entries(action.parameters).map(([key, entry]) => `${key}=${quote(entry)}`).join(' ');
lines.push(`${indentation}do ${action.type}${parameters ? ` ${parameters}` : ''}`);
});
appendActions(value.actions, 0);
return lines.join('\n');
}
import {
ArrowDown, ArrowUp, Braces, ChevronDown, ChevronRight, CirclePlay, ClipboardPaste, Code2, Copy, CopyPlus,
FolderPlus, GripVertical, Maximize2, Minimize2, Plus, Save, Trash2, Workflow,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { RichTextEditor, richTextIsValid, richTextPlainText } from '../components/rich-text-editor';
import { variableAppliesToEvent } from '../components/rich-text-variables';
import { templateTimeFormatsAreValid, VariableTextInput } from '../components/variable-catalog-picker';
import { Badge, Button, Field, Input, PageHeader, Panel, ResourceState, Select, Textarea, Toast } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import { hasCapability } from '../lib/permissions';
import { insertTriggerNode, parseTriggerNode, serializeTriggerNode, triggerProgramFitsLimits,
type TriggerPasteTarget, type TriggerProgramNode } from '../lib/trigger-program-clipboard';
import type {
TriggerAction, TriggerCatalog, TriggerCommandArgument, TriggerCondition, TriggerDefinition, TriggerEventSpec,
TriggerEventCapture, TriggerEventSample, TriggerExecution, TriggerExpression, TriggerFunctionDeclaration,
TriggerGroup, TriggerLibraryInstallation, TriggerLibraryPackage, TriggerProgramV2, TriggerSimulationResponse,
TriggerStateVariableDefinition, TriggerStatement, TriggerValidation, TriggerVariableDeclarationV2,
TriggerVariableDefinition, TriggerWorkspace,
} from '../types';
const fallbackCatalog: TriggerCatalog = {
defaultMessageSender: 'XFEServerManager',
events: ['schedule.daily', 'schedule.interval', 'server.started', 'server.stopping', 'player.join',
'player.leave', 'player.chat', 'player.command', 'player.command_trigger', 'player.death', 'player.respawn',
'player.dimension_change', 'player.advancement', 'player.item_pickup', 'player.item_drop',
'player.item_use', 'player.item_use_finish', 'player.item_craft', 'player.attack', 'player.hurt',
'player.heal', 'player.sleep', 'player.wake', 'player.interact', 'player.entity_interact',
'block.break', 'block.place', 'block.change', 'block.grow', 'block.tool_modify', 'entity.spawn',
'entity.remove', 'entity.death', 'world.explosion', 'world.weather_change', 'world.load',
'world.unload', 'chunk.load', 'chunk.unload', 'variable.changed', 'menu.open', 'menu.close',
'menu.control', 'economy.balance_changed', 'economy.deposit', 'economy.withdraw', 'economy.set',
'economy.transfer', 'protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow', 'protection.mod_entity_overflow', 'protection.spawn_burst',
'protection.command_block_rate', 'protection.slow_tick', 'protection.loaded_chunk_overflow',
'protection.memory_pressure', 'custom'],
operators: ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'not_starts_with',
'ends_with', 'not_ends_with', 'matches', 'not_matches', 'gt', 'gte', 'lt', 'lte',
'between', 'not_between', 'in', 'not_in', 'exists', 'not_exists', 'empty', 'not_empty',
'true', 'false'],
actions: ['send_player', 'broadcast', 'title', 'actionbar', 'sound', 'server_command',
'player_command', 'kick', 'teleport', 'give_item', 'clear_inventory', 'set_gamemode',
'add_effect', 'remove_effects', 'heal', 'feed', 'set_time', 'set_weather', 'whitelist_add',
'whitelist_remove', 'ban', 'pardon', 'log', 'variable', 'wait', 'run_trigger', 'open_menu', 'close_menu',
'economy_deposit', 'economy_withdraw', 'economy_set_balance', 'economy_transfer',
'economy_deposit_player', 'economy_withdraw_player', 'economy_set_player_balance',
'economy_transfer_players'],
actionParameters: {
send_player: ['message'], broadcast: ['message'], title: ['title', 'subtitle', 'numberPrecision', 'fadeIn', 'stay', 'fadeOut'],
actionbar: ['message'], sound: ['sound', 'volume', 'pitch'], server_command: ['command', 'showFeedback'],
player_command: ['command', 'showFeedback'], kick: ['message'], log: ['message', 'level'],
teleport: ['destination'], give_item: ['item', 'count'], clear_inventory: ['item', 'maxCount'],
set_gamemode: ['gamemode'], add_effect: ['effect', 'duration', 'amplifier'], remove_effects: [],
heal: [], feed: [], set_time: ['time'], set_weather: ['weather', 'duration'], whitelist_add: [],
whitelist_remove: [], ban: ['reason'], pardon: [],
variable: ['name', 'operation', 'value', 'extra'],
wait: ['mode', 'value', 'timeout', 'pollTicks', 'field', 'operator', 'expected'],
run_trigger: ['triggerId', 'waitForCompletion'],
open_menu: ['menuId'], close_menu: [],
economy_deposit: ['currency', 'amount', 'reason'],
economy_withdraw: ['currency', 'amount', 'reason'],
economy_set_balance: ['currency', 'amount', 'reason'],
economy_transfer: ['currency', 'amount', 'target', 'reason'],
economy_deposit_player: ['player', 'currency', 'amount', 'reason'],
economy_withdraw_player: ['player', 'currency', 'amount', 'reason'],
economy_set_player_balance: ['player', 'currency', 'amount', 'reason'],
economy_transfer_players: ['source', 'target', 'currency', 'amount', 'reason'],
},
commandArgumentTypes: ['literal', 'bool', 'integer', 'long', 'float', 'double', 'coordinate', 'block_pos',
'column_pos', 'vec2', 'vec3', 'rotation', 'angle', 'string', 'word', 'greedy_string', 'player',
'players', 'entity', 'entities', 'game_profile', 'block_state', 'block_predicate', 'item_stack',
'item_predicate', 'color', 'component', 'message', 'nbt', 'nbt_tag', 'compound_tag', 'nbt_path', 'objective',
'objective_criteria', 'operation', 'score_holder', 'scoreboard_slot', 'swizzle', 'team',
'int_range', 'float_range', 'particle', 'resource_location', 'resource',
'resource_key', 'resource_or_tag', 'resource_or_tag_key', 'dimension', 'gamemode', 'time', 'uuid',
'function', 'entity_anchor', 'enchantment', 'mob_effect', 'item_slot', 'item_slots', 'template_mirror',
'template_rotation', 'heightmap', 'loot_table', 'loot_predicate', 'loot_modifier', 'biome',
'structure', 'advancement', 'recipe'],
triggerVariableTypes: ['bool', 'integer', 'float', 'string', 'coordinate', 'uuid', 'player',
'resource_location', 'block_state', 'item_stack', 'component', 'nbt', 'position', 'rotation',
'duration', 'instant', 'region_ref', 'player_ref', 'entity_ref', 'block_ref', 'item_ref', 'record',
'array', 'set', 'optional', 'dictionary', 'list'],
variableLifetimes: ['session', 'ttl', 'persistent'],
variableStorageScopes: ['server', 'player', 'dimension', 'trigger', 'execution', 'chunk', 'entity'],
};
const eventLabels: Record<string, [string, string]> = {
'schedule.daily': ['每天定时', 'Daily schedule'], 'schedule.interval': ['固定间隔', 'Interval'],
'server.started': ['服务器启动', 'Server started'], 'server.stopping': ['服务器停止', 'Server stopping'],
'player.join': ['玩家进入服务器', 'Player joined'], 'player.leave': ['玩家离开服务器', 'Player left'],
'player.chat': ['玩家聊天', 'Player chat'], 'player.command': ['玩家执行指令', 'Player command'],
'player.command_trigger': ['玩家输入自定义指令', 'Player enters a custom command'],
'player.death': ['玩家死亡', 'Player death'], 'player.respawn': ['玩家重生', 'Player respawn'],
'player.dimension_change': ['玩家切换维度', 'Dimension changed'],
'player.advancement': ['玩家完成进度', 'Advancement'], 'player.item_pickup': ['玩家拾取物品', 'Item pickup'],
'player.item_drop': ['玩家丢弃物品', 'Item dropped'], 'player.item_use': ['玩家使用物品', 'Item used'],
'player.item_use_finish': ['玩家使用完物品', 'Item use finished'], 'player.item_craft': ['玩家合成物品', 'Item crafted'],
'player.attack': ['玩家攻击', 'Player attack'], 'player.hurt': ['玩家受伤', 'Player hurt'],
'player.heal': ['玩家恢复生命', 'Player healed'], 'player.sleep': ['玩家入睡', 'Player slept'],
'player.wake': ['玩家醒来', 'Player woke'], 'player.interact': ['玩家交互', 'Player interact'],
'player.entity_interact': ['玩家与实体交互', 'Player interacted with entity'],
'block.break': ['方块被破坏', 'Block broken'], 'block.place': ['方块被放置', 'Block placed'],
'block.change': ['方块变化', 'Block changed'], 'block.grow': ['方块生长', 'Block grew'],
'block.tool_modify': ['工具修改方块', 'Block modified by tool'], 'entity.spawn': ['实体生成', 'Entity spawned'],
'entity.remove': ['实体移除', 'Entity removed'], 'entity.death': ['实体死亡', 'Entity died'],
'world.explosion': ['世界爆炸', 'Explosion'], 'world.weather_change': ['天气变化', 'Weather changed'],
'world.load': ['世界加载', 'World loaded'], 'world.unload': ['世界卸载', 'World unloaded'],
'chunk.load': ['区块加载', 'Chunk loaded'], 'chunk.unload': ['区块卸载', 'Chunk unloaded'],
'variable.changed': ['触发器变量发生变化', 'Trigger variable changed'],
'menu.open': ['菜单打开', 'Menu opened'], 'menu.close': ['菜单关闭', 'Menu closed'],
'menu.control': ['菜单控件交互', 'Menu control interaction'],
'economy.balance_changed': ['任意经济余额变化', 'Any economy balance change'],
'economy.deposit': ['货币增加', 'Economy deposit'], 'economy.withdraw': ['货币扣除', 'Economy withdrawal'],
'economy.set': ['余额设置', 'Economy balance set'], 'economy.transfer': ['玩家转账', 'Player transfer'],
'protection.item_overflow': ['掉落物数量超限', 'Dropped-item count exceeded'],
'protection.mob_overflow': ['生物数量超限', 'Mob count exceeded'],
'protection.entity_overflow': ['实体总数超限', 'Total entity count exceeded'],
'protection.mod_entity_overflow': ['指定模组实体超限', 'Mod entity count exceeded'],
'protection.spawn_burst': ['实体生成速率超限', 'Entity spawn rate exceeded'],
'protection.command_block_rate': ['命令方块执行过快', 'Command-block rate exceeded'],
'protection.slow_tick': ['服务器连续慢刻', 'Consecutive slow server ticks'],
'protection.loaded_chunk_overflow': ['加载区块数量超限', 'Loaded-chunk count exceeded'],
'protection.memory_pressure': ['堆内存使用率过高', 'Heap memory pressure'],
'region.enter': ['进入区域', 'Entered region'], 'region.leave': ['离开区域', 'Left region'],
'region.stay': ['停留在区域', 'Stayed in region'],
'custom.content': ['自定义内容交互', 'Custom content interaction'],
custom: ['自定义/模组事件', 'Custom/mod event'],
};
const eventDescriptions: Record<string, [string, string]> = {
'schedule.daily': ['每天在指定时区和时间运行一次。', 'Runs once per day at the configured time and time zone.'],
'schedule.interval': ['按照固定秒数或游戏刻间隔重复运行。', 'Repeats at the configured real-time or tick interval.'],
'server.started': ['服务器完成启动、可以处理游戏事件时运行。', 'Runs after the server has started and can process game events.'],
'server.stopping': ['服务器进入安全停止流程时运行。', 'Runs when the server enters its orderly shutdown sequence.'],
'player.command_trigger': ['玩家输入此触发器注册的自定义命令时运行,并提供命令参数。',
'Runs when a player enters the custom command registered by this trigger, with parsed arguments.'],
'variable.changed': ['持久或会话触发器变量成功写入新值后运行。', 'Runs after a session or persistent trigger variable changes.'],
'menu.control': ['玩家点击或修改 Web 菜单控件时运行。', 'Runs when a player activates or changes a menu control.'],
'region.enter': ['玩家或实体从区域外移动到区域内时运行。', 'Runs when a player or entity crosses into a region.'],
'region.leave': ['玩家或实体从区域内移动到区域外时运行。', 'Runs when a player or entity leaves a region.'],
'region.stay': ['玩家或实体持续位于区域内并达到检测间隔时运行。', 'Runs while a player or entity remains inside a region.'],
'custom.content': ['仅通过内容工作台的“调用触发器”行为显式调用,提供交互玩家与 content.* 响应;不会自动订阅所有内容。',
'Invoked explicitly by a Call trigger behavior in the content workbench, with the interacting player and content.* responses; does not automatically subscribe to all content.'],
custom: ['接收由其他模组通过命名空间发布并经过校验的事件。', 'Receives a validated namespaced event published by another mod.'],
};
const actionLabels: Record<string, [string, string]> = {
send_player: ['向事件玩家发消息', 'Message event player'], broadcast: ['全服广播', 'Broadcast'],
title: ['显示标题', 'Show title'], actionbar: ['显示操作栏', 'Show action bar'],
sound: ['播放声音', 'Play sound'], server_command: ['执行控制台指令', 'Run server command'],
player_command: ['以玩家身份执行指令', 'Run as player'], kick: ['踢出玩家', 'Kick player'],
teleport: ['传送玩家', 'Teleport player'], give_item: ['给予物品', 'Give item'],
clear_inventory: ['清理背包', 'Clear inventory'], set_gamemode: ['设置游戏模式', 'Set game mode'],
add_effect: ['添加效果', 'Add effect'], remove_effects: ['清除效果', 'Clear effects'],
heal: ['治疗玩家', 'Heal player'], feed: ['恢复饱食度', 'Feed player'],
set_time: ['设置世界时间', 'Set world time'], set_weather: ['设置天气', 'Set weather'],
whitelist_add: ['加入白名单', 'Add to whitelist'], whitelist_remove: ['移出白名单', 'Remove from whitelist'],
ban: ['封禁玩家', 'Ban player'], pardon: ['解除封禁', 'Pardon player'],
log: ['写入日志', 'Write log'],
variable: ['操作触发器变量', 'Modify trigger variable'],
wait: ['等待', 'Wait'], run_trigger: ['执行其他触发器', 'Run another trigger'],
open_menu: ['打开 UI 菜单', 'Open UI menu'], close_menu: ['关闭 UI 菜单', 'Close UI menu'],
economy_deposit: ['增加事件玩家余额', 'Deposit to event player'],
economy_withdraw: ['扣除事件玩家余额', 'Withdraw from event player'],
economy_set_balance: ['设置事件玩家余额', 'Set event player balance'],
economy_transfer: ['从事件玩家转账', 'Transfer from event player'],
economy_deposit_player: ['增加指定玩家余额', 'Deposit to selected player'],
economy_withdraw_player: ['扣除指定玩家余额', 'Withdraw from selected player'],
economy_set_player_balance: ['设置指定玩家余额', 'Set selected player balance'],
economy_transfer_players: ['指定玩家之间转账', 'Transfer between selected players'],
set_health: ['设置玩家生命值', 'Set player health'], set_food: ['设置玩家饱食度', 'Set player food'],
set_experience: ['设置玩家经验', 'Set player experience'], set_spawnpoint: ['设置重生点', 'Set spawn point'],
scoreboard_set: ['设置计分板分数', 'Set scoreboard score'], scoreboard_add: ['增加计分板分数', 'Add scoreboard score'],
team_join: ['加入计分板队伍', 'Join scoreboard team'], team_leave: ['离开计分板队伍', 'Leave scoreboard team'],
advancement_grant: ['授予进度', 'Grant advancement'], advancement_revoke: ['撤销进度', 'Revoke advancement'],
spawn_entity: ['生成实体', 'Spawn entity'], damage_entity: ['伤害实体', 'Damage entity'],
teleport_entity: ['传送实体', 'Teleport entity'], remove_entity: ['移除实体', 'Remove entity'],
set_entity_attribute: ['设置实体属性', 'Set entity attribute'], tag_entity: ['修改实体标签', 'Modify entity tag'],
set_block: ['设置方块', 'Set block'], fill_blocks: ['批量填充方块', 'Fill blocks'],
explosion: ['创建爆炸', 'Create explosion'], lightning: ['召唤闪电', 'Summon lightning'],
set_difficulty: ['设置难度', 'Set difficulty'], set_gamerule: ['设置游戏规则', 'Set game rule'],
world_border: ['修改世界边界', 'Modify world border'],
};
const actionDescriptions: Record<string, [string, string]> = {
send_player: ['向触发当前事件的玩家发送消息。需要玩家事件上下文。', 'Sends a message to the player that caused the event. Requires player context.'],
broadcast: ['向服务器内所有在线玩家发送消息。', 'Sends a message to every online player.'],
title: ['在事件玩家屏幕中央显示标题、副标题和淡入淡出效果。', 'Shows a title and subtitle with timing effects to the event player.'],
actionbar: ['在事件玩家快捷栏上方显示短消息。', 'Shows a short message above the event player hotbar.'],
sound: ['在事件玩家当前位置播放指定资源声音。', 'Plays a resource sound at the event player position.'],
server_command: ['以服务器控制台权限执行单行命令;仅 Owner 可配置。', 'Runs one command as the server console; Owner only.'],
player_command: ['让事件玩家执行一条命令;仅 Owner 可配置。', 'Runs one command as the event player; Owner only.'],
kick: ['断开事件玩家连接并显示原因。', 'Disconnects the event player with a reason.'],
teleport: ['将事件玩家传送到坐标、玩家或预设位置。', 'Teleports the event player to coordinates, another player, or a position.'],
give_item: ['向事件玩家背包给予指定物品和数量。', 'Gives an item stack to the event player inventory.'],
clear_inventory: ['从事件玩家背包移除全部或指定物品。', 'Removes all or selected items from the event player inventory.'],
set_gamemode: ['修改事件玩家的游戏模式。', 'Changes the event player game mode.'],
add_effect: ['为事件玩家添加药水效果、持续时间和等级。', 'Applies an effect with duration and amplifier to the event player.'],
remove_effects: ['清除事件玩家当前的全部药水效果。', 'Clears all active effects from the event player.'],
heal: ['将事件玩家恢复到最大生命值。', 'Restores the event player to maximum health.'],
feed: ['恢复事件玩家的饱食度与饱和度。', 'Restores the event player food and saturation.'],
set_time: ['修改事件世界的昼夜时间;仅 Owner 可配置。', 'Changes world day time; Owner only.'],
set_weather: ['修改事件世界的天气与持续时间;仅 Owner 可配置。', 'Changes world weather and duration; Owner only.'],
whitelist_add: ['将事件玩家身份加入服务器白名单。', 'Adds the event player identity to the server allowlist.'],
whitelist_remove: ['将事件玩家身份移出服务器白名单。', 'Removes the event player identity from the server allowlist.'],
ban: ['封禁事件玩家并记录原因。', 'Bans the event player and records a reason.'],
pardon: ['解除事件玩家的封禁。', 'Removes the event player ban.'],
log: ['向服务器日志写入指定级别的审计消息。', 'Writes an audit message at the selected server log level.'],
variable: ['设置、计算或修改声明过的触发器变量。', 'Sets, calculates, or mutates a declared trigger variable.'],
wait: ['暂停当前执行,等待时长、游戏时间或条件;可在重启后恢复。', 'Suspends this execution for a duration, game time, or condition and can resume after restart.'],
run_trigger: ['调用另一个触发器,可选择等待子执行完成。', 'Invokes another trigger and can wait for the child execution.'],
open_menu: ['为事件玩家打开指定的自定义菜单。', 'Opens a custom menu for the event player.'],
close_menu: ['关闭事件玩家当前打开的自定义菜单。', 'Closes the event player current custom menu.'],
economy_deposit: ['增加事件玩家的指定货币余额并写入流水。', 'Credits the event player currency balance and writes a ledger entry.'],
economy_withdraw: ['扣除事件玩家余额;余额不足时操作失败。', 'Debits the event player balance and fails when funds are insufficient.'],
economy_set_balance: ['将事件玩家的指定货币余额设置为目标值。', 'Sets the event player currency balance to an exact value.'],
economy_transfer: ['从事件玩家向目标玩家原子转账。', 'Atomically transfers currency from the event player to a target.'],
economy_deposit_player: ['增加通过名称、UUID 或引用指定的玩家余额。', 'Credits a player selected by name, UUID, or reference.'],
economy_withdraw_player: ['扣除通过名称、UUID 或引用指定的玩家余额。', 'Debits a player selected by name, UUID, or reference.'],
economy_set_player_balance: ['设置指定玩家的精确货币余额。', 'Sets an exact currency balance for a selected player.'],
economy_transfer_players: ['在两个指定玩家之间原子转账。', 'Atomically transfers currency between two selected players.'],
set_health: ['把事件玩家生命值限制并设置到给定数值。', 'Clamps and sets the event player health.'],
set_food: ['把事件玩家饱食度设置为 0 到 20。', 'Sets event player food level from 0 to 20.'],
set_experience: ['按经验点或等级设置事件玩家经验。', 'Sets event player experience as points or levels.'],
set_spawnpoint: ['设置事件玩家的维度与重生坐标。', 'Sets the event player respawn dimension and position.'],
scoreboard_set: ['将事件玩家在目标计分项的分数设为指定值。', 'Sets the event player score for an objective.'],
scoreboard_add: ['在事件玩家当前计分上增加指定值。', 'Adds a value to the event player score.'],
team_join: ['把事件玩家加入指定计分板队伍。', 'Adds the event player to a scoreboard team.'],
team_leave: ['让事件玩家离开当前计分板队伍。', 'Removes the event player from the current scoreboard team.'],
advancement_grant: ['向事件玩家授予指定 Minecraft 进度。', 'Grants a Minecraft advancement to the event player.'],
advancement_revoke: ['撤销事件玩家的指定 Minecraft 进度。', 'Revokes a Minecraft advancement from the event player.'],
spawn_entity: ['在指定维度和位置生成实体;仅 Owner 可配置。', 'Spawns an entity at a world position; Owner only.'],
damage_entity: ['对选择器匹配的实体造成指定类型与数值的伤害。', 'Damages entities matched by a selector.'],
teleport_entity: ['将选择器匹配的实体传送到目标位置。', 'Teleports entities matched by a selector.'],
remove_entity: ['安全移除选择器匹配的实体。', 'Safely removes entities matched by a selector.'],
set_entity_attribute: ['修改选择器匹配实体的属性基础值。', 'Changes an attribute base value on matched entities.'],
tag_entity: ['为选择器匹配实体添加或移除命令标签。', 'Adds or removes command tags on matched entities.'],
set_block: ['在指定维度和坐标设置一个方块;仅 Owner 可配置。', 'Sets one block at a dimension position; Owner only.'],
fill_blocks: ['在两个坐标之间批量填充方块;执行前受预算限制。', 'Fills blocks between two positions under an execution budget.'],
explosion: ['在指定世界位置创建受控爆炸。', 'Creates a controlled explosion at a world position.'],
lightning: ['在指定世界位置召唤闪电。', 'Summons lightning at a world position.'],
set_difficulty: ['修改服务器世界难度。', 'Changes server world difficulty.'],
set_gamerule: ['修改指定游戏规则并记录审计。', 'Changes a game rule with an audit record.'],
world_border: ['设置或移动世界边界中心、大小与过渡时间。', 'Sets or transitions world-border center and size.'],
};
const variableOperations = ['set', 'parse', 'add', 'subtract', 'multiply', 'divide', 'modulo',
'increment', 'decrement', 'append', 'prepend', 'replace', 'regex_replace', 'trim', 'upper',
'lower', 'escape_json', 'escape_command', 'escape_regex', 'toggle', 'list_add', 'list_remove',
'array_add', 'array_insert', 'array_remove', 'array_remove_at', 'array_set',
'dictionary_put', 'dictionary_remove', 'dictionary_merge', 'clear'];
const variableOperationLabels: Record<string, [string, string]> = {
set: ['设置值', 'Set'], parse: ['解析并设置', 'Parse and set'], add: ['数值相加', 'Add'],
subtract: ['数值相减', 'Subtract'], multiply: ['数值相乘', 'Multiply'], divide: ['数值相除', 'Divide'],
modulo: ['取余', 'Modulo'], increment: ['加一', 'Increment'], decrement: ['减一', 'Decrement'],
append: ['字符串追加', 'Append'], prepend: ['字符串前置', 'Prepend'], replace: ['文本替换', 'Replace'],
regex_replace: ['正则替换', 'Regex replace'], trim: ['去除首尾空白', 'Trim'], upper: ['转为大写', 'Uppercase'],
lower: ['转为小写', 'Lowercase'], escape_json: ['JSON 转义', 'Escape JSON'],
escape_command: ['命令字符串转义', 'Escape command'], escape_regex: ['正则转义', 'Escape regex'],
toggle: ['切换布尔值', 'Toggle'], list_add: ['列表添加', 'Add to list'],
list_remove: ['列表移除', 'Remove from list'], array_add: ['数组末尾添加', 'Append to array'],
array_insert: ['数组指定位置插入', 'Insert into array'], array_remove: ['移除数组匹配值', 'Remove array value'],
array_remove_at: ['移除数组下标', 'Remove array index'], array_set: ['设置数组下标', 'Set array index'],
dictionary_put: ['设置字典键值', 'Put dictionary entry'], dictionary_remove: ['删除字典键', 'Remove dictionary key'],
dictionary_merge: ['合并字典', 'Merge dictionary'], clear: ['清空/归零', 'Clear/reset'],
};
type TriggerModuleKind = 'event' | 'argument' | 'variable' | 'condition' | 'action';
interface TriggerModuleClipboard { marker: 'xfesm-trigger-module-v1'; kind: TriggerModuleKind; value: unknown }
let triggerModuleClipboard: TriggerModuleClipboard | undefined;
async function copyTriggerModule(kind: TriggerModuleKind, value: unknown): Promise<void> {
const envelope: TriggerModuleClipboard = { marker: 'xfesm-trigger-module-v1', kind, value: structuredClone(value) };
triggerModuleClipboard = envelope;
try { await navigator.clipboard?.writeText(JSON.stringify(envelope)); } catch { /* Internal clipboard remains available. */ }
}
async function pasteTriggerModule<T>(kind: TriggerModuleKind): Promise<T | undefined> {
let envelope = triggerModuleClipboard;
try {
const text = await navigator.clipboard?.readText();
if (text) {
const candidate = JSON.parse(text) as Partial<TriggerModuleClipboard>;
if (candidate.marker === 'xfesm-trigger-module-v1' && candidate.kind === kind) {
envelope = candidate as TriggerModuleClipboard;
}
}
} catch { /* Browser permission may deny system clipboard reads; use the internal copy. */ }
return envelope?.kind === kind ? structuredClone(envelope.value) as T : undefined;
}
function ModuleCopyButton({ kind, value, zh, name }: { kind: TriggerModuleKind; value: unknown; zh: boolean; name: string }) {
return <Button type="button" variant="ghost" title={zh ? `复制${name}` : `Copy ${name}`}
aria-label={zh ? `复制${name}` : `Copy ${name}`} onClick={() => void copyTriggerModule(kind, value)}><Copy size={14} /></Button>;
}
const waitModes = ['duration', 'ticks', 'game_time', 'condition'];
const waitModeLabels: Record<string, [string, string]> = {
duration: ['现实时间(秒)', 'Real duration (seconds)'], ticks: ['服务器游戏刻', 'Server ticks'],
game_time: ['等待到游戏时间', 'Until game time'], condition: ['等待判断条件成立', 'Until condition matches'],
};
const actionParameterLabels: Record<string, [string, string]> = {
message: ['消息内容', 'Message'], title: ['主标题', 'Title'], subtitle: ['副标题', 'Subtitle'],
fadeIn: ['淡入时间(游戏刻)', 'Fade-in ticks'], stay: ['停留时间(游戏刻)', 'Stay ticks'],
fadeOut: ['淡出时间(游戏刻)', 'Fade-out ticks'], sound: ['声音资源 ID', 'Sound resource ID'],
volume: ['音量', 'Volume'], pitch: ['音调', 'Pitch'], command: ['命令(不含 /)', 'Command (without /)'],
destination: ['目标位置', 'Destination'], item: ['物品资源 ID', 'Item resource ID'], count: ['数量', 'Count'],
maxCount: ['最大移除数量', 'Maximum removal count'], gamemode: ['游戏模式', 'Game mode'],
effect: ['效果资源 ID', 'Effect resource ID'], duration: ['持续时间', 'Duration'], amplifier: ['效果等级', 'Amplifier'],
time: ['世界时间', 'World time'], weather: ['天气', 'Weather'], level: ['日志级别', 'Log level'],
name: ['变量', 'Variable'], operation: ['操作方式', 'Operation'], value: ['值/等待量', 'Value / wait amount'],
extra: ['第二值/替换内容', 'Second value / replacement'], mode: ['模式', 'Mode'],
timeout: ['超时秒数(0 为无超时)', 'Timeout seconds (0 = none)'],
pollTicks: ['检测频率(游戏刻)', 'Check interval (ticks)'], field: ['判断字段', 'Condition field'],
operator: ['判断方式', 'Operator'], expected: ['期望值', 'Expected value'],
triggerId: ['目标触发器', 'Target trigger'], waitForCompletion: ['调用方式', 'Invocation mode'],
menuId: ['目标菜单', 'Target menu'],
currency: ['货币(代码或变量)', 'Currency (code or variable)'],
amount: ['金额', 'Amount'], target: ['目标玩家(名称、UUID 或变量)', 'Target player (name, UUID, or variable)'],
player: ['指定玩家(名称、UUID 或变量)', 'Selected player (name, UUID, or variable)'],
source: ['来源玩家(名称、UUID 或变量)', 'Source player (name, UUID, or variable)'],
reason: ['流水原因', 'Ledger reason'],
showFeedback: ['显示命令反馈', 'Show command feedback'],
numberPrecision: ['浮点变量保留位数', 'Floating-point fraction digits'],
position: ['世界位置', 'World position'], selector: ['实体选择器', 'Entity selector'],
entityType: ['实体类型资源 ID', 'Entity type resource ID'], nbt: ['实体 NBT', 'Entity NBT'],
damageType: ['伤害类型', 'Damage type'], attribute: ['实体属性', 'Entity attribute'],
tag: ['实体标签', 'Entity tag'], dimension: ['维度资源 ID', 'Dimension resource ID'],
block: ['方块状态/资源 ID', 'Block state/resource ID'], from: ['起点位置', 'Start position'],
to: ['终点位置', 'End position'], power: ['爆炸威力', 'Explosion power'], fire: ['是否产生火焰', 'Create fire'],
difficulty: ['世界难度', 'World difficulty'], rule: ['游戏规则', 'Game rule'],
seconds: ['过渡秒数', 'Transition seconds'], objective: ['计分项目标', 'Scoreboard objective'],
team: ['计分板队伍', 'Scoreboard team'], advancement: ['进度资源 ID', 'Advancement resource ID'],
};
const actionParameterDescriptions: Record<string, [string, string]> = {
message: ['支持常量、事件响应、变量和函数表达式。', 'Accepts constants, event responses, variables, and function expressions.'],
selector: ['可使用 UUID、持久实体引用或受限 Minecraft 选择器。', 'Accepts a UUID, persistent entity reference, or bounded Minecraft selector.'],
position: ['使用 position 表达式,或“维度 + x/y/z”结构。', 'Use a position expression or a dimension plus x/y/z record.'],
destination: ['可引用玩家、实体或明确的世界坐标。', 'May reference a player, entity, or explicit world coordinates.'],
duration: ['动作不同,可表示秒数、游戏刻或 duration 类型。', 'Depending on the action, this is seconds, ticks, or a duration value.'],
operation: ['选择该动作要执行的具体修改方式。', 'Selects the concrete mutation performed by this action.'],
waitForCompletion: ['启用后,父触发器会等待子触发器结束再继续。', 'When enabled, the parent waits for the child trigger to finish.'],
showFeedback: ['默认关闭,避免触发器命令刷屏。', 'Disabled by default to avoid command feedback spam.'],
nbt: ['仅接受受限、可序列化的 NBT 文本;留空使用实体默认值。', 'Accepts bounded serializable NBT text; leave empty for defaults.'],
};
const catalogKindLabels: Record<string, [string, string]> = {
EVENT: ['事件', 'Event'], EVENT_RESPONSE: ['事件响应', 'Event response'],
VALUE_FUNCTION: ['值函数', 'Value function'], CONDITION_FUNCTION: ['条件函数', 'Condition function'],
ACTION: ['动作', 'Action'], TYPE: ['类型', 'Type'], ALL: ['全部六类', 'All six categories'],
};
const statementKindLabels: Record<string, [string, string]> = {
ACTION: ['执行动作', 'Action'], SET: ['设置变量', 'Set variable'], IF: ['条件分支', 'If'],
SWITCH: ['多路分支', 'Switch'], REPEAT: ['定次数循环', 'Repeat'], WHILE: ['条件循环', 'While'],
FOREACH: ['遍历集合', 'For each'], CALL: ['调用函数', 'Call'], RETURN: ['返回', 'Return'],
TRY: ['错误处理', 'Try / catch'], BREAK: ['跳出循环', 'Break'], CONTINUE: ['继续循环', 'Continue'],
};
const expressionKindLabels: Record<string, [string, string]> = {
LITERAL: ['常量', 'Literal'], REFERENCE: ['变量/事件引用', 'Reference'], UNARY: ['一元运算', 'Unary operation'],
BINARY: ['二元运算', 'Binary operation'], FUNCTION: ['函数调用', 'Function call'], INDEX: ['集合索引', 'Collection index'],
COALESCE: ['空值回退', 'Null coalescing'], CONVERT: ['类型转换', 'Type conversion'],
};
const triggerTypeLabels: Record<string, [string, string]> = {
void: ['无返回值', 'No value'], any: ['任意值', 'Any value'], bool: ['布尔值', 'Boolean'],
integer: ['整数', 'Integer'], float: ['小数', 'Number'], string: ['文本', 'Text'], coordinate: ['坐标分量', 'Coordinate'],
uuid: ['UUID', 'UUID'], player: ['在线玩家', 'Online player'], resource_location: ['资源 ID', 'Resource ID'],
block_state: ['方块状态', 'Block state'], item_stack: ['物品堆', 'Item stack'], component: ['富文本组件', 'Text component'],
nbt: ['NBT 数据', 'NBT data'], position: ['世界位置', 'World position'], rotation: ['旋转角度', 'Rotation'],
duration: ['时间长度', 'Duration'], instant: ['时间点', 'Instant'], region_ref: ['区域引用', 'Region reference'],
player_ref: ['玩家引用', 'Player reference'], entity_ref: ['实体引用', 'Entity reference'],
block_ref: ['方块引用', 'Block reference'], item_ref: ['物品引用', 'Item reference'], record: ['记录结构', 'Record'],
array: ['数组', 'Array'], set: ['集合', 'Set'], optional: ['可空值', 'Optional'], dictionary: ['字典', 'Dictionary'], list: ['列表', 'List'],
};
const triggerTypeDescriptions: Record<string, [string, string]> = {
player_ref: ['仅持久化玩家 UUID;使用前会重新验证玩家。', 'Persists only a player UUID and revalidates it before use.'],
entity_ref: ['仅持久化实体 UUID 与维度;实体失效时返回空值。', 'Persists entity UUID and dimension; resolves to empty when invalid.'],
block_ref: ['持久化维度与方块坐标,读取时重新查询方块。', 'Persists dimension and block coordinates and queries the block again on use.'],
item_ref: ['持久化物品资源 ID,不保存 Minecraft/Java 对象。', 'Persists an item resource ID rather than a Minecraft or Java object.'],
region_ref: ['引用已声明的长方体、球体或圆柱区域。', 'References a declared cuboid, sphere, or cylinder region.'],
optional: ['显式表示可能不存在的强类型值。', 'Explicitly represents a typed value that may be absent.'],
record: ['由命名字段组成的声明式结构。', 'A declarative structure made of named fields.'],
};
const functionLabels: Record<string, [string, string]> = {
len: ['获取长度', 'Length'], contains: ['是否包含', 'Contains'], starts_with: ['是否以文本开头', 'Starts with'],
ends_with: ['是否以文本结尾', 'Ends with'], lower: ['转为小写', 'Lowercase'], upper: ['转为大写', 'Uppercase'],
abs: ['绝对值', 'Absolute value'], min: ['最小值', 'Minimum'], max: ['最大值', 'Maximum'], clamp: ['限制数值范围', 'Clamp'],
random_float: ['确定性随机小数', 'Deterministic random number'], random_int: ['确定性随机整数', 'Deterministic random integer'],
instant: ['解析时间点', 'Parse instant'], is_null: ['是否为空', 'Is null'], chance: ['概率判断', 'Chance'],
random_pick: ['随机选择元素', 'Random pick'], distance: ['计算距离', 'Distance'], has_tag: ['是否含标签', 'Has tag'],
};
const functionDescriptions: Record<string, [string, string]> = {
random_float: ['使用当前执行种子生成可回放的小数结果。', 'Uses the execution seed to produce a replayable random number.'],
random_int: ['在给定整数范围内生成可回放的随机结果。', 'Produces a replayable random integer within a range.'],
chance: ['按 0 到 1 的概率值进行可复现判断。', 'Performs a reproducible probability check from 0 to 1.'],
random_pick: ['从集合中按执行种子选择一个元素。', 'Selects one collection element using the execution seed.'],
distance: ['计算两个 position、实体或玩家引用之间的距离。', 'Calculates distance between positions, entity references, or player references.'],
has_tag: ['检查实体、方块或物品是否包含指定标签。', 'Checks whether an entity, block, or item contains a tag.'],
};
const storageLabels: Record<string, [string, string]> = {
server: ['服务器', 'Server'], player: ['每个玩家', 'Per player'], dimension: ['每个维度', 'Per dimension'],
trigger: ['当前触发器', 'Trigger'], execution: ['当前执行实例', 'Execution'], chunk: ['每个区块', 'Per chunk'], entity: ['每个实体', 'Per entity'],
};
const lifetimeLabels: Record<string, [string, string]> = {
SESSION: ['本次运行会话', 'Session'], TTL: ['限时持久化', 'Time to live'], PERSISTENT: ['永久持久化', 'Persistent'],
};
const visibilityLabels: Record<string, [string, string]> = {
trigger: ['仅当前触发器', 'Trigger only'], global: ['所有触发器共享', 'Shared globally'],
};
const templateLabels: Record<string, [string, string]> = {
welcome: ['欢迎消息', 'Welcome message'], 'region-task': ['区域任务', 'Region task'],
'kill-counter': ['击杀计数', 'Kill counter'], 'timed-boss': ['定时 Boss', 'Timed boss'],
'economy-reward': ['经济奖励', 'Economy reward'], 'menu-interaction': ['菜单交互', 'Menu interaction'],
'performance-alert': ['性能告警', 'Performance alert'],
};
const templateDescriptions: Record<string, [string, string]> = {
welcome: ['玩家加入服务器时发送欢迎消息。', 'Greets each player when they join the server.'],
'region-task': ['进入区域后更新每个玩家的持久任务进度。', 'Updates durable per-player task progress on region entry.'],
'kill-counter': ['统计归因于玩家的实体击杀数量。', 'Counts entity kills attributed to each player.'],
'timed-boss': ['按照固定间隔在指定位置生成 Boss。', 'Spawns a boss at a configured position on an interval.'],
'economy-reward': ['玩家完成进度时发放货币奖励。', 'Rewards a player with currency after an advancement.'],
'menu-interaction': ['处理菜单控件事件并打开目标菜单。', 'Handles a menu-control event and opens a target menu.'],
'performance-alert': ['服务器连续慢刻时向全服广播性能告警。', 'Broadcasts a performance alert after sustained slow ticks.'],
};
const executionStatusLabels: Record<string, [string, string]> = {
RUNNING: ['运行中', 'Running'], WAITING: ['等待中', 'Waiting'], COMPLETED: ['已完成', 'Completed'],
FAILED: ['失败', 'Failed'], NEEDS_REVIEW: ['需要人工复核', 'Needs review'], SIMULATED: ['模拟完成', 'Simulated'],
CANCELLED: ['已取消', 'Cancelled'],
};
const traceKindLabels: Record<string, [string, string]> = {
PROGRAM: ['程序', 'Program'], EVENT: ['事件', 'Event'], EXPRESSION: ['表达式', 'Expression'],
CONDITION: ['条件', 'Condition'], ACTION: ['动作', 'Action'], VARIABLE: ['变量写入', 'Variable write'],
WAIT: ['等待', 'Wait'], CALL: ['调用', 'Call'], ERROR: ['错误', 'Error'], FAILURE: ['失败', 'Failure'],
};
function variableOperationLabel(value: string, zh: boolean): string {
return `${label(variableOperationLabels[value], zh, value)} · ${value}`;
}
function waitModeLabel(value: string, zh: boolean): string {
return `${label(waitModeLabels[value], zh, value)} · ${value}`;
}
function actionParameterLabel(value: string, zh: boolean): string {
return label(actionParameterLabels[value], zh, value);
}
function actionParameterDescription(value: string, zh: boolean): string {
return label(actionParameterDescriptions[value], zh,
zh ? `参数技术名称:${value}` : `Technical parameter name: ${value}`);
}
type ConditionValueKind = 'text' | 'number' | 'range' | 'list' | 'regex' | 'none';
type ConditionOperatorCategory = 'comparison' | 'text' | 'number' | 'list' | 'presence' | 'extension';
interface ConditionOperatorMetadata {
labels: [string, string];
category: ConditionOperatorCategory;
valueKind: ConditionValueKind;
}
const conditionOperatorMetadata: Record<string, ConditionOperatorMetadata> = {
eq: { labels: ['等于', 'Equals'], category: 'comparison', valueKind: 'text' },
neq: { labels: ['不等于', 'Does not equal'], category: 'comparison', valueKind: 'text' },
contains: { labels: ['包含指定文本', 'Contains the specified text'], category: 'text', valueKind: 'text' },
not_contains: { labels: ['不包含指定文本', 'Does not contain the specified text'], category: 'text', valueKind: 'text' },
starts_with: { labels: ['以指定文本开头', 'Starts with the specified text'], category: 'text', valueKind: 'text' },
not_starts_with: { labels: ['不以指定文本开头', 'Does not start with the specified text'], category: 'text', valueKind: 'text' },
ends_with: { labels: ['以指定文本结尾', 'Ends with the specified text'], category: 'text', valueKind: 'text' },
not_ends_with: { labels: ['不以指定文本结尾', 'Does not end with the specified text'], category: 'text', valueKind: 'text' },
matches: { labels: ['匹配正则表达式', 'Matches a regular expression'], category: 'text', valueKind: 'regex' },
not_matches: { labels: ['不匹配正则表达式', 'Does not match a regular expression'], category: 'text', valueKind: 'regex' },
gt: { labels: ['大于', 'Is greater than'], category: 'number', valueKind: 'number' },
gte: { labels: ['大于或等于', 'Is greater than or equal to'], category: 'number', valueKind: 'number' },
lt: { labels: ['小于', 'Is less than'], category: 'number', valueKind: 'number' },
lte: { labels: ['小于或等于', 'Is less than or equal to'], category: 'number', valueKind: 'number' },
between: { labels: ['介于两个数值之间(含边界)', 'Is between two numbers (inclusive)'], category: 'number', valueKind: 'range' },
not_between: { labels: ['不在两个数值之间(含边界)', 'Is not between two numbers (inclusive)'], category: 'number', valueKind: 'range' },
in: { labels: ['属于列表中的任一值', 'Is one of the listed values'], category: 'list', valueKind: 'list' },
not_in: { labels: ['不属于列表中的任一值', 'Is not one of the listed values'], category: 'list', valueKind: 'list' },
exists: { labels: ['字段存在', 'Field exists'], category: 'presence', valueKind: 'none' },
not_exists: { labels: ['字段不存在', 'Field does not exist'], category: 'presence', valueKind: 'none' },
empty: { labels: ['字段为空', 'Field is empty'], category: 'presence', valueKind: 'none' },
not_empty: { labels: ['字段不为空', 'Field is not empty'], category: 'presence', valueKind: 'none' },
true: { labels: ['字段为真', 'Field is true'], category: 'presence', valueKind: 'none' },
false: { labels: ['字段为假', 'Field is false'], category: 'presence', valueKind: 'none' },
};
const conditionOperatorCategories: Array<{ category: ConditionOperatorCategory; labels: [string, string] }> = [
{ category: 'comparison', labels: ['基础比较', 'Basic comparisons'] },
{ category: 'text', labels: ['文本与正则', 'Text and regular expressions'] },
{ category: 'number', labels: ['数值比较', 'Numeric comparisons'] },
{ category: 'list', labels: ['列表判断', 'List membership'] },
{ category: 'presence', labels: ['字段状态', 'Field state'] },
{ category: 'extension', labels: ['扩展判断', 'Extension operators'] },
];
const conditionFieldLabels: Record<string, [string, string]> = {
'player.uuid': ['玩家 UUID', 'Player UUID'], 'player.name': ['玩家名称', 'Player name'],
'player.firstJoin': ['玩家是否首次加入', 'Whether this is the player’s first join'],
'player.dailyDue': ['玩家今日消息是否待发送', 'Whether the player’s daily message is due'],
'player.dimension': ['玩家当前维度', 'Player’s current dimension'],
'player.fromDimension': ['玩家原维度', 'Player’s previous dimension'],
'player.toDimension': ['玩家目标维度', 'Player’s destination dimension'],
'player.x': ['玩家 X 坐标', 'Player X coordinate'], 'player.y': ['玩家 Y 坐标', 'Player Y coordinate'],
'player.z': ['玩家 Z 坐标', 'Player Z coordinate'], 'server.online': ['在线玩家数', 'Online player count'],
'server.maxPlayers': ['最大玩家数', 'Maximum player count'],
'server.maintenanceEnabled': ['维护模式是否启用', 'Whether maintenance mode is enabled'],
'server.maintenanceMessage': ['维护提示消息', 'Maintenance message'],
'server.defaultMessageSender': ['默认消息发送者', 'Default message sender'],
'chat.message': ['聊天消息', 'Chat message'], 'command.value': ['玩家执行的命令', 'Command entered by the player'],
'command.name': ['自定义指令名称', 'Custom command name'],
'command.raw': ['自定义指令原始输入', 'Raw custom command input'],
'advancement.id': ['进度 ID', 'Advancement ID'], 'block.id': ['方块 ID', 'Block ID'],
'block.x': ['方块 X 坐标', 'Block X coordinate'], 'block.y': ['方块 Y 坐标', 'Block Y coordinate'],
'block.z': ['方块 Z 坐标', 'Block Z coordinate'], 'block.previousId': ['变化前方块 ID', 'Previous block ID'],
'item.id': ['物品 ID', 'Item ID'], 'item.count': ['物品数量', 'Item count'],
'item.resultId': ['合成结果物品 ID', 'Crafting result item ID'],
'item.resultCount': ['合成结果数量', 'Crafting result count'],
'interaction.hand': ['交互使用的手', 'Interaction hand'], 'use.duration': ['使用持续时间', 'Use duration'],
'damage.amount': ['伤害数值', 'Damage amount'], 'damage.type': ['伤害类型', 'Damage type'],
'attacker.uuid': ['攻击者 UUID', 'Attacker UUID'], 'attacker.type': ['攻击者类型', 'Attacker type'],
'attacker.name': ['攻击者名称', 'Attacker name'], 'heal.amount': ['治疗数值', 'Healing amount'],
'sleep.x': ['睡眠位置 X 坐标', 'Sleep position X coordinate'],
'sleep.y': ['睡眠位置 Y 坐标', 'Sleep position Y coordinate'],
'sleep.z': ['睡眠位置 Z 坐标', 'Sleep position Z coordinate'], 'sleep.result': ['睡眠结果', 'Sleep result'],
'tool.action': ['工具操作', 'Tool action'], 'entity.uuid': ['实体 UUID', 'Entity UUID'],
'entity.type': ['实体类型', 'Entity type'], 'entity.x': ['实体 X 坐标', 'Entity X coordinate'],
'entity.y': ['实体 Y 坐标', 'Entity Y coordinate'], 'entity.z': ['实体 Z 坐标', 'Entity Z coordinate'],
'world.dimension': ['世界维度', 'World dimension'], 'chunk.x': ['区块 X 坐标', 'Chunk X coordinate'],
'chunk.z': ['区块 Z 坐标', 'Chunk Z coordinate'], 'chunk.new': ['是否为新生成区块', 'Whether the chunk is new'],
'explosion.affectedBlocks': ['爆炸影响的方块数', 'Blocks affected by the explosion'],
'weather.raining': ['是否下雨', 'Whether it is raining'],
'weather.thundering': ['是否雷暴', 'Whether it is thundering'],
'event.type': ['事件类型', 'Event type'], 'event.time': ['事件发生时间', 'Event time'],
'economy.primary.code': ['主货币代码', 'Primary currency code'],
'economy.primary.balance': ['事件玩家主货币余额', 'Event player primary balance'],
'economy.transaction.type': ['经济交易类型', 'Economy transaction type'],
'economy.transaction.amount': ['经济交易金额', 'Economy transaction amount'],
'economy.transaction.origin': ['经济交易来源', 'Economy transaction origin'],
'economy.transaction.actor': ['经济交易执行者', 'Economy transaction actor'],
'economy.transaction.correlationId': ['经济交易关联 ID', 'Economy transaction correlation ID'],
'economy.currency.code': ['交易货币代码', 'Transaction currency code'],
'economy.currency.icon': ['交易货币图标', 'Transaction currency icon'],
'economy.currency.fractionDigits': ['交易货币小数位数', 'Currency fraction digits'],
'economy.source.balanceBefore': ['来源账户变更前余额', 'Source balance before'],
'economy.source.balanceAfter': ['来源账户变更后余额', 'Source balance after'],
'economy.target.balanceBefore': ['目标账户变更前余额', 'Target balance before'],
'economy.target.balanceAfter': ['目标账户变更后余额', 'Target balance after'],
'protection.kind': ['防护类型', 'Protection kind'],
'protection.scope': ['统计范围', 'Protection scope'],
'protection.count': ['当前数量/测量值', 'Observed count/value'],
'protection.threshold': ['服务端防护阈值', 'Server protection threshold'],
'protection.excess': ['超出数量', 'Excess amount'],
'protection.removed': ['已清理掉落物数', 'Removed dropped items'],
'protection.action': ['自动防护动作', 'Automatic protection action'],
'protection.reason': ['自动拦截原因', 'Automatic block reason'],
'protection.namespace': ['实体模组命名空间', 'Entity mod namespace'],
'protection.windowTicks': ['统计窗口游戏刻', 'Window ticks'],
'protection.windowSeconds': ['统计窗口秒数', 'Window seconds'],
'protection.consecutive': ['连续慢刻数', 'Consecutive slow ticks'],
'protection.heapUsedBytes': ['堆内存已用字节', 'Heap used bytes'],
'protection.heapMaxBytes': ['堆内存上限字节', 'Heap maximum bytes'],
'protection.commandSource': ['命令方块来源', 'Command-block source'],
date: ['当前日期', 'Current date'],
};
const baseFieldSuggestions = Object.keys(conditionFieldLabels);
const booleanConditionFields = new Set([
'player.firstJoin', 'player.dailyDue', 'server.maintenanceEnabled', 'chunk.new',
'weather.raining', 'weather.thundering',
]);
const numericConditionFields = new Set([
'player.x', 'player.y', 'player.z', 'server.online', 'server.maxPlayers', 'block.x', 'block.y',
'block.z', 'item.count', 'item.resultCount', 'use.duration', 'damage.amount', 'heal.amount',
'sleep.x', 'sleep.y', 'sleep.z', 'entity.x', 'entity.y', 'entity.z', 'chunk.x', 'chunk.z',
'explosion.affectedBlocks',
'economy.primary.balance', 'economy.currencyCount', 'economy.currency.fractionDigits', 'economy.transaction.amount', 'economy.source.balanceBefore', 'economy.source.balanceAfter',
'economy.target.balanceBefore', 'economy.target.balanceAfter',
'protection.count', 'protection.threshold', 'protection.excess', 'protection.removed',
'protection.windowTicks', 'protection.windowSeconds', 'protection.consecutive',
'protection.heapUsedBytes', 'protection.heapMaxBytes',
]);
const playerContextActions = new Set([
'send_player', 'title', 'actionbar', 'sound', 'player_command', 'kick', 'teleport', 'give_item',
'clear_inventory', 'set_gamemode', 'add_effect', 'remove_effects', 'heal', 'feed',
'whitelist_add', 'whitelist_remove', 'ban', 'pardon', 'open_menu', 'close_menu',
'economy_deposit', 'economy_withdraw', 'economy_set_balance', 'economy_transfer',
]);
const onlinePlayerActions = new Set([
'send_player', 'title', 'actionbar', 'sound', 'player_command', 'kick', 'teleport', 'give_item',
'clear_inventory', 'set_gamemode', 'add_effect', 'remove_effects', 'heal', 'feed',
'open_menu', 'close_menu',
]);
const commandActions = new Set(['server_command', 'player_command']);
const CONDITION_ACTION_TYPE = 'condition';
const commandPartPattern = /^[a-z][a-z0-9_-]{0,31}$/;
function blankTrigger(groupId: string): TriggerDefinition {
const now = new Date().toISOString();
const eventNode = newNodeId();
const actionNode = newNodeId();
return {
schemaVersion: 2,
id: '', groupId, name: 'New trigger', description: '', enabled: true, mode: 'visual',
event: { type: 'player.join', configuration: {} }, conditionMode: 'all', conditions: [],
actions: [{ type: 'send_player', parameters: { message: 'Welcome, {player.name}!' } }],
events: [{ nodeId: eventNode, type: 'player.join', configuration: {} }], declarations: [], functions: [],
statements: [{ nodeId: actionNode, kind: 'ACTION', name: 'send_player',
inputs: { message: literalExpression('Welcome, adventurer!', 'string') },
cases: [], statements: [], elseStatements: [] }],
script: '', revision: 0, createdBy: '', createdAt: now, updatedAt: now, migrated: false,
};
}
export function TriggersPage() {
const { api, session } = useServer();
const { locale } = useI18n();
const zh = locale === 'zh-CN';
const l = useCallback((chinese: string, english: string) => zh ? chinese : english, [zh]);
const resource = useApiResource(() => api.triggerWorkspace(), [], ['triggers-changed']);
const catalogResource = useApiResource(() => api.triggerCatalog(), []);
const menuResource = useApiResource(() => api.menuWorkspace(), [], ['menus-changed']);
const economyResource = useApiResource(() => api.economy(), [], ['economy-changed']);
const catalog = catalogResource.data ?? fallbackCatalog;
const writable = hasCapability(session?.actor, 'trigger_write');
const commandCapable = hasCapability(session?.actor, 'console_execute');
const [groupId, setGroupId] = useState('');
const [triggerId, setTriggerId] = useState('');
const [groupDraft, setGroupDraft] = useState<TriggerGroup>();
const [draft, setDraft] = useState<TriggerDefinition>();
const [showGroupSettings, setShowGroupSettings] = useState(false);
const [focused, setFocused] = useState(false);
useEffect(() => { if (!draft) setFocused(false); }, [draft]);
const [groupBaseline, setGroupBaseline] = useState('');
const [draftBaseline, setDraftBaseline] = useState('');
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ message: string; tone: 'good' | 'danger' | 'warn' }>();
const mutationInFlight = useRef(false);
const knownTriggerIds = useRef<Set<string>>(new Set());
const pendingGroupId = useRef('');
const orphanedGroupId = useRef('');
const groupGeneration = useRef(0);
const selectionGeneration = useRef(0);
const groups = resource.data?.groups ?? [];
const group = groups.find((item) => item.id === groupId);
const selected = group?.triggers.find((item) => item.id === triggerId);
const persistedDraft = group?.triggers.find((item) => item.id === draft?.id);
const commandLocked = Boolean(!commandCapable && persistedDraft && triggerUsesCommandAction(persistedDraft));
const groupDirty = Boolean(groupDraft && (!groupDraft.id
|| (groupBaseline && groupFingerprint(groupDraft) !== groupBaseline)));
const draftDirty = Boolean(draft && (!draft.id || triggerFingerprint(draft) !== draftBaseline));
const clearTriggerSelection = useCallback(() => {
selectionGeneration.current += 1;
setTriggerId('');
setDraft(undefined);
setDraftBaseline('');
}, []);
const confirmDiscard = useCallback((includeGroup: boolean) => {
if (!draftDirty && (!includeGroup || !groupDirty)) return true;
return window.confirm(l(
'当前有尚未保存的修改,确定丢弃并继续?',
'You have unsaved changes. Discard them and continue?'));
}, [draftDirty, groupDirty, l]);
useEffect(() => {
if (pendingGroupId.current && groups.some((item) => item.id === pendingGroupId.current)) {
setGroupId(pendingGroupId.current);
if (draft?.groupId !== pendingGroupId.current) clearTriggerSelection();
if (groupDraft?.id !== pendingGroupId.current) {
setGroupDraft(undefined);
setGroupBaseline('');
}
pendingGroupId.current = '';
return;
}
if (!groupId && groups[0] && !pendingGroupId.current && !groupDraft) setGroupId(groups[0].id);
if (groupId && !groups.some((item) => item.id === groupId)) {
const preserveTrigger = draft?.groupId === groupId && draftDirty;
const preserveGroup = groupDraft?.id === groupId && (groupDirty || preserveTrigger);
if (preserveGroup && groupDraft) {
groupGeneration.current += 1;
orphanedGroupId.current = groupId;
selectionGeneration.current += 1;
setGroupId('');
setTriggerId('');
setGroupDraft({ ...structuredClone(groupDraft), id: '', revision: 0,
triggers: [], migrated: false });
setGroupBaseline('');
if (preserveTrigger && draft) {
setDraft({ ...structuredClone(draft), id: '', groupId: '', revision: 0, migrated: false });
setDraftBaseline('');
} else {
setDraft(undefined);
setDraftBaseline('');
}
setToast({
message: l('远端触发器组已删除;本地修改已保留,可保存为新组。',
'The remote trigger group was deleted; local edits were kept and can be saved as a new group.'),
tone: 'warn',
});
return;
}
setGroupId(groups[0]?.id ?? '');
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
}
}, [clearTriggerSelection, draft, draftDirty, groupDraft, groupDirty, groupId, groups, l]);
useEffect(() => {
if (!resource.data) return;
const next = new Set(resource.data.groups.flatMap((item) => item.triggers.map((trigger) => trigger.id)));
if (orphanedGroupId.current && draft?.groupId === orphanedGroupId.current) {
knownTriggerIds.current = next;
return;
}
if (triggerId && knownTriggerIds.current.has(triggerId) && !next.has(triggerId)) {
if (draft?.id === triggerId && draftDirty) {
selectionGeneration.current += 1;
setTriggerId('');
setDraft({ ...structuredClone(draft), id: '', revision: 0, migrated: false });
setDraftBaseline('');
setToast({
message: l('远端触发器已删除;本地修改已保留为未保存副本。',
'The remote trigger was deleted; your local edits were kept as an unsaved copy.'),
tone: 'warn',
});
} else {
clearTriggerSelection();
}
}
knownTriggerIds.current = next;
}, [clearTriggerSelection, draft, draftDirty, l, resource.data, triggerId]);
useEffect(() => {
if (!group) {
if (groupDraft && (groupDraft.id === ''
|| groupDraft.id === orphanedGroupId.current
|| groupDraft.id === pendingGroupId.current
|| groups.some((item) => item.id === groupDraft.id))) return;
setGroupDraft(undefined);
setGroupBaseline('');
return;
}
// A child mutation increments its parent group's revision. If the editable group fields did
// not change remotely, retain the local edit while absorbing the new CAS revision/metadata.
if (groupDraft?.id === group.id && groupDirty) {
if (groupFingerprint(group) === groupBaseline) {
setGroupDraft((current) => current?.id === group.id ? {
...structuredClone(group),
name: current.name,
description: current.description,
enabled: current.enabled,
} : current);
}
return;
}
setGroupDraft(structuredClone(group));
setGroupBaseline(groupFingerprint(group));
// groupDraft/groupDirty intentionally describe the state at the remote revision boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group?.id, group?.revision, groups]);
useEffect(() => {
if (!selected) return;
// Refresh an unchanged editor from SSE, but never overwrite an optimistic local edit.
if (draft?.id === selected.id && draftDirty) return;
setDraft(structuredClone(selected));
setDraftBaseline(triggerFingerprint(selected));
// draft/draftDirty intentionally describe the local state at this remote revision boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.id, selected?.revision]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!draftDirty && !groupDirty) return;
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, [draftDirty, groupDirty]);
const act = useCallback(async <T,>(action: () => Promise<T>, success: string,
reload = true): Promise<T | undefined> => {
if (mutationInFlight.current) return undefined;
mutationInFlight.current = true;
setBusy(true); setToast(undefined);
try {
const result = await action();
setToast({ message: success, tone: 'good' });
if (reload) resource.reload();
return result;
}
catch (error) {
setToast({ message: error instanceof Error ? error.message : String(error), tone: 'danger' });
if (reload) resource.reload();
}
finally { mutationInFlight.current = false; setBusy(false); }
}, [resource]);
const createGroup = () => {
if (!confirmDiscard(true)) return;
void act(async () => {
const before = new Set(groups.map((item) => item.id));
const workspace = await api.createTriggerGroup({ name: l('新触发器组', 'New trigger group'), description: '', enabled: true });
pendingGroupId.current = workspace.groups.find((item) => !before.has(item.id))?.id ?? '';
setShowGroupSettings(true);
}, l('触发器组已创建', 'Trigger group created'));
};
const saveGroup = () => groupDraft && void act(async () => {
const generation = groupGeneration.current;
const creatingCopy = !groupDraft.id;
const existingIds = new Set(groups.map((item) => item.id));
const workspace = creatingCopy
? await api.createTriggerGroup({ name: groupDraft.name, description: groupDraft.description,
enabled: groupDraft.enabled })
: await api.updateTriggerGroup(groupDraft);
const saved = creatingCopy
? workspace.groups.find((item) => !existingIds.has(item.id))
: workspace.groups.find((item) => item.id === groupDraft.id);
if (generation !== groupGeneration.current) return workspace;
if (saved) {
setGroupDraft(structuredClone(saved));
setGroupBaseline(groupFingerprint(saved));
if (creatingCopy) {
orphanedGroupId.current = '';
pendingGroupId.current = saved.id;
if (draft && !draft.groupId) setDraft({ ...draft, groupId: saved.id });
}
}
return workspace;
}, l('触发器组已保存', 'Trigger group saved'));
const deleteGroup = () => groupDraft && window.confirm(l(
'删除触发器组会同时删除组内所有触发器,确定继续?',
'Deleting this group also deletes all triggers in it. Continue?')) && void act(async () => {
await api.deleteTriggerGroup(groupDraft.id, groupDraft.revision);
setGroupId('');
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
}, l('触发器组已删除', 'Trigger group deleted'));
const saveTrigger = useCallback(() => {
if (!draft || !writable || !triggerIsSavable(draft, commandCapable, catalog)) return;
const generation = selectionGeneration.current;
void act(async () => {
// The Java validator remains authoritative for both authoring modes. Visual documents are
// sent as their structured program so no DSL round-trip can alter values. This also protects
// Ctrl/Cmd+S from bypassing the visible button's local checks.
const v2 = triggerV2Program(draft);
const validation = v2
? await api.validateTriggerV2(v2)
: draft.mode === 'code'
? await api.validateTriggerScript(draft.script)
: await api.validateVisualTrigger({
event: draft.event,
conditionMode: draft.conditionMode,
conditions: draft.conditions,
actions: draft.actions,
});
if (!validation.valid) throw new Error(l('触发器预检未通过', 'Trigger validation failed'));
const result = draft.id ? await api.updateTrigger(draft) : await api.createTrigger({
groupId: draft.groupId, name: draft.name, description: draft.description, enabled: draft.enabled,
mode: draft.mode, event: draft.event, conditionMode: draft.conditionMode,
conditions: draft.conditions, actions: draft.actions, script: draft.script,
schemaVersion: draft.schemaVersion, events: draft.events, declarations: draft.declarations,
functions: draft.functions, statements: draft.statements,
});
if (generation !== selectionGeneration.current) return result;
setTriggerId(result.trigger.id);
setDraft(result.trigger);
setDraftBaseline(triggerFingerprint(result.trigger));
}, draft.enabled ? l('触发器已保存并立即生效', 'Trigger saved and activated')
: l('触发器已保存(当前停用)', 'Trigger saved (disabled)'));
}, [act, api, catalog, commandCapable, draft, l, writable]);
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's' || !draft || !writable) return;
event.preventDefault();
saveTrigger();
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [draft, saveTrigger, writable]);
const deleteTrigger = () => draft?.id && window.confirm(l('确定删除这个触发器?', 'Delete this trigger?'))
&& void act(async () => {
await api.deleteTrigger(draft.id, draft.revision);
clearTriggerSelection();
}, l('触发器已删除', 'Trigger deleted'));
const duplicate = () => {
if (!draft || mutationInFlight.current) return;
selectionGeneration.current += 1;
setTriggerId('');
setDraftBaseline('');
setDraft({ ...structuredClone(draft), id: '', revision: 0,
name: `${draft.name} ${l('副本', 'copy')}`, migrated: false });
};
const selectGroup = (id: string) => {
if (mutationInFlight.current || id === group?.id || !confirmDiscard(true)) return;
setGroupId(id);
setShowGroupSettings(false);
clearTriggerSelection();
setGroupDraft(undefined);
setGroupBaseline('');
};
const selectTrigger = (trigger: TriggerDefinition) => {
if (mutationInFlight.current || trigger.id === triggerId || !confirmDiscard(false)) return;
selectionGeneration.current += 1;
setTriggerId(trigger.id);
setDraft(structuredClone(trigger));
setDraftBaseline(triggerFingerprint(trigger));
};
const createTrigger = () => {
if (!group || mutationInFlight.current || !confirmDiscard(false)) return;
selectionGeneration.current += 1;
setTriggerId('');
setDraftBaseline('');
setDraft(blankTrigger(group.id));
};
const compileDraft = useCallback(async (script: string) => {
const generation = selectionGeneration.current;
const result = await act(() => api.validateTriggerScript(script),
l('脚本校验通过', 'Script is valid'), false);
return generation === selectionGeneration.current ? result : undefined;
}, [act, api, l]);
return <div className={`page trigger-page${focused ? ' trigger-page--focused' : ''}`}>
<PageHeader eyebrow="AUTOMATION" title={l('触发器', 'Triggers')}
description={l('用模块化流程或 XFE Script 编排服务器事件、条件与操作。',
'Automate server events, conditions, and actions with visual blocks or XFE Script.')}
actions={<><Badge tone="info">{resource.data?.totalTriggers ?? 0} {l('个触发器', 'triggers')}</Badge>
<Button variant="secondary" disabled={!draft} aria-pressed={focused} onClick={() => setFocused((current) => !current)}
title={focused ? l('恢复导航与预设库', 'Restore navigation and presets')
: l('收起导航与预设库,让程序占满工作区', 'Hide navigation and presets to expand the program workspace')}>
{focused ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
{focused ? l('退出专注', 'Exit focus') : l('专注编辑', 'Focus editor')}</Button>
<Button onClick={createGroup} disabled={!writable || busy}><FolderPlus size={15} />{l('新建组', 'New group')}</Button></>} />
{!writable && <div className="confirm-box"><Workflow /><div><strong>{l('当前为只读模式', 'Read-only mode')}</strong>
<p>{l('管理员或 Owner 可以编辑触发器。', 'Administrators and Owners can edit triggers.')}</p></div></div>}
<ResourceState loading={resource.loading} error={resource.error} onRetry={resource.reload}>
<div className="trigger-layout">
<aside className="trigger-sidebar">
<div className="trigger-sidebar__heading"><span>{l('触发器组', 'Trigger groups')}</span><Badge>{groups.length}</Badge></div>
{groups.map((item) => <button key={item.id} type="button"
disabled={busy}
aria-pressed={item.id === group?.id}
className={`trigger-group-item ${item.id === group?.id ? 'is-active' : ''}`}
onClick={() => selectGroup(item.id)}>
<span aria-hidden="true" className={`status-dot ${item.enabled ? 'is-on' : ''}`} />
<span><strong>{item.name}</strong><small>{item.triggers.length} {l('个触发器', 'triggers')}{item.migrated ? ` · ${l('已迁移', 'migrated')}` : ''}</small></span>
<ChevronRight size={14} />
</button>)}
{groups.length === 0 && <div className="trigger-empty">{l('创建第一个组以开始编排。', 'Create your first group to begin.')}</div>}
{group && <><div className="trigger-sidebar__subheading"><span>{l('组内触发器', 'Triggers in group')}</span>
<div className="button-row"><Button type="button" variant="ghost" disabled={!groupDraft}
aria-pressed={showGroupSettings} onClick={() => setShowGroupSettings((current) => !current)}>
{l('组设置', 'Group settings')}</Button>
<Button type="button" variant="ghost" disabled={!writable || busy} onClick={createTrigger}
aria-label={l('新建触发器', 'New trigger')}><Plus size={13} /></Button></div></div>
<div className="trigger-list trigger-sidebar__trigger-list">
{group.triggers.map((item) => <button type="button" key={item.id}
disabled={busy} aria-pressed={item.id === triggerId}
className={item.id === triggerId ? 'is-active' : ''} onClick={() => selectTrigger(item)}>
<span aria-hidden="true" className={`status-dot ${item.enabled ? 'is-on' : ''}`} />
<span><strong>{item.name}</strong><small>{eventLabel(item.event.type, zh)} · {item.mode === 'code' ? 'CODE' : 'UI'}</small></span>
<ChevronRight size={13} />
</button>)}
{!group.triggers.length && <div className="trigger-empty">{l('此组中还没有触发器。', 'No triggers in this group yet.')}</div>}
</div></>}
</aside>
<div className="trigger-content">
{groupDraft && showGroupSettings && <Panel className="trigger-group-panel" title={<><Workflow size={17} />{l('组设置', 'Group settings')}</>}
action={<div className="button-row"><Button variant="ghost" onClick={deleteGroup} disabled={!writable || busy || !groupDraft.id}
aria-label={l('删除触发器组', 'Delete trigger group')} title={l('删除触发器组', 'Delete trigger group')}><Trash2 size={14} /></Button>
<Button variant="secondary" onClick={saveGroup} disabled={!writable || busy}><Save size={14} />{l('保存组', 'Save group')}</Button></div>}>
<fieldset className="trigger-fieldset form-grid form-grid--three" disabled={!writable || busy}>
<Field label={l('名称', 'Name')}><Input value={groupDraft.name} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></Field>
<Field label={l('说明', 'Description')}><Input value={groupDraft.description} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></Field>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={groupDraft.enabled}
onChange={(event) => setGroupDraft({ ...groupDraft, enabled: event.target.checked })} /><span><strong>{groupDraft.enabled ? l('组已启用', 'Group enabled') : l('组已停用', 'Group disabled')}</strong></span></label>
</fieldset>
</Panel>}
{group && <div className="trigger-workspace">
<div className="trigger-editor-wrap">
{draft ? <TriggerEditor value={draft} catalog={catalog} writable={writable && !busy}
defaultSenderName={catalog.defaultMessageSender}
globalVariables={groups.flatMap((entry) => entry.triggers.flatMap((trigger) =>
(trigger.event.variables ?? []).filter((variable) => variable.visibility === 'global')))}
triggerChoices={groups.flatMap((entry) => entry.triggers.map((trigger) => ({ id: trigger.id, name: trigger.name })))}
menuChoices={(menuResource.data?.menus ?? []).map((menu) => ({ id: menu.id, name: menu.name }))}
canExecuteCommands={commandCapable} commandLocked={commandLocked}
zh={zh} onChange={setDraft} onSave={saveTrigger} onDelete={deleteTrigger} onDuplicate={duplicate}
onCompile={compileDraft} />
: <Panel className="trigger-editor-empty"><div className="trigger-empty trigger-empty--large"><CirclePlay />
<strong>{l('选择或创建触发器', 'Select or create a trigger')}</strong>
<span>{l('编辑器会显示在这里。', 'The editor will appear here.')}</span></div></Panel>}
</div>
</div>}
</div>
</div>
</ResourceState>
<TriggerOperationsPanel catalog={catalog} selectedTrigger={draft?.id ? draft : undefined}
writable={writable} owner={commandCapable} zh={zh} />
{toast && <Toast message={toast.message} tone={toast.tone} onClose={() => setToast(undefined)} />}
<datalist id="trigger-economy-currencies">{economyResource.data?.currencies.map((currency) =>
<option key={currency.id} value={currency.code} label={`${currency.symbol} ${currency.name}`} />)}</datalist>
</div>;
}
function TriggerOperationsPanel({ catalog, selectedTrigger, writable, owner, zh }: { catalog: TriggerCatalog;
selectedTrigger?: TriggerDefinition; writable: boolean; owner: boolean; zh: boolean }) {
const { api } = useServer();
const l = (chinese: string, english: string) => zh ? chinese : english;
const [tab, setTab] = useState<'history' | 'capture' | 'libraries'>('history');
const [executions, setExecutions] = useState<TriggerExecution[]>([]);
const [execution, setExecution] = useState<TriggerExecution>();
const [captures, setCaptures] = useState<TriggerEventCapture[]>([]);
const [samples, setSamples] = useState<TriggerEventSample[]>([]);
const [captureEvent, setCaptureEvent] = useState('player.join');
const [libraries, setLibraries] = useState<TriggerLibraryInstallation[]>([]);
const [libraryDraft, setLibraryDraft] = useState('');
const [operationMessage, setOperationMessage] = useState('');
const loadExecutions = async () => setExecutions((await api.triggerExecutions(100)).items);
const loadCaptures = async () => setCaptures((await api.triggerEventCaptures(100)).items);
const loadLibraries = async () => setLibraries((await api.triggerLibraries()).items);
useEffect(() => { void loadExecutions().catch(() => undefined); }, [api]);
useEffect(() => { if (tab === 'capture') void loadCaptures().catch(() => undefined); }, [api, tab]);
useEffect(() => { if (tab === 'libraries') void loadLibraries().catch(() => undefined); }, [api, tab]);
const run = async (work: () => Promise<unknown>, success: string) => {
setOperationMessage('');
try { await work(); setOperationMessage(success); }
catch (failure) { setOperationMessage(failure instanceof Error ? failure.message : String(failure)); }
};
const parseLibrary = (): TriggerLibraryPackage => JSON.parse(libraryDraft) as TriggerLibraryPackage;
const exportLibrary = async (item: TriggerLibraryInstallation) => {
await run(async () => {
const data = await api.exportTriggerLibrary(item.namespace, item.version);
const json = JSON.stringify(data, null, 2);
setLibraryDraft(json);
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url; link.download = `${item.namespace}-${item.version}.xfelib`; link.click();
URL.revokeObjectURL(url);
}, l('库已导出。', 'Library exported.'));
};
return <Panel className="trigger-operations" title={<><Workflow size={16} />{l('运行与扩展中心', 'Runtime & extension center')}</>}
action={<div className="segmented" role="tablist"><button type="button" className={tab === 'history' ? 'is-active' : ''}
onClick={() => setTab('history')}>{l('执行历史', 'History')}</button><button type="button"
className={tab === 'capture' ? 'is-active' : ''} onClick={() => setTab('capture')}>{l('事件采集', 'Capture')}</button>
<button type="button" className={tab === 'libraries' ? 'is-active' : ''}
onClick={() => setTab('libraries')}>.xfelib</button></div>}>
{operationMessage && <div className="confirm-box"><Workflow /><div><strong>{operationMessage}</strong></div></div>}
{tab === 'history' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<div className="button-row"><Button type="button" variant="secondary" onClick={() => void loadExecutions()}>
{l('刷新', 'Refresh')}</Button><span>{l('摘要保留 7 天 / 50,000 条', 'Summaries retained 7 days / 50,000')}</span></div>
{executions.map((item) => <button type="button" key={item.executionId}
className={execution?.executionId === item.executionId ? 'is-active' : ''}
onClick={() => void api.triggerExecution(item.executionId).then(setExecution)}><Badge
tone={item.status === 'COMPLETED' || item.status === 'SIMULATED' ? 'good'
: item.status === 'NEEDS_REVIEW' || item.status === 'FAILED' ? 'danger' : 'warn'}>
{executionStatusLabel(item.status, zh)}</Badge>
<span><strong>{eventLabel(item.eventType, zh)}</strong><small>{item.eventType} · {new Date(item.startedAt).toLocaleString()}</small></span>
<code>{item.executionId.slice(0, 8)}</code></button>)}</div>
<div className="trigger-runtime-detail">{execution ? <><div className="galaxy-pane__heading"><strong>
{eventLabel(execution.eventType, zh)}</strong><Badge>{executionStatusLabel(execution.status, zh)}</Badge></div>
{execution.error && <div className="form-error">{execution.error}</div>}
<JsonValueEditor label={l('执行摘要', 'Execution summary')} value={execution.summary} onCommit={() => undefined} />
<div className="trigger-block-list">{execution.steps.map((step) => <div className="trigger-trace-row" key={step.sequence}>
<code>#{step.sequence}</code><strong>{traceKindLabel(step.kind, zh)}</strong><span>{step.nodeId?.slice(0, 8) ?? 'program'}</span>
<small>{step.instructions} {l('条指令', 'instructions')}</small></div>)}</div></>
: <div className="trigger-empty">{l('选择记录查看逐节点追踪。', 'Select an execution for node-level trace.')}</div>}</div></div>}
{tab === 'capture' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<div className="form-grid"><Field label={l('事件类型', 'Event type')}><Select value={captureEvent}
onChange={(event) => setCaptureEvent(event.target.value)}>{catalog.events.map((entry) =>
<option key={entry} value={entry}>{eventLabel(entry, zh)}</option>)}</Select></Field>
<Button type="button" disabled={!writable} onClick={() => void run(async () => {
await api.startTriggerEventCapture({ eventTypes: [captureEvent], durationSeconds: 300, maximumSamples: 100 });
await loadCaptures();
}, l('已开始 5 分钟脱敏采集。', 'Started a 5-minute redacted capture.'))}>{l('开始采集', 'Start capture')}</Button></div>
{captures.map((item) => <button type="button" key={item.captureId} onClick={() => void api
.triggerEventSamples(item.captureId).then((result) => setSamples(result.items))}><Badge
tone={new Date(item.expiresAt).getTime() > Date.now() ? 'good' : 'neutral'}>{item.sampleCount}/{item.maximumSamples}</Badge>
<span><strong>{item.eventTypes.map((entry) => eventLabel(entry, zh)).join(zh ? '、' : ', ')}</strong>
<small>{item.eventTypes.join(', ')} · {new Date(item.expiresAt).toLocaleString()}</small></span></button>)}</div>
<div className="trigger-runtime-detail"><p className="field__hint">{l(
'采集默认关闭;样本已脱敏,回放只进入模拟器,不执行真实副作用。',
'Capture is opt-in and redacted; replay only simulates and never performs real side effects.')}</p>
{samples.map((sample) => <div className="trigger-sample" key={sample.sampleId}><div><strong>{eventLabel(sample.eventType, zh)}</strong>
<small>{sample.eventType} · {new Date(sample.capturedAt).toLocaleString()}</small></div><Button type="button" variant="secondary"
disabled={!selectedTrigger} onClick={() => selectedTrigger && void run(async () => {
await api.simulateTriggerEventSample(sample.sampleId, selectedTrigger.id); await loadExecutions();
}, l('样本模拟已写入执行历史。', 'Sample simulation added to execution history.'))}>{l('用所选触发器模拟', 'Simulate selected')}</Button></div>)}</div></div>}
{tab === 'libraries' && <div className="trigger-runtime-grid"><div className="trigger-runtime-list">
<p className="field__hint">{l('声明式库不允许携带字节码或脚本入口;安装前检查依赖 DAG、版本和风险权限。',
'Declarative libraries cannot carry bytecode or script entry points; dependency DAG, versions, and risk are checked before install.')}</p>
{libraries.map((item) => <div className="trigger-library-row" key={`${item.namespace}:${item.version}`}>
<span><strong>{item.manifest.displayName}</strong><small>{item.namespace}@{item.version}</small></span>
<Badge tone={item.active ? 'good' : 'neutral'}>{item.active ? l('已启用', 'Active') : l('未启用', 'Inactive')}</Badge>
<Button type="button" variant="ghost" onClick={() => void exportLibrary(item)}>{l('导出', 'Export')}</Button>
{!item.active && <Button type="button" variant="ghost" disabled={!owner} onClick={() => void run(async () => {
await api.rollbackTriggerLibrary(item.namespace, item.version); await loadLibraries();
}, l('库版本已回滚。', 'Library version rolled back.'))}>{l('回滚', 'Rollback')}</Button>}</div>)}</div>
<div className="trigger-runtime-detail"><Field label={l('.xfelib JSON', '.xfelib JSON')}><Textarea value={libraryDraft}
onChange={(event) => setLibraryDraft(event.target.value)} /></Field><input type="file" accept=".xfelib,.json,application/json"
onChange={(event) => { const file = event.target.files?.[0]; if (file) void file.text().then(setLibraryDraft); }} />
<div className="button-row"><Button type="button" variant="secondary" disabled={!libraryDraft}
onClick={() => void run(async () => {
const result = await api.validateTriggerLibrary(parseLibrary());
if (!result.valid) throw new Error(result.missingDependencies.join(', '));
}, l('库校验通过。', 'Library validation passed.'))}>{l('校验', 'Validate')}</Button>
<Button type="button" disabled={!owner || !libraryDraft} onClick={() => void run(async () => {
await api.importTriggerLibrary(parseLibrary(), libraries.some((entry) =>
entry.namespace === parseLibrary().manifest.namespace)); await loadLibraries();
}, l('库已安装。', 'Library installed.'))}>{l('导入/升级', 'Import / upgrade')}</Button></div></div></div>}
</Panel>;
}
function TriggerEditor({ value, catalog, writable, canExecuteCommands, commandLocked, defaultSenderName, globalVariables, triggerChoices, menuChoices, zh,
onChange, onSave, onDelete, onDuplicate, onCompile }: {
value: TriggerDefinition; catalog: TriggerCatalog; writable: boolean; canExecuteCommands: boolean;
commandLocked: boolean; defaultSenderName: string; triggerChoices: Array<{ id: string; name: string }>; zh: boolean;
globalVariables: TriggerStateVariableDefinition[];
menuChoices: Array<{ id: string; name: string }>;
onChange: (value: TriggerDefinition) => void; onSave: () => void; onDelete: () => void;
onDuplicate: () => void; onCompile: (script: string) => Promise<TriggerValidation | undefined>;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const { api } = useServer();
const latestScript = useRef(value.script);
latestScript.current = value.script;
const [validatedScript, setValidatedScript] = useState(
value.mode === 'code' && value.id ? value.script : '');
useEffect(() => {
setValidatedScript(value.mode === 'code' && value.id ? value.script : '');
}, [value.id, value.revision]);
const [liveVariableValues, setLiveVariableValues] = useState<Record<string, unknown>>({});
const [simulation, setSimulation] = useState<TriggerSimulationResponse>();
const [simulating, setSimulating] = useState(false);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof globalThis.setInterval> | undefined;
const load = async () => {
if (!value.id || !(value.event.variables?.length)) {
if (active) setLiveVariableValues({});
return;
}
try {
const result = await api.triggerVariableValues(value.id);
if (active) setLiveVariableValues(result.values);
} catch {
// A newly created/disabled trigger may not have a runtime value until it is saved.
}
};
void load();
if (value.id && value.event.variables?.length) timer = globalThis.setInterval(() => void load(), 1_000);
return () => { active = false; if (timer !== undefined) globalThis.clearInterval(timer); };
}, [api, value.id, value.revision, value.event.variables?.length]);
const updateEvent = (event: TriggerEventSpec) => onChange({ ...value, event });
const validateCode = async (script: string) => {
const compiled = await onCompile(script);
if (compiled?.valid && latestScript.current === script) setValidatedScript(script);
return latestScript.current === script ? compiled : undefined;
};
const switchMode = async (mode: TriggerDefinition['mode']) => {
if (mode === value.mode) return;
if (mode === 'code') {
if (triggerV2Program(value) && !window.confirm(l(
'XFE Script v1 无法表达函数、循环和多事件;继续会切换为兼容脚本副本。',
'XFE Script v1 cannot represent functions, loops, or multiple events. Continue with a compatibility script copy?'))) return;
setValidatedScript('');
onChange({ ...value, schemaVersion: undefined, events: undefined, declarations: undefined,
functions: undefined, statements: undefined, mode, script: toScript(value) });
return;
}
const compiled = await validateCode(value.script);
if (compiled?.valid) onChange({ ...value, mode, event: compiled.event,
conditionMode: compiled.conditionMode, conditions: compiled.conditions, actions: compiled.actions });
};
const leafActions = actionLeaves(value.actions);
const incompatible = value.mode === 'visual' && leafActions.some((action) =>
!actionCompatibleWithEvent(action.type, value.event.type));
const leaveIncompatible = value.event.type === 'player.leave' && leafActions.some((action) =>
onlinePlayerActions.has(action.type.trim().toLowerCase()));
const commandRestricted = !canExecuteCommands && triggerUsesCommandAction(value);
const editorWritable = writable && !commandLocked;
const incomplete = !triggerIsSavable(value, canExecuteCommands, catalog)
|| (value.mode === 'code' && validatedScript !== value.script);
const customEvent = value.event.type === 'custom' || value.event.type.startsWith('custom.');
const customEventSuffix = value.event.type === 'custom' ? ''
: customEvent ? value.event.type.slice('custom.'.length) : '';
const customEventInvalid = Boolean(customEventSuffix && !/^[a-z0-9_.-]+$/.test(customEventSuffix));
const eventTypes = catalog.events.includes('custom') ? catalog.events : [...catalog.events, 'custom'];
const selectableEvents = !customEvent && !eventTypes.includes(value.event.type)
? [value.event.type, ...eventTypes] : eventTypes;
const commandArguments = [...new Set(commandArgumentNames(value.event)
.filter((argument) => commandPartPattern.test(argument)))];
const stateVariables = (value.event.variables ?? []).filter((variable) => triggerVariableNameIsValid(variable.name));
const localVariables = stateVariables.filter((variable) => (variable.visibility ?? 'trigger') === 'trigger');
const availableGlobals = [...new Map([...globalVariables,
...stateVariables.filter((variable) => variable.visibility === 'global')]
.filter((variable) => triggerVariableNameIsValid(variable.name))
.map((variable) => [variable.name, variable])).values()];
const actionVariables = [...commandArguments.map((argument) => `{args.${argument}}`),
...localVariables.map((variable) => `{var.${variable.name}}`),
...availableGlobals.map((variable) => `{global.${variable.name}}`)];
const variableCatalog: TriggerVariableDefinition[] = [
...(catalog.variables ?? []).filter((variable) => variable.templateAllowed !== false && variable.sensitive !== true),
...localVariables.map((variable) => ({ key: `var.${variable.name}`,
nameZh: `触发器变量 ${variable.name}`, nameEn: `Trigger variable ${variable.name}`,
descriptionZh: `当前 ${variable.type} 类型值;存储位置:${variable.storage ?? 'trigger'}。`,
descriptionEn: `Current ${variable.type} trigger value; storage: ${variable.storage ?? 'trigger'}.`,
category: 'trigger', scopes: ['global'], type: variable.type, templateAllowed: true,
conditionAllowed: true, sampleValue: variable.initialValue })),
...availableGlobals.map((variable) => ({ key: `global.${variable.name}`,
nameZh: `全局变量 ${variable.name}`, nameEn: `Global variable ${variable.name}`,
descriptionZh: `可被所有触发器读写的 ${variable.type} 类型值;存储位置:${variable.storage ?? 'server'}。`,
descriptionEn: `Mutable ${variable.type} value shared by all triggers; storage: ${variable.storage ?? 'server'}.`,
category: 'trigger', scopes: ['global'], type: variable.type, templateAllowed: true,
conditionAllowed: true, sampleValue: variable.initialValue })),
];
const suggestedFields = [...new Set([...eventFieldSuggestions(value.event, catalog),
...availableGlobals.map((variable) => `global.${variable.name}`)])];
const actionNodeCount = countActionNodes(value.actions);
const v2Program = triggerV2Program(value);
const simulate = async () => {
if (!value.id || simulating) return;
setSimulating(true);
try {
setSimulation(await api.simulateTrigger({ triggerId: value.id,
event: sampleSimulationContext(value.event, catalog), variables: liveVariableValues, seed: 0 }));
} finally { setSimulating(false); }
};
return <Panel className="trigger-editor" title={<><GripVertical size={16} />{value.id ? l('编辑触发器', 'Edit trigger') : l('新触发器', 'New trigger')}</>}
action={<div className="button-row"><Button variant="ghost" onClick={onDuplicate} disabled={!editorWritable}
aria-label={l('复制触发器', 'Duplicate trigger')} title={l('复制触发器', 'Duplicate trigger')}><CopyPlus size={14} /></Button>
{value.id && <Button variant="ghost" onClick={onDelete} disabled={!editorWritable}
aria-label={l('删除触发器', 'Delete trigger')} title={l('删除触发器', 'Delete trigger')}><Trash2 size={14} /></Button>}
{value.id && <Button type="button" variant="secondary" onClick={() => void simulate()} disabled={simulating}
title={l('使用脱敏样例上下文模拟已保存版本', 'Simulate the saved revision with sample context')}>
<CirclePlay size={14} />{simulating ? l('模拟中', 'Simulating') : l('模拟', 'Simulate')}</Button>}
<Button onClick={onSave} disabled={!editorWritable || incomplete}><Save size={14} />{l('保存', 'Save')}</Button>
<span className="keyboard-hint" aria-label={l('快捷保存:Control 或 Command 加 S', 'Quick save: Control or Command plus S')}
title={l('快捷保存:Control 或 Command 加 S', 'Quick save: Control or Command plus S')}>
<span>{l('快捷保存', 'Quick save')}</span><kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>S</kbd>
</span></div>}>
{commandRestricted && <div className="form-error" role="alert">{commandLocked
? l('此触发器包含命令操作,仅 Owner 可编辑或保存。当前以只读方式展示。',
'This trigger contains command actions. Only an Owner can edit or save it; it is shown read-only.')
: l('命令操作需要 Owner 权限;请移除后再保存。',
'Command actions require Owner permission. Remove them before saving.')}</div>}
<fieldset className="trigger-fieldset trigger-editor__fields" disabled={!editorWritable}>
<div className="form-grid">
<Field label={l('触发器名称', 'Trigger name')}><Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('说明', 'Description')}><Input value={value.description} onChange={(event) => onChange({ ...value, description: event.target.value })} /></Field>
</div>
<div className="trigger-editor-toolbar">
<div className="segmented" role="group" aria-label={l('编辑模式', 'Editing mode')}><button type="button"
aria-pressed={value.mode === 'visual'} className={value.mode === 'visual' ? 'is-active' : ''}
onClick={() => void switchMode('visual')}><Workflow size={14} />{l('模块化 UI', 'Visual')}</button>
<button type="button" aria-pressed={value.mode === 'code'} className={value.mode === 'code' ? 'is-active' : ''}
onClick={() => void switchMode('code')}><Code2 size={14} />XFE Script</button></div>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={value.enabled}
onChange={(event) => onChange({ ...value, enabled: event.target.checked })} /><span><strong>{value.enabled ? l('已启用', 'Enabled') : l('已停用', 'Disabled')}</strong></span></label>
</div>
{value.mode === 'code' ? <div className="trigger-code-editor">
<div className="trigger-step-heading"><Braces size={16} /><span><strong>XFE Script</strong><small>{l('安全、可审计的触发器 DSL', 'Safe, auditable trigger DSL')}</small></span>
<Button variant="secondary" onClick={() => void validateCode(value.script)}>{l('校验', 'Validate')}</Button></div>
<Textarea aria-label="XFE Script" spellCheck={false} value={value.script} onChange={(event) => onChange({ ...value, script: event.target.value })} />
<code>{`on player.join
match all
when player.name contains "Steve"
do send_player message="Hello"`}</code>
</div> : v2Program ? <TriggerProgramWorkspace value={value} program={v2Program} catalog={catalog}
writable={editorWritable} owner={canExecuteCommands} zh={zh} onChange={onChange} /> : <div className="trigger-flow">
<section className="trigger-step">
<StepTitle number="01" title={l('触发条件', 'Event')} detail={l('什么时候运行', 'When it runs')}
action={<div className="button-row"><ModuleCopyButton kind="event" value={value.event} zh={zh} name={l('触发条件模块', 'event module')} />
<Button type="button" variant="ghost" title={l('粘贴触发条件模块', 'Paste event module')}
aria-label={l('粘贴触发条件模块', 'Paste event module')} onClick={() => void pasteTriggerModule<TriggerEventSpec>('event')
.then((event) => event && updateEvent(event))}><ClipboardPaste size={14} /></Button></div>} />
<div className="form-grid">
<Field label={l('事件类型', 'Event type')}><Select value={customEvent ? 'custom' : value.event.type}
onChange={(event) => { const next = { ...defaultEvent(event.target.value), variables: value.event.variables ?? [] }; const actions = actionCompatibleWithEvent('send_player', next.type)
? value.actions : mapActionTree(value.actions, (action) => action.type === 'send_player'
? { ...action, type: 'broadcast' } : action); onChange({ ...value, event: next, actions }); }}>
{selectableEvents.map((event) => <option value={event} key={event}>{eventLabel(event, zh)}</option>)}</Select></Field>
{customEvent && <Field label={l('自定义事件 ID', 'Custom event ID')} hint="custom.mod_name.event">
<Input aria-label={l('自定义事件 ID', 'Custom event ID')}
value={customEventSuffix} pattern="[a-z0-9_.-]+" aria-invalid={customEventInvalid}
placeholder="mod_name.event"
onChange={(event) => { const suffix = event.target.value.trim().replace(/^custom\./, ''); updateEvent({ ...value.event, type: suffix ? `custom.${suffix}` : 'custom' }); }} /></Field>}
{value.event.type === 'schedule.daily' && <><Field label={l('每天时间', 'Time')}><Input type="time" value={value.event.configuration.time ?? '08:00'}
onChange={(event) => updateEvent({ ...value.event, configuration: { ...value.event.configuration, time: event.target.value } })} /></Field>
<Field label={l('时区', 'Time zone')}><Input value={value.event.configuration.timezone ?? 'Asia/Shanghai'}
onChange={(event) => updateEvent({ ...value.event, configuration: { ...value.event.configuration, timezone: event.target.value } })} /></Field></>}
{value.event.type === 'schedule.interval' && <IntervalFields key={`${value.id}:${value.revision}`}
totalSeconds={value.event.configuration.seconds ?? '60'} zh={zh}
onChange={(seconds) => updateEvent({ ...value.event, configuration: { seconds } })} />}
{value.event.type.startsWith('protection.') && <ProtectionEventFields
event={value.event} zh={zh} onChange={updateEvent} />}
{value.event.type === 'player.command_trigger' && <CommandTriggerFields key={`${value.id}:${value.revision}`}
event={value.event} catalog={catalog} zh={zh}
onChange={updateEvent} />}
</div>
</section>
<section className="trigger-step trigger-variable-step">
<StepTitle number="V" title={l('触发器变量', 'Trigger variables')}
detail={l('强类型状态;可用于条件、文本、参数和变量操作', 'Strongly typed state for conditions, text, parameters, and variable actions')} />
<TriggerVariablesEditor values={value.event.variables ?? []} liveValues={liveVariableValues}
types={catalog.triggerVariableTypes ?? fallbackCatalog.triggerVariableTypes ?? []} zh={zh}
onChange={(variables) => updateEvent({ ...value.event, variables })} />
</section>
{incompatible && <div className="form-error" role="alert">{leaveIncompatible
? l('玩家离服后无法再执行在线玩家操作,请改用广播、日志或身份列表操作。',
'Online-player actions cannot run after a player leaves. Use broadcast, log, or identity-list actions.')
: l('当前事件没有玩家上下文,请移除需要玩家的操作。',
'This event has no player context. Remove player-only actions.')}</div>}
<section className="trigger-step">
<StepTitle number="02" title={l('条件判断', 'Conditions')} detail={l('可选;可要求全部或任意条件成立', 'Optional; require all or any conditions')} action={<div className="button-row">
<Select aria-label={l('条件匹配模式', 'Condition match mode')} value={value.conditionMode}
onChange={(event) => onChange({ ...value, conditionMode: event.target.value as 'all' | 'any' })}><option value="all">{l('全部满足', 'Match all')}</option><option value="any">{l('任意满足', 'Match any')}</option></Select>
<Button type="button" variant="ghost" title={l('粘贴条件', 'Paste condition')}
aria-label={l('粘贴条件', 'Paste condition')}
onClick={() => void pasteTriggerModule<TriggerCondition>('condition').then((condition) =>
condition && onChange({ ...value, conditions: [...value.conditions, condition] }))}><ClipboardPaste size={14} /></Button>
<Button variant="ghost" onClick={() => onChange({ ...value, conditions: [...value.conditions, { field: 'player.name', operator: 'eq', value: '' }] })}><Plus size={14} />{l('添加条件', 'Add')}</Button></div>} />
<p className="field__hint" id="trigger-condition-help">{l(
'字段来自当前事件的上下文;请选择建议项,也可输入模组提供的扩展字段代码。列表值使用英文逗号分隔,区间填写两个有限数字,正则表达式采用服务端支持的 Java 安全语法;字段状态判断无需填写值。',
'Fields come from the current event context. Choose a suggestion or enter an extension field supplied by a mod. Separate list values with commas, enter two finite numbers for ranges, and use the server-supported safe Java regular-expression syntax. Field-state checks do not need a value.')}</p>
<datalist id="trigger-fields">{suggestedFields.map((field) => <option key={field} value={field}
label={`${conditionFieldLabel(field, zh, catalog)} · ${field}`} />)}</datalist>
<div className="trigger-block-list">{value.conditions.map((condition, index) => <ConditionRow key={index} value={condition} catalog={catalog}
eventType={value.event.type} position={index} count={value.conditions.length} zh={zh}
onChange={(next) => onChange({ ...value, conditions: replace(value.conditions, index, next) })}
onMove={(offset) => onChange({ ...value, conditions: move(value.conditions, index, offset) })}
onDelete={() => onChange({ ...value, conditions: remove(value.conditions, index) })} />)}
{!value.conditions.length && <div className="trigger-empty">{l('没有条件:每次事件发生都会执行。', 'No conditions: every matching event will run.')}</div>}</div>
</section>
<section className="trigger-step">
<StepTitle number="03" title={l('执行操作', 'Actions')}
detail={l('按从上到下执行;条件节点仅在判断通过后执行其内部操作',
'Runs top to bottom; a condition executes its contained actions only when it matches')} />
<ActionTreeEditor actions={value.actions} catalog={catalog} eventType={value.event.type}
canExecuteCommands={canExecuteCommands} defaultSenderName={defaultSenderName}
triggerChoices={triggerChoices} menuChoices={menuChoices}
variables={actionVariables} variableCatalog={variableCatalog} zh={zh} depth={0} path={[]}
totalNodes={actionNodeCount} root
onChange={(actions) => onChange({ ...value, actions })} />
<p className="field__hint">{l(`操作树当前有 ${actionNodeCount} 个节点;上限 4096 个节点、32 层嵌套。`,
`The action tree currently has ${actionNodeCount} nodes; limits are 4,096 nodes and 32 levels.`)}</p>
</section>
</div>}
</fieldset>
{simulation && <section className="trigger-step" aria-label={l('模拟追踪', 'Simulation trace')}>
<StepTitle number="T" title={l('模拟追踪', 'Simulation trace')}
detail={l('只读;不会执行真实动作', 'Read-only; no real side effects are executed')} />
<div className="button-row"><Badge tone={simulation.result.status === 'COMPLETED' ? 'good' : 'danger'}>
{executionStatusLabel(simulation.result.status, zh)}</Badge><span>{l('指令', 'Instructions')}: {simulation.result.instructions}</span>
<span>{l('计划动作', 'Planned actions')}: {simulation.result.actions.length}</span></div>
{simulation.result.error && <div className="form-error" role="alert">{simulation.result.error}</div>}
<div className="trigger-block-list">{simulation.result.trace.slice(-100).map((step) =>
<div className="trigger-condition-row" key={step.sequence}><code>{step.sequence}</code>
<strong>{traceKindLabel(step.kind, zh)}</strong><code>{step.nodeId ?? 'program'}</code>
<span>{formatLiveVariable(step.result)}</span></div>)}</div>
</section>}
</Panel>;
}
type V2Node = TriggerProgramNode;
type TriggerEventBindingLike = NonNullable<TriggerDefinition['events']>[number];
let triggerProgramClipboard = '';
function isTextEditingTarget(target: EventTarget | null): boolean {
return target instanceof Element && !!target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"],[role="dialog"]');
}
function TriggerProgramWorkspace({ value, program, catalog, writable, owner, zh, onChange }: {
value: TriggerDefinition; program: TriggerProgramV2; catalog: TriggerCatalog; writable: boolean;
owner: boolean; zh: boolean; onChange: (value: TriggerDefinition) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const [query, setQuery] = useState('');
const [kind, setKind] = useState('ALL');
const [selectedId, setSelectedId] = useState(program.events[0]?.nodeId ?? '');
const [pasteTarget, setPasteTarget] = useState<TriggerPasteTarget>();
const [clipboardMessage, setClipboardMessage] = useState('');
const [renameTo, setRenameTo] = useState('');
const [templateId, setTemplateId] = useState('');
const history = useRef<TriggerProgramV2[]>([]);
const future = useRef<TriggerProgramV2[]>([]);
const current = useRef(program);
current.current = program;
const clipboardContext = useRef('');
clipboardContext.current = `${value.id}:${selectedId}:${JSON.stringify(pasteTarget)}`;
useEffect(() => {
history.current = [];
future.current = [];
setSelectedId(program.events[0]?.nodeId ?? '');
setPasteTarget(undefined);
setClipboardMessage('');
}, [value.id, value.revision]);
const selectNode = (id: string) => { setSelectedId(id); setPasteTarget(undefined); };
const selectBranch = (target: TriggerPasteTarget) => { setSelectedId(target.parentId); setPasteTarget(target); };
const apply = (next: TriggerProgramV2, remember = true) => {
if (!writable) return;
if (remember) {
history.current = [...history.current.slice(-99), structuredClone(current.current)];
future.current = [];
}
const first = next.events[0];
onChange({ ...value, schemaVersion: 2, events: next.events, declarations: next.declarations,
functions: next.functions, statements: next.statements,
event: { type: first.type, configuration: first.configuration,
variables: next.declarations.map(v2VariableProjection) } });
};
const undo = () => {
const previous = history.current.at(-1);
if (!previous) return;
history.current = history.current.slice(0, -1);
future.current = [structuredClone(current.current), ...future.current.slice(0, 99)];
apply(previous, false);
setPasteTarget(undefined);
if (!findV2Node(previous, selectedId)) setSelectedId(previous.events[0]?.nodeId ?? '');
setClipboardMessage(l('已撤销。', 'Undone.'));
};
const redo = () => {
const next = future.current[0];
if (!next) return;
future.current = future.current.slice(1);
history.current = [...history.current.slice(-99), structuredClone(current.current)];
apply(next, false);
setPasteTarget(undefined);
if (!findV2Node(next, selectedId)) setSelectedId(next.events[0]?.nodeId ?? '');
setClipboardMessage(l('已重做。', 'Redone.'));
};
useEffect(() => {
const listener = (event: KeyboardEvent) => {
if (!writable || !(event.ctrlKey || event.metaKey) || !['z', 'y'].includes(event.key.toLowerCase())) return;
if (isTextEditingTarget(event.target)) return;
event.preventDefault();
if (event.key.toLowerCase() === 'y' || event.shiftKey) redo(); else undo();
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
});
const selected = findV2Node(program, selectedId);
useEffect(() => {
setRenameTo(selected?.category === 'declaration'
? (selected.value as TriggerVariableDeclarationV2).name : '');
}, [selectedId]);
const descriptors = (catalog.descriptors ?? []).filter((descriptor) => {
if (kind !== 'ALL' && descriptor.kind !== kind) return false;
if (!owner && descriptor.risk === 'OWNER') return false;
const text = `${descriptor.id} ${descriptor.displayName} ${descriptor.description} ${descriptorName(descriptor, catalog, zh)} ${descriptorDescription(descriptor, catalog, zh)}`.toLowerCase();
return text.includes(query.trim().toLowerCase());
});
const addDescriptor = (descriptor: NonNullable<TriggerCatalog['descriptors']>[number]) => {
if (!writable) return;
if (descriptor.kind === 'EVENT') {
const event = { nodeId: newNodeId(), type: descriptor.id, configuration: defaultV2EventConfiguration(descriptor.id) };
apply({ ...program, events: [...program.events, event] });
selectNode(event.nodeId);
} else if (descriptor.kind === 'ACTION') {
const statement = actionStatement(descriptor);
apply({ ...program, statements: [...program.statements, statement] });
selectNode(statement.nodeId);
}
};
const updateSelected = (replacement: V2Node['value']) => {
if (!selected) return;
apply(replaceV2Node(program, selectedId, replacement));
setPasteTarget(undefined);
};
const removeSelected = () => {
if (!selected || selected.category === 'event' && program.events.length === 1) return;
const next = removeV2Node(program, selectedId);
apply(next);
selectNode(next.events[0]?.nodeId ?? next.statements[0]?.nodeId ?? '');
};
const pasteNode = (source: string, target: TriggerPasteTarget | null | undefined = pasteTarget) => {
if (!writable) return;
const node = parseTriggerNode(source);
if (!node) { setClipboardMessage(l('剪贴板中没有有效的 V2 程序节点。', 'No valid V2 program node on the clipboard.')); return; }
const result = insertTriggerNode(program, node, selectedId, target ?? undefined);
const ownerOnly = (entry: TriggerStatement) => entry.kind === 'ACTION' && (commandActions.has(entry.name)
|| !!catalog.descriptors?.some((descriptor) => descriptor.kind === 'ACTION' && descriptor.id === entry.name && descriptor.risk === 'OWNER'));
if (!owner && [...result.program.statements, ...result.program.functions.flatMap((entry) => entry.statements)]
.some((entry) => statementTreeContains(entry, ownerOnly))) {
setClipboardMessage(l('无法粘贴:节点包含仅 Owner 可配置的动作。', 'Cannot paste: the node contains Owner-only actions.')); return;
}
if (!triggerProgramFitsLimits(result.program, catalog.budgets?.nodes ?? 4096, catalog.budgets?.nestingDepth ?? 32)) {
setClipboardMessage(l('无法粘贴:程序结构无效,或超出事件数、节点数、嵌套深度上限。',
'Cannot paste: invalid program structure or event, node, or nesting limit exceeded.')); return;
}
apply(result.program); selectNode(result.nodeId);
setClipboardMessage(l('已粘贴节点,包含其全部子节点;可撤销。', 'Node pasted with all its children; undo is available.'));
};
const copyNode = () => {
if (!selected) return '';
triggerProgramClipboard = serializeTriggerNode(selected);
setClipboardMessage(l('已复制节点及其子节点,按 Ctrl/Cmd+V 粘贴。', 'Node and children copied. Press Ctrl/Cmd+V to paste.'));
return triggerProgramClipboard;
};
const copyToClipboard = async () => {
const source = copyNode(); if (!source) return;
try { await navigator.clipboard?.writeText(source); } catch { /* The in-page paste button still works. */ }
};
const pasteFromClipboard = async () => {
const beforeRead = current.current;
const beforeContext = clipboardContext.current;
let source = triggerProgramClipboard;
try { if (navigator.clipboard?.readText) source = await navigator.clipboard.readText(); }
catch { /* Non-secure mod consoles may only support the internal clipboard. */ }
if (current.current !== beforeRead || clipboardContext.current !== beforeContext) return;
pasteNode(source);
};
const duplicateSelected = () => { if (selected) pasteNode(serializeTriggerNode(selected), null); };
useEffect(() => {
const copy = (event: ClipboardEvent) => {
if (event.defaultPrevented || !selected || isTextEditingTarget(event.target)
|| window.getSelection()?.toString() || !event.clipboardData) return;
event.clipboardData.setData('text/plain', copyNode());
event.preventDefault();
};
const paste = (event: ClipboardEvent) => {
if (event.defaultPrevented || !writable || isTextEditingTarget(event.target) || !event.clipboardData) return;
const source = event.clipboardData.getData('text/plain');
if (!source.includes('xfesm-trigger-node-v2')) return; // Leave ordinary text and other editors alone.
event.preventDefault(); pasteNode(source);
};
window.addEventListener('copy', copy); window.addEventListener('paste', paste);
return () => { window.removeEventListener('copy', copy); window.removeEventListener('paste', paste); };
});
const addControl = (statementKind: TriggerStatement['kind']) => {
const statement = defaultV2Statement(statementKind, program);
apply({ ...program, statements: [...program.statements, statement] });
selectNode(statement.nodeId);
};
const applyTemplate = () => {
const template = (catalog.templates ?? []).find((entry) => entry.id === templateId);
if (!template || !window.confirm(l('模板会替换当前 V2 程序,是否继续?',
'The template replaces the current V2 program. Continue?'))) return;
const next = regenerateNodeIds(template.program) as TriggerProgramV2;
apply(next);
selectNode(next.events[0].nodeId);
};
const renameVariable = () => {
if (!selected || selected.category !== 'declaration') return;
const declaration = selected.value as TriggerVariableDeclarationV2;
const normalized = renameTo.trim();
if (!/^[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test(normalized)
|| program.declarations.some((entry) => entry.nodeId !== declaration.nodeId && entry.name === normalized)) return;
apply(renameV2Variable(program, declaration.name, normalized));
};
const references = selected?.category === 'declaration'
? variableReferenceCount(program, (selected.value as TriggerVariableDeclarationV2).name) : 0;
const dependencies = v2Dependencies(program);
return <div className="galaxy-editor" onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const id = event.dataTransfer.getData('application/x-xfesm-trigger-descriptor');
const descriptor = (catalog.descriptors ?? []).find((entry) => `${entry.kind}:${entry.id}` === id);
if (descriptor) addDescriptor(descriptor);
}}>
<section className="galaxy-pane galaxy-library" aria-label={l('触发器库', 'Trigger library')}>
<div className="galaxy-pane__heading"><strong>{l('库与预设', 'Library & presets')}</strong>
<Badge>{catalog.catalogRevision ?? 0}</Badge></div>
<Input aria-label={l('搜索触发器目录', 'Search trigger catalog')} value={query}
placeholder={l('搜索事件、函数、动作、类型', 'Search events, functions, actions, types')}
onChange={(event) => setQuery(event.target.value)} />
<Select aria-label={l('目录分类', 'Catalog category')} value={kind} onChange={(event) => setKind(event.target.value)}>
<option value="ALL">{label(catalogKindLabels.ALL, zh, 'ALL')}</option>
{['EVENT', 'EVENT_RESPONSE', 'VALUE_FUNCTION', 'CONDITION_FUNCTION', 'ACTION', 'TYPE'].map((entry) =>
<option key={entry} value={entry}>{label(catalogKindLabels[entry], zh, entry.replace('_', ' '))}</option>)}</Select>
<div className="galaxy-catalog-list">{descriptors.slice(0, 250).map((descriptor) =>
<button type="button" key={`${descriptor.kind}:${descriptor.id}`} draggable={writable}
onDragStart={(event) => event.dataTransfer.setData('application/x-xfesm-trigger-descriptor',
`${descriptor.kind}:${descriptor.id}`)}
disabled={!writable || !['EVENT', 'ACTION'].includes(descriptor.kind)}
onClick={() => addDescriptor(descriptor)} title={descriptorDescription(descriptor, catalog, zh)}>
<span><strong>{descriptorName(descriptor, catalog, zh)}</strong>
<small className="galaxy-catalog-list__description">{descriptorDescription(descriptor, catalog, zh)}</small>
<code>{descriptor.id}</code></span>
<Badge tone={descriptor.risk === 'OWNER' ? 'danger' : descriptor.risk === 'ADMINISTRATOR' ? 'warn' : 'good'}>
{label(catalogKindLabels[descriptor.kind], zh, descriptor.kind)}</Badge></button>)}</div>
{(catalog.templates ?? []).length > 0 && <div className="galaxy-template-picker">
<Select aria-label={l('官方模板', 'Official template')} value={templateId}
onChange={(event) => setTemplateId(event.target.value)}><option value="">{l('选择官方模板', 'Choose template')}</option>
{(catalog.templates ?? []).map((template) => <option key={template.id} value={template.id}>
{label(templateLabels[template.id], zh, template.name)}</option>)}</Select>
<Button type="button" variant="secondary" disabled={!writable || !templateId} onClick={applyTemplate}>
{l('应用', 'Apply')}</Button>
{templateId && <small className="galaxy-template-picker__description">{label(templateDescriptions[templateId], zh,
(catalog.templates ?? []).find((entry) => entry.id === templateId)?.description ?? '')}</small>}</div>}
</section>
<div className="galaxy-workbench">
<section className="galaxy-pane galaxy-tree" aria-label={l('程序树', 'Program tree')}>
<div className="galaxy-pane__heading"><strong>{l('程序层级', 'Program hierarchy')}</strong>
<span className="button-row"><Button type="button" variant="ghost" disabled={!selected}
onClick={() => void copyToClipboard()} title="Ctrl/Cmd+C"><Copy size={14} />{l('复制', 'Copy')}</Button>
<Button type="button" variant="ghost" disabled={!writable} title="Ctrl/Cmd+V"
onClick={() => void pasteFromClipboard()}><ClipboardPaste size={14} />{l('粘贴', 'Paste')}</Button>
<Button type="button" variant="ghost" disabled={!writable || !history.current.length}
onClick={undo}>{l('撤销', 'Undo')}</Button><Button type="button" variant="ghost"
onClick={redo} disabled={!writable || !future.current.length}>{l('重做', 'Redo')}</Button></span></div>
<p className="galaxy-tree__clipboard-help">{l('Ctrl/Cmd+C 复制 · Ctrl/Cmd+V 粘贴。选中语句:同级插入;选中分支:插入分支内。',
'Ctrl/Cmd+C to copy · Ctrl/Cmd+V to paste. Select a statement to insert after it; select a branch to insert inside.')}</p>
{clipboardMessage && <p className="galaxy-tree__clipboard-status" role="status">{clipboardMessage}</p>}
<ProgramTreeSection title={l('事件绑定', 'Event bindings')} items={program.events}
selectedId={selectedId} onSelect={selectNode} renderLabel={(entry) => eventLabel(entry.type, zh)} onAdd={() => {
const event = { nodeId: newNodeId(), type: 'custom', configuration: {} };
apply({ ...program, events: [...program.events, event] }); selectNode(event.nodeId);
}} />
<ProgramTreeSection title={l('变量声明', 'Declarations')} items={program.declarations}
selectedId={selectedId} onSelect={selectNode} onAdd={() => {
const declaration: TriggerVariableDeclarationV2 = { nodeId: newNodeId(),
name: uniqueVariableName(program, 'value'), valueType: 'integer',
initialValue: literalExpression(0, 'integer'), visibility: 'trigger', storage: 'trigger',
lifetime: 'SESSION', ttlSeconds: null };
apply({ ...program, declarations: [...program.declarations, declaration] });
selectNode(declaration.nodeId);
}} />
<ProgramTreeSection title={l('函数/过程', 'Functions / procedures')} items={program.functions}
selectedId={selectedId} onSelect={selectNode} onAdd={() => {
const fn: TriggerFunctionDeclaration = { nodeId: newNodeId(),
name: uniqueFunctionName(program, 'procedure'), parameters: [], returnType: 'void', locals: [], statements: [] };
apply({ ...program, functions: [...program.functions, fn] }); selectNode(fn.nodeId);
}} renderChildren={(entry) =>
<StatementTree statements={(entry as TriggerFunctionDeclaration).statements}
selectedId={selectedId} onSelect={selectNode} depth={1} catalog={catalog} zh={zh}
pasteTarget={pasteTarget} onSelectBranch={selectBranch} />} />
<div className="galaxy-tree__section"><div className="galaxy-tree__title"><strong>{l('语句树', 'Statement tree')}</strong>
<span className="button-row"><Button type="button" variant="ghost" disabled={!writable}
onClick={() => addControl('IF')}>{l('条件', 'If')}</Button><Button type="button" variant="ghost" disabled={!writable}
onClick={() => addControl('REPEAT')}>{l('循环', 'loop')}</Button><Button type="button" variant="ghost"
disabled={!writable} onClick={() => addControl('TRY')}>{l('错误处理', 'Try')}</Button></span></div>
<StatementTree statements={program.statements} selectedId={selectedId} onSelect={selectNode} depth={0}
catalog={catalog} zh={zh} pasteTarget={pasteTarget} onSelectBranch={selectBranch} />
{!program.statements.length && <div className="trigger-empty">{l('拖入动作或添加控制流。', 'Drop an action or add control flow.')}</div>}</div>
{dependencies.length > 0 && <div className="galaxy-dependencies"><strong>{l('依赖图', 'Dependency graph')}</strong>
{dependencies.map((edge) => <code key={edge}>{edge}</code>)}</div>}
</section>
<section className="galaxy-pane galaxy-inspector" aria-label={l('节点属性', 'Node inspector')}>
<div className="galaxy-pane__heading"><strong>{l('节点属性', 'Node inspector')}</strong>
{selected && <Badge>{label({ event: ['事件', 'Event'], declaration: ['变量', 'Variable'],
function: ['函数', 'Function'], statement: ['语句', 'Statement'] }[selected.category] as [string, string], zh, selected.category)}</Badge>}</div>
{!selected && <div className="trigger-empty">{l('选择一个节点进行编辑。', 'Select a node to edit.')}</div>}
{selected?.category === 'event' && <EventBindingInspector value={selected.value as TriggerEventBindingLike}
catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)} />}
{selected?.category === 'declaration' && <VariableDeclarationInspector
value={selected.value as TriggerVariableDeclarationV2} catalog={catalog} zh={zh}
onChange={(next) => updateSelected(next)} />}
{selected?.category === 'function' && <FunctionInspector value={selected.value as TriggerFunctionDeclaration}
catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)} />}
{selected?.category === 'statement' && <StatementInspector value={selected.value as TriggerStatement}
program={program} catalog={catalog} zh={zh} onChange={(next) => updateSelected(next)}
onAppend={(branch, child) => apply(appendV2Child(program, selectedId, branch, child))} />}
{selected?.category === 'declaration' && <div className="galaxy-rename"><Field
label={`${l('安全重命名', 'Safe rename')} · ${references} ${l('处引用', 'references')}`}>
<Input value={renameTo} onChange={(event) => setRenameTo(event.target.value)} /></Field>
<Button type="button" variant="secondary" onClick={renameVariable} disabled={!writable}>{l('重命名', 'Rename')}</Button></div>}
{selected && <><JsonNodeEditor key={selectedId} value={selected.value} zh={zh}
onCommit={(next) => updateSelected({ ...next, nodeId: selectedId } as V2Node['value'])} />
<div className="button-row"><Button type="button" variant="ghost" title="Ctrl/Cmd+C"
onClick={() => void copyToClipboard()}><Copy size={14} />{l('复制节点', 'Copy node')}</Button>
<Button type="button" variant="ghost" disabled={!writable}
onClick={duplicateSelected}><CopyPlus size={14} />{l('创建副本', 'Duplicate')}</Button>
<Button type="button" variant="ghost" disabled={!writable
|| selected.category === 'event' && program.events.length === 1} onClick={removeSelected}>
<Trash2 size={14} />{l('删除节点', 'Delete')}</Button></div></>}
<div className="galaxy-budget"><span>{l('节点', 'Nodes')}: {countV2Nodes(program)}/{catalog.budgets?.nodes ?? 4096}</span>
<span>{l('函数', 'Functions')}: {program.functions.length}</span><span>{l('事件', 'Events')}: {program.events.length}</span></div>
</section>
</div>
</div>;
}
function ProgramTreeSection<T extends { nodeId: string; name?: string; type?: string }>({ title, items, selectedId,
onSelect, onAdd, renderChildren, renderLabel }: { title: string; items: T[]; selectedId: string; onSelect: (id: string) => void;
onAdd?: () => void; renderChildren?: (item: T) => ReactNode; renderLabel?: (item: T) => string }) {
return <div className="galaxy-tree__section"><div className="galaxy-tree__title"><strong>{title}</strong><Badge>{items.length}</Badge>
{onAdd && <Button type="button" variant="ghost" onClick={onAdd}><Plus size={12} /></Button>}</div>
{items.map((item) => <div key={item.nodeId}><button type="button"
className={`galaxy-tree__node ${selectedId === item.nodeId ? 'is-active' : ''}`} aria-pressed={selectedId === item.nodeId}
onClick={() => onSelect(item.nodeId)}><span>{renderLabel?.(item) ?? item.name ?? item.type ?? item.nodeId}</span>
<small>{item.nodeId.slice(0, 8)}</small></button>{renderChildren?.(item)}</div>)}</div>;
}
function StatementTree({ statements, selectedId, onSelect, depth, catalog, zh, pasteTarget, onSelectBranch }: {
statements: TriggerStatement[]; selectedId: string; onSelect: (id: string) => void; depth: number;
catalog: TriggerCatalog; zh: boolean;
pasteTarget?: TriggerPasteTarget; onSelectBranch?: (target: TriggerPasteTarget) => void;
}) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-statement-tree">
{statements.map((statement) => {
const summary = statementPreview(statement, catalog, zh);
const branches: Array<{ label: string; target: TriggerPasteTarget; statements: TriggerStatement[] }> = [];
if (['IF', 'REPEAT', 'WHILE', 'FOREACH', 'TRY'].includes(statement.kind) || statement.statements.length) {
branches.push({ label: statement.kind === 'IF' ? l('条件成立时', 'Then') : statement.kind === 'TRY'
? l('尝试执行', 'Try body') : ['REPEAT', 'WHILE', 'FOREACH'].includes(statement.kind)
? l('循环体', 'Loop body') : l('子语句', 'Child statements'),
target: { parentId: statement.nodeId, branch: 'statements' }, statements: statement.statements });
}
statement.cases.forEach((branch) => branches.push({
label: `${l('匹配', 'Case')} ${expressionPreview(branch.match, catalog, zh)}`,
target: { parentId: statement.nodeId, branch: 'cases', caseId: branch.nodeId }, statements: branch.statements,
}));
if (['IF', 'SWITCH', 'TRY'].includes(statement.kind) || statement.elseStatements.length) {
branches.push({ label: statement.kind === 'TRY' ? l('发生错误时', 'On error') : statement.kind === 'SWITCH'
? l('默认分支(均不匹配)', 'Default (no match)') : l('否则(条件不成立)', 'Else (condition not met)'),
target: { parentId: statement.nodeId, branch: 'elseStatements' }, statements: statement.elseStatements });
}
const isCollapsed = collapsed.has(statement.nodeId);
return <div className="galaxy-statement" key={statement.nodeId}>
<div className="galaxy-statement__row">
{branches.length > 0 ? <button type="button" className="galaxy-statement__toggle"
aria-label={`${isCollapsed ? l('展开', 'Expand') : l('折叠', 'Collapse')} ${summary.title}`}
aria-expanded={!isCollapsed} onClick={() => setCollapsed((previous) => {
const next = new Set(previous); if (isCollapsed) next.delete(statement.nodeId); else next.add(statement.nodeId);
return next;
})}>{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}</button>
: <span className="galaxy-statement__leaf" aria-hidden="true" />}
<button type="button" aria-pressed={selectedId === statement.nodeId && !pasteTarget}
className={`galaxy-tree__node galaxy-tree__node--statement ${selectedId === statement.nodeId && !pasteTarget ? 'is-active' : ''}`}
onClick={() => onSelect(statement.nodeId)}>
<Badge>{label(statementKindLabels[statement.kind], zh, statement.kind)}</Badge>
<span className="galaxy-tree__summary"><span className="galaxy-tree__summary-title">{summary.title}</span>
{summary.details.map((detail, index) => <span className="galaxy-tree__summary-detail" key={index}>{detail}</span>)}</span>
<small>{statement.kind === 'ACTION' ? statement.name : statement.nodeId.slice(0, 8)}</small>
</button>
</div>
{!isCollapsed && branches.map((branch) => <div className="galaxy-tree__branch" role="group"
aria-label={branch.label} key={branch.target.caseId ?? branch.target.branch}>
<button type="button" className={`galaxy-tree__branch-label ${pasteTarget?.parentId === statement.nodeId
&& pasteTarget.branch === branch.target.branch && pasteTarget.caseId === branch.target.caseId ? 'is-active' : ''}`}
title={l('选择此分支,粘贴语句到分支末尾', 'Select this branch to paste statements at its end')}
onClick={() => onSelectBranch?.(branch.target)}>
<span>{branch.label}</span><small>{branch.statements.length} {l('条', 'statements')}</small>
</button>
<StatementTree statements={branch.statements} selectedId={selectedId} onSelect={onSelect}
depth={depth + 1} catalog={catalog} zh={zh} pasteTarget={pasteTarget} onSelectBranch={onSelectBranch} />
{!branch.statements.length && <span className="galaxy-tree__empty-branch">{l('暂无语句', 'No statements')}</span>}
</div>)}
</div>;
})}</div>;
}
function EventBindingInspector({ value, catalog, zh, onChange }: { value: TriggerEventBindingLike;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerEventBindingLike) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const custom = value.type === 'custom' || value.type.startsWith('custom.');
return <div className="galaxy-inspector__fields"><Field label={l('事件类型', 'Event type')}>
<Select value={custom ? 'custom' : value.type} onChange={(event) => onChange({ ...value,
type: event.target.value, configuration: defaultV2EventConfiguration(event.target.value) })}>
{catalog.events.map((entry) => <option key={entry} value={entry}>{eventLabel(entry, zh)}</option>)}</Select></Field>
{custom && <Field label={l('自定义事件 ID', 'Custom event ID')}
hint={l('填写发布模组的命名空间与事件名,例如 custom.mymod.quest_completed。',
'Enter the publishing mod namespace and event name, for example custom.mymod.quest_completed.')}>
<Input value={value.type === 'custom' ? '' : value.type} placeholder="custom.mymod.event"
onChange={(event) => onChange({ ...value, type: event.target.value.trim().toLowerCase() || 'custom' })} /></Field>}
<p className="galaxy-context-help"><strong>{eventLabel(value.type, zh)}</strong>
<span>{eventDescription(value.type, zh)}</span><code>{value.type}</code></p>
<V2EventConfigurationFields value={value} zh={zh} onChange={onChange} />
<details className="galaxy-json"><summary>{l('高级事件配置 JSON', 'Advanced event configuration JSON')}</summary>
<JsonValueEditor label={l('事件配置', 'Event configuration')} value={value.configuration}
onCommit={(configuration) => onChange({ ...value, configuration: configuration as Record<string, string> })} /></details></div>;
}
function V2EventConfigurationFields({ value, zh, onChange }: { value: TriggerEventBindingLike;
zh: boolean; onChange: (value: TriggerEventBindingLike) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const update = (name: string, next: string) => onChange({ ...value,
configuration: { ...value.configuration, [name]: next } });
const shape = value.configuration.shape ?? 'cuboid';
if (value.type === 'schedule.daily') return <div className="galaxy-event-fields form-grid">
<Field label={l('每天时间', 'Daily time')} hint={l('按下方时区每天执行一次。', 'Runs once per day in the time zone below.')}>
<Input type="time" value={value.configuration.time ?? '08:00'} onChange={(event) => update('time', event.target.value)} /></Field>
<Field label={l('时区', 'Time zone')} hint="Asia/Shanghai · UTC · Europe/London">
<Input value={value.configuration.timezone ?? 'Asia/Shanghai'} onChange={(event) => update('timezone', event.target.value)} /></Field></div>;
if (value.type === 'schedule.interval') return <IntervalFields totalSeconds={value.configuration.seconds ?? '60'}
zh={zh} onChange={(seconds) => onChange({ ...value, configuration: { seconds } })} />;
if (value.type.startsWith('protection.')) return <ProtectionEventFields
event={{ type: value.type, configuration: value.configuration }} zh={zh}
onChange={(event) => onChange({ ...value, configuration: event.configuration })} />;
if (value.type === 'player.command_trigger') return <div className="galaxy-event-fields">
<Field label={l('自定义命令', 'Custom command')}
hint={l('不含 /,只能使用小写字母、数字、下划线和连字符。', 'Omit /; use lowercase letters, numbers, underscores, and hyphens.')}>
<Input value={value.configuration.command ?? ''} placeholder="rules" pattern="[a-z][a-z0-9_-]{0,31}"
onChange={(event) => update('command', event.target.value.trim().toLowerCase())} /></Field>
<Field label={l('参数名称(兼容模式)', 'Argument names (compatibility mode)')}
hint={l('用空格分隔,将生成 args.<名称> 事件响应;复杂类型参数请继续使用旧版参数树。',
'Separate names with spaces to expose args.<name>; keep using the legacy argument tree for complex typed arguments.')}>
<Input value={value.configuration.arguments ?? ''} placeholder="target amount"
onChange={(event) => update('arguments', event.target.value.toLowerCase())} /></Field></div>;
if (value.type.startsWith('region.')) return <div className="galaxy-event-fields">
<Field label={l('区域 ID', 'Region ID')} hint={l('同一个 ID 的几何定义必须完全一致。', 'Geometry must be identical for every binding with the same ID.')}>
<Input value={value.configuration.regionId ?? ''} placeholder="spawn.safe_zone" pattern="[a-z][a-z0-9_.-]{1,63}"
onChange={(event) => update('regionId', event.target.value.trim().toLowerCase())} /></Field>
<div className="form-grid"><Field label={l('区域形状', 'Region shape')}><Select value={shape}
onChange={(event) => onChange({ ...value, configuration: { ...regionConfiguration(event.target.value),
regionId: value.configuration.regionId ?? '', dimension: value.configuration.dimension ?? 'minecraft:overworld' } })}>
<option value="cuboid">{l('长方体', 'Cuboid')} · cuboid</option><option value="sphere">{l('球体', 'Sphere')} · sphere</option>
<option value="cylinder">{l('圆柱体', 'Cylinder')} · cylinder</option></Select></Field>
<Field label={l('维度', 'Dimension')}><Input value={value.configuration.dimension ?? 'minecraft:overworld'}
onChange={(event) => update('dimension', event.target.value)} /></Field></div>
{shape === 'cuboid' ? <><div className="coordinate-grid">{['minX', 'minY', 'minZ'].map((name) => <Field key={name}
label={`${l('最小', 'Min')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div>
<div className="coordinate-grid">{['maxX', 'maxY', 'maxZ'].map((name) => <Field key={name}
label={`${l('最大', 'Max')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div></>
: <><div className="coordinate-grid">{['centerX', ...(shape === 'sphere' ? ['centerY'] : []), 'centerZ'].map((name) => <Field key={name}
label={`${l('中心', 'Center')} ${name.at(-1)}`}><Input type="number" value={value.configuration[name] ?? '0'}
onChange={(event) => update(name, event.target.value)} /></Field>)}</div>
<Field label={l('半径', 'Radius')}><Input type="number" min={0.01} value={value.configuration.radius ?? '8'}
onChange={(event) => update('radius', event.target.value)} /></Field>
{shape === 'cylinder' && <div className="form-grid"><Field label={l('最低 Y', 'Minimum Y')}><Input type="number"
value={value.configuration.minY ?? '0'} onChange={(event) => update('minY', event.target.value)} /></Field>
<Field label={l('最高 Y', 'Maximum Y')}><Input type="number" value={value.configuration.maxY ?? '320'}
onChange={(event) => update('maxY', event.target.value)} /></Field></div>}</>}
<Field label={l('检测频率(游戏刻)', 'Check frequency (ticks)')}
hint={l('20 游戏刻约等于 1 秒;数值越小检测越频繁。', '20 ticks is about one second; lower values check more often.')}>
<Input type="number" min={1} max={72000} value={value.configuration.frequencyTicks ?? '20'}
onChange={(event) => update('frequencyTicks', event.target.value)} /></Field></div>;
return <p className="field__hint">{l('此事件不需要额外配置。', 'This event needs no additional configuration.')}</p>;
}
function VariableDeclarationInspector({ value, catalog, zh, onChange }: { value: TriggerVariableDeclarationV2;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerVariableDeclarationV2) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-inspector__fields"><Field label={l('变量名', 'Variable name')}><Input value={value.name}
readOnly title={l('请使用下方安全重命名', 'Use safe rename below')} /></Field>
<Field label={l('类型', 'Type')} hint={label(triggerTypeDescriptions[value.valueType], zh,
l(`技术类型:${value.valueType}`, `Technical type: ${value.valueType}`))}><Input list="v2-trigger-types" value={value.valueType}
onChange={(event) => onChange({ ...value, valueType: event.target.value })} /></Field>
<datalist id="v2-trigger-types">{(catalog.triggerVariableTypes ?? []).map((entry) =>
<option key={entry} value={entry}>{label(triggerTypeLabels[entry], zh, entry)}</option>)}</datalist>
<Field label={l('可见性', 'Visibility')}><Select value={value.visibility}
onChange={(event) => onChange({ ...value, visibility: event.target.value as 'trigger' | 'global' })}>
<option value="trigger">{label(visibilityLabels.trigger, zh, 'trigger')} · trigger</option>
<option value="global">{label(visibilityLabels.global, zh, 'global')} · global</option></Select></Field>
<Field label={l('存储域', 'Storage')}><Select value={value.storage}
onChange={(event) => onChange({ ...value, storage: event.target.value as TriggerVariableDeclarationV2['storage'] })}>
{(catalog.variableStorageScopes ?? []).map((entry) => <option key={entry} value={entry}>
{label(storageLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
<Field label={l('生命周期', 'Lifetime')}><Select value={value.lifetime}
onChange={(event) => { const lifetime = event.target.value as TriggerVariableDeclarationV2['lifetime'];
onChange({ ...value, lifetime, ttlSeconds: lifetime === 'TTL' ? value.ttlSeconds ?? 3600 : null }); }}>
<option value="SESSION">{label(lifetimeLabels.SESSION, zh, 'SESSION')} · SESSION</option>
<option value="TTL">{label(lifetimeLabels.TTL, zh, 'TTL')} · TTL</option>
<option value="PERSISTENT">{label(lifetimeLabels.PERSISTENT, zh, 'PERSISTENT')} · PERSISTENT</option></Select></Field>
{value.lifetime === 'TTL' && <Field label={l('有效期(秒)', 'TTL (seconds)')}><Input type="number" min={1} max={31536000}
value={value.ttlSeconds ?? 3600} onChange={(event) => onChange({ ...value, ttlSeconds: Number(event.target.value) })} /></Field>}
{value.initialValue && <ExpressionEditor value={value.initialValue} zh={zh}
onChange={(initialValue) => onChange({ ...value, initialValue })} />}</div>;
}
function FunctionInspector({ value, catalog, zh, onChange }: { value: TriggerFunctionDeclaration;
catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerFunctionDeclaration) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-inspector__fields"><Field label={l('函数名', 'Function name')}><Input value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('返回类型', 'Return type')} hint={label(triggerTypeDescriptions[value.returnType], zh,
l(`技术类型:${value.returnType}`, `Technical type: ${value.returnType}`))}><Input list="v2-trigger-types" value={value.returnType}
onChange={(event) => onChange({ ...value, returnType: event.target.value })} /></Field>
<JsonValueEditor label={l('参数(IN/OUT/INOUT)', 'Parameters (IN/OUT/INOUT)')} value={value.parameters}
onCommit={(parameters) => onChange({ ...value, parameters: parameters as TriggerFunctionDeclaration['parameters'] })} />
<JsonValueEditor label={l('局部变量', 'Local variables')} value={value.locals}
onCommit={(locals) => onChange({ ...value, locals: locals as TriggerVariableDeclarationV2[] })} />
<small>{l('函数体在中间树中显示;可通过高级 JSON 或复制语句维护。',
'The function body is shown in the tree; use advanced JSON or duplicated statements to maintain it.')}</small></div>;
}
function StatementInspector({ value, program, catalog, zh, onChange, onAppend }: { value: TriggerStatement;
program: TriggerProgramV2; catalog: TriggerCatalog; zh: boolean; onChange: (value: TriggerStatement) => void;
onAppend: (branch: 'statements' | 'elseStatements', child: TriggerStatement) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const action = (catalog.descriptors ?? []).find((entry) => entry.kind === 'ACTION' && entry.id === value.name);
const needsExpression = ['SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'RETURN'].includes(value.kind);
return <div className="galaxy-inspector__fields"><Field label={l('语句类型', 'Statement kind')}><Select value={value.kind}
onChange={(event) => onChange({ ...defaultV2Statement(event.target.value as TriggerStatement['kind'], program),
nodeId: value.nodeId })}>{['ACTION', 'SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'CALL', 'RETURN', 'TRY']
.map((entry) => <option key={entry} value={entry}>{label(statementKindLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
{value.kind === 'ACTION' ? <Field label={l('动作', 'Action')}><Select value={value.name}
onChange={(event) => { const descriptor = (catalog.descriptors ?? []).find((entry) =>
entry.kind === 'ACTION' && entry.id === event.target.value); if (descriptor) onChange({ ...actionStatement(descriptor), nodeId: value.nodeId }); }}>
{(catalog.descriptors ?? []).filter((entry) => entry.kind === 'ACTION').map((entry) =>
<option key={entry.id} value={entry.id}>{descriptorName(entry, catalog, zh)} · {entry.id}</option>)}</Select></Field>
: ['SET', 'FOREACH', 'CALL'].includes(value.kind) && <Field label={value.kind === 'SET'
? l('目标变量', 'Target variable') : value.kind === 'FOREACH' ? l('元素变量', 'Item variable') : l('函数', 'Function')}>
{value.kind === 'SET' ? <Select value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })}>
{program.declarations.map((entry) => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</Select>
: value.kind === 'CALL' ? <Select value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })}>
{program.functions.map((entry) => <option key={entry.name} value={entry.name}>{entry.name}</option>)}</Select>
: <Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />}</Field>}
{needsExpression && value.expression && <ExpressionEditor value={value.expression} zh={zh}
onChange={(expression) => onChange({ ...value, expression })} />}
{value.kind === 'ACTION' && action && <p className="galaxy-context-help"><strong>{descriptorName(action, catalog, zh)}</strong>
<span>{descriptorDescription(action, catalog, zh)}</span><code>{action.id}</code></p>}
{value.kind === 'ACTION' && <div className="galaxy-expression-list">{(action?.parameters ?? []).map((parameter) => {
const expression = value.inputs[parameter.name] ?? literalExpression(parameter.defaultValue ?? '', 'string');
return <ExpressionEditor key={parameter.name} label={`${actionParameterLabel(parameter.name, zh)} · ${parameter.name}`}
hint={parameter.description || actionParameterDescription(parameter.name, zh)} value={expression} zh={zh}
onChange={(next) => onChange({ ...value, inputs: { ...value.inputs, [parameter.name]: next } })} />;
})}</div>}
{['IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'TRY'].includes(value.kind) && <div className="button-row">
<Button type="button" variant="secondary" onClick={() => onAppend('statements',
actionStatement(firstActionDescriptor(catalog)))}>{l('添加子动作', 'Add child action')}</Button>
{['IF', 'SWITCH', 'TRY'].includes(value.kind) && <Button type="button" variant="secondary"
onClick={() => onAppend('elseStatements', actionStatement(firstActionDescriptor(catalog)))}>
{value.kind === 'TRY' ? l('添加错误分支', 'Add error branch') : l('添加否则分支', 'Add else branch')}</Button>}</div>}
</div>;
}
function ExpressionEditor({ value, label: editorLabel, hint, zh, onChange }: { value: TriggerExpression; label?: string; hint?: string; zh: boolean;
onChange: (value: TriggerExpression) => void }) {
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="galaxy-expression"><strong>{editorLabel ?? l('表达式', 'Expression')}</strong>
<div className="form-grid"><Field label={l('种类', 'Kind')}><Select value={value.kind}
onChange={(event) => onChange(expressionOfKind(event.target.value as TriggerExpression['kind'], value.nodeId))}>
{['LITERAL', 'REFERENCE', 'UNARY', 'BINARY', 'FUNCTION', 'INDEX', 'COALESCE', 'CONVERT'].map((entry) =>
<option key={entry} value={entry}>{label(expressionKindLabels[entry], zh, entry)} · {entry}</option>)}</Select></Field>
<Field label={l('结果类型', 'Value type')} hint={label(triggerTypeDescriptions[value.valueType], zh,
l(`技术类型:${value.valueType}`, `Technical type: ${value.valueType}`))}><Input list="v2-trigger-types" value={value.valueType}
onChange={(event) => onChange({ ...value, valueType: event.target.value })} /></Field></div>
{hint && <small className="galaxy-expression__help">{hint}</small>}
{value.kind === 'LITERAL' ? <LiteralEditor value={value.literal} valueType={value.valueType} zh={zh}
onChange={(literal) => onChange({ ...value, literal })} /> : <Field label={l('名称/操作符/引用', 'Name/operator/reference')}>
<Input value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>}
{value.kind !== 'LITERAL' && <JsonValueEditor label={l('子表达式', 'Arguments')} value={value.arguments}
onCommit={(argumentsList) => onChange({ ...value, arguments: argumentsList as TriggerExpression[] })} />}</div>;
}
function LiteralEditor({ value, valueType, zh, onChange }: { value: unknown; valueType: string; zh: boolean;
onChange: (value: unknown) => void }) {
if (valueType === 'bool') return <Select value={String(value)}
onChange={(event) => onChange(event.target.value === 'true')}><option value="true">{zh ? '是 · true' : 'True · true'}</option>
<option value="false">{zh ? '否 · false' : 'False · false'}</option></Select>;
if (valueType === 'integer' || valueType === 'float') return <Input type="number" value={String(value ?? 0)}
onChange={(event) => onChange(valueType === 'integer' ? Number.parseInt(event.target.value || '0', 10)
: Number(event.target.value || '0'))} />;
if (valueType === 'list' || /^(?:array|set|dictionary|record)</.test(valueType)) return <JsonValueEditor
label="JSON" value={value} onCommit={onChange} />;
return <Input value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} />;
}
function JsonValueEditor({ label, value, onCommit }: { label: string; value: unknown; onCommit: (value: unknown) => void }) {
const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2));
const [invalid, setInvalid] = useState(false);
useEffect(() => { setDraft(JSON.stringify(value, null, 2)); setInvalid(false); }, [value]);
const commit = () => { try { onCommit(JSON.parse(draft)); setInvalid(false); } catch { setInvalid(true); } };
return <Field label={label}><Textarea value={draft} aria-invalid={invalid} onChange={(event) => setDraft(event.target.value)}
onBlur={commit} /><Button type="button" variant="ghost" onClick={commit}>{invalid ? 'JSON ✕' : 'JSON ✓'}</Button></Field>;
}
function JsonNodeEditor({ value, zh, onCommit }: { value: object; zh: boolean; onCommit: (value: Record<string, unknown>) => void }) {
return <details className="galaxy-json"><summary>{zh ? '高级节点 JSON' : 'Advanced node JSON'}</summary>
<JsonValueEditor label="JSON" value={value} onCommit={(next) => {
if (next && typeof next === 'object' && !Array.isArray(next)) onCommit(next as Record<string, unknown>);
}} /></details>;
}
function StepTitle({ number, title, detail, action }: { number: string; title: string; detail: string; action?: ReactNode }) {
return <div className="trigger-step-heading"><span className="trigger-step-number">{number}</span><span><strong>{title}</strong><small>{detail}</small></span>{action}</div>;
}
interface IntervalParts { hours: string; minutes: string; seconds: string }
function IntervalFields({ totalSeconds, zh, onChange }: {
totalSeconds: string; zh: boolean; onChange: (seconds: string) => void;
}) {
const [parts, setParts] = useState<IntervalParts>(() => splitIntervalSeconds(totalSeconds));
const lastEmittedTotal = useRef(totalSeconds);
useEffect(() => {
if (totalSeconds === lastEmittedTotal.current) return;
lastEmittedTotal.current = totalSeconds;
setParts(splitIntervalSeconds(totalSeconds));
}, [totalSeconds]);
const updatePart = (part: keyof IntervalParts, nextValue: string) => {
const next = { ...parts, [part]: nextValue };
setParts(next);
const serialized = joinIntervalSeconds(next);
lastEmittedTotal.current = serialized;
onChange(serialized);
};
const l = (chinese: string, english: string) => zh ? chinese : english;
return <div className="trigger-interval-editor" role="group" aria-label={l('执行间隔', 'Execution interval')}>
<div className="coordinate-grid">
<Field label={l('小时', 'Hours')}><Input type="number" inputMode="numeric" min={0} max={8760} step={1}
aria-label={l('间隔小时', 'Interval hours')} value={parts.hours}
onChange={(event) => updatePart('hours', event.target.value)} /></Field>
<Field label={l('分钟', 'Minutes')}><Input type="number" inputMode="numeric" min={0} max={59} step={1}
aria-label={l('间隔分钟', 'Interval minutes')} value={parts.minutes}
onChange={(event) => updatePart('minutes', event.target.value)} /></Field>
<Field label={l('秒', 'Seconds')}><Input type="number" inputMode="numeric" min={0} max={59} step={1}
aria-label={l('间隔秒', 'Interval seconds')} value={parts.seconds}
onChange={(event) => updatePart('seconds', event.target.value)} /></Field>
</div>
<p className="field__hint">{l(
'总间隔为 1 秒至 1 年,并按服务器本地时间边界对齐。例如服务器时间 12:58、间隔 15 分钟时,下一次执行为 13:00。',
'The total interval can be 1 second to 1 year and aligns to server-local time boundaries. For example, at 12:58 with a 15-minute interval, the next run is at 13:00.')}</p>
</div>;
}
function ProtectionEventFields({ event, zh, onChange }: {
event: TriggerEventSpec; zh: boolean; onChange: (event: TriggerEventSpec) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const update = (name: string, value: string) => onChange({ ...event,
configuration: { ...event.configuration, [name]: value } });
const countEvent = ['protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow'].includes(event.type);
const unit = event.type === 'protection.slow_tick' ? l('毫秒', 'milliseconds')
: event.type === 'protection.memory_pressure' ? l('百分比', 'percent') : l('数量', 'count');
return <div className="trigger-protection-editor">
<Field label={l(`触发阈值(${unit})`, `Trigger threshold (${unit})`)}
hint={l('只有当前测量值严格大于此数值时才触发。', 'Runs only when the observed value is greater than this number.')}>
<Input type="number" inputMode="numeric" step={1}
min={event.type === 'protection.memory_pressure' || event.type === 'protection.slow_tick' ? 50 : 1}
max={event.type === 'protection.memory_pressure' ? 99 : 2147483647}
aria-label={l('防护事件阈值', 'Protection event threshold')}
value={event.configuration.threshold ?? ''}
onChange={(change) => update('threshold', change.target.value)} />
</Field>
{countEvent && <Field label={l('统计范围', 'Counting scope')}>
<Select aria-label={l('防护统计范围', 'Protection counting scope')}
value={event.configuration.scope ?? 'dimension'}
onChange={(change) => update('scope', change.target.value)}>
<option value="dimension">{l('整个维度', 'Entire dimension')}</option>
<option value="chunk">{l('单个区块峰值', 'Peak single chunk')}</option>
</Select>
</Field>}
{event.type === 'protection.mod_entity_overflow' && <Field
label={l('模组命名空间', 'Mod namespace')} hint="create · minecraft · modid">
<Input value={event.configuration.namespace ?? ''} pattern="[a-z0-9_.-]+" spellCheck={false}
aria-label={l('实体模组命名空间', 'Entity mod namespace')}
onChange={(change) => update('namespace', change.target.value.trim().toLowerCase())} />
</Field>}
{event.type === 'protection.slow_tick' && <Field label={l('连续慢刻数', 'Consecutive slow ticks')}>
<Input type="number" inputMode="numeric" min={1} max={1200} step={1}
aria-label={l('连续慢刻数', 'Consecutive slow ticks')}
value={event.configuration.consecutive ?? '1'}
onChange={(change) => update('consecutive', change.target.value)} />
</Field>}
<Field label={l('重复触发冷却(秒)', 'Repeat cooldown (seconds)')}
hint={l('同一维度和范围在冷却期间只执行一次。', 'The same dimension and scope run once during the cooldown.')}>
<Input type="number" inputMode="numeric" min={1} max={86400} step={1}
aria-label={l('防护重复触发冷却', 'Protection repeat cooldown')}
value={event.configuration.cooldownSeconds ?? '60'}
onChange={(change) => update('cooldownSeconds', change.target.value)} />
</Field>
<p className="field__hint">{l(
'该阈值控制触发器何时执行;系统设置中的硬限制负责自动清理、阻止生成和熔断命令方块,两者可以分别配置。',
'This threshold controls trigger execution. Hard limits in server settings independently clean items, block spawns, and trip command-block circuit breakers.')}</p>
</div>;
}
function CommandTriggerFields({ event, catalog, zh, onChange }: {
event: TriggerEventSpec; catalog: TriggerCatalog; zh: boolean; onChange: (event: TriggerEventSpec) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const command = event.configuration.command ?? '';
const argumentTree = commandArgumentTree(event);
const argumentCount = countCommandArgumentNodes(argumentTree);
const updateArguments = (next: TriggerCommandArgument[]) => {
const configuration = { ...event.configuration };
delete configuration.arguments;
onChange({ ...event, configuration, arguments: next });
};
return <div className="trigger-command-editor">
<Field label={l('自定义指令', 'Custom command')}
hint={l('仅填写指令根,不含 /;使用小写字母、数字、_ 或 -,最多 32 个字符。',
'Enter the command root without /; use lowercase letters, numbers, _ or -, up to 32 characters.')}>
<Input aria-label={l('自定义指令根', 'Custom command root')} value={command}
pattern="[a-z][a-z0-9_-]{0,31}" maxLength={32} spellCheck={false}
aria-invalid={!commandPartPattern.test(command)} placeholder="rules"
onChange={(change) => onChange({ ...event,
configuration: { ...event.configuration, command: change.target.value } })} />
</Field>
<CommandArgumentTreeEditor arguments={argumentTree} catalog={catalog} zh={zh} depth={0}
path={[]} totalNodes={argumentCount} root onChange={updateArguments} />
<div className="trigger-command-preview"><span>{l('玩家输入预览', 'Player input preview')}</span>
<code>/{command || 'command'}{commandArgumentPreview(argumentTree)}</code></div>
</div>;
}
function CommandArgumentTreeEditor({ arguments: values, catalog, zh, depth, path, totalNodes, ordinalOffset = 0,
duplicateNames, root = false, onChange }: {
arguments: TriggerCommandArgument[]; catalog: TriggerCatalog; zh: boolean; depth: number; path: number[];
totalNodes: number; ordinalOffset?: number; duplicateNames?: Set<string>; root?: boolean;
onChange: (value: TriggerCommandArgument[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const duplicates = duplicateNames ?? duplicateCommandArgumentNames(values);
const add = () => onChange([...values, defaultCommandArgument(totalNodes + 1)]);
return <div className={`trigger-argument-tree${root ? ' trigger-argument-tree--root' : ''}`}>
<div className="trigger-command-arguments__heading"><span><strong>{root
? l('指令参数树', 'Command argument tree') : l('下一层参数', 'Next argument level')}</strong>
<small>{root ? l('同层参数是候选分支,内部参数是下一输入层级;每个值可作为 args 变量使用。',
'Siblings are alternative branches and contained arguments form the next input level; each value becomes an args variable.')
: l('只有当前参数匹配后,才会解析这里的参数。', 'These arguments are parsed only after this argument matches.')}</small></span>
<div className="button-row"><Button type="button" variant="ghost"
title={root ? l('粘贴为同级根参数', 'Paste as a root sibling') : l('粘贴为同级下层参数', 'Paste as a child sibling')}
aria-label={root ? l('粘贴根参数', 'Paste root argument') : l('粘贴下层参数', 'Paste child argument')}
onClick={() => void pasteTriggerModule<TriggerCommandArgument>('argument').then((argument) => {
if (argument) onChange([...values, argument]);
})}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost"
onClick={add}><Plus size={14} />{root ? l('添加参数', 'Add argument') : l('添加下级参数', 'Add child argument')}</Button></div></div>
<div className="trigger-argument-list">{values.map((argument, index) => {
const currentPath = [...path, index + 1];
const ordinal = ordinalOffset + 1 + values.slice(0, index)
.reduce((total, item) => total + 1 + countCommandArgumentNodes(item.children ?? []), 0);
return <CommandArgumentCard key={index} value={argument} catalog={catalog} zh={zh}
path={currentPath} ordinal={ordinal} duplicateNames={duplicates} position={index} count={values.length} depth={depth + 1} totalNodes={totalNodes}
onChange={(next) => onChange(replace(values, index, next))}
onMove={(offset) => onChange(move(values, index, offset))}
onDelete={() => onChange(remove(values, index))} />;
})}
{!values.length && <div className="trigger-empty">{root
? l('没有参数;该指令仅匹配指令根。', 'No arguments; this command matches the command root only.')
: l('没有后续参数。', 'No following argument.')}</div>}
</div>
</div>;
}
function CommandArgumentCard({ value, catalog, zh, path, ordinal, duplicateNames, position, count, depth, totalNodes,
onChange, onMove, onDelete }: {
value: TriggerCommandArgument; catalog: TriggerCatalog; zh: boolean; path: number[]; ordinal: number;
duplicateNames: Set<string>; position: number;
count: number; depth: number; totalNodes: number; onChange: (value: TriggerCommandArgument) => void;
onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const name = l(`参数 ${path.join('.')}`, `Argument ${path.join('.')}`);
const types = catalog.commandArgumentTypes ?? fallbackCatalog.commandArgumentTypes ?? [];
return <article className="trigger-argument-card" aria-label={name}>
<div className="trigger-action-card__head"><GripVertical size={14} /><strong>{name}</strong>
<div className="button-row"><ModuleCopyButton kind="argument" value={value} zh={zh} name={name} />
<ActionMoveButtons zh={zh} name={name} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-argument-card__body">
<Field label={l('变量名称', 'Variable name')} hint={`{args.${value.name || 'arg'}}`}><Input
aria-label={zh ? `指令参数 ${ordinal}` : `Command argument ${ordinal}`} value={value.name} pattern="[a-z][a-z0-9_-]{0,31}"
aria-invalid={!commandPartPattern.test(value.name) || duplicateNames.has(value.name)}
maxLength={32} spellCheck={false} onChange={(event) => onChange({ ...value, name: event.target.value })} /></Field>
<Field label={l('参数类型', 'Argument type')}><Select aria-label={`${name} ${l('类型', 'type')}`}
value={value.type} onChange={(event) => onChange({ ...value, type: event.target.value })}>
{types.map((type) => <option key={type} value={type}>{commandArgumentTypeLabel(type, zh)}</option>)}</Select></Field>
{value.type === 'literal' && <Field label={l('固定值', 'Literal value')}><Input value={value.literal}
maxLength={128} onChange={(event) => onChange({ ...value, literal: event.target.value })} /></Field>}
{['integer', 'long', 'float', 'double', 'time'].includes(value.type) && <><Field label={l('最小值(可空)', 'Minimum (optional)')}>
<Input inputMode="decimal" value={value.minimum} onChange={(event) => onChange({ ...value, minimum: event.target.value })} /></Field>
<Field label={l('最大值(可空)', 'Maximum (optional)')}><Input inputMode="decimal" value={value.maximum}
onChange={(event) => onChange({ ...value, maximum: event.target.value })} /></Field></>}
<Field label={l('候选值(中英文逗号分隔)', 'Suggestions (comma separated)')}><SuggestionListInput
value={value.suggestions}
placeholder={['player', 'players'].includes(value.type) ? l('在线玩家会由游戏自动补全', 'Online players are suggested by the game') : ''}
onChange={(suggestions) => onChange({ ...value, suggestions })} /></Field>
<Field label={l('输入错误提示', 'Invalid-value message')}><Input value={value.errorMessage} maxLength={256}
placeholder={l('留空则使用默认的类型错误提示', 'Leave empty to use the default type error')}
onChange={(event) => onChange({ ...value, errorMessage: event.target.value })} /></Field>
<label className="toggle-row toggle-row--compact"><input type="checkbox" checked={value.optional}
onChange={(event) => onChange({ ...value, optional: event.target.checked })} /><span><strong>{l('此层级可选', 'Optional at this level')}</strong>
<small>{l('允许在输入该参数前结束指令。', 'Allows the command to end before this argument.')}</small></span></label>
</div>
<CommandArgumentTreeEditor arguments={value.children} catalog={catalog} zh={zh} depth={depth}
path={path} totalNodes={totalNodes} ordinalOffset={ordinal}
duplicateNames={duplicateNames}
onChange={(children) => onChange({ ...value, children })} />
</article>;
}
function SuggestionListInput({ value, placeholder, onChange }: {
value: string[]; placeholder?: string; onChange: (value: string[]) => void;
}) {
const signature = value.join('\u0000');
const lastEmitted = useRef(signature);
const [draft, setDraft] = useState(value.join(', '));
useEffect(() => {
if (signature === lastEmitted.current) return;
lastEmitted.current = signature;
setDraft(value.join(', '));
}, [signature, value]);
const parse = (raw: string) => raw.split(/[,,]/u).map((item) => item.trim()).filter(Boolean);
return <Input value={draft} placeholder={placeholder} onChange={(event) => {
const raw = event.target.value;
const suggestions = parse(raw);
setDraft(raw);
lastEmitted.current = suggestions.join('\u0000');
onChange(suggestions);
}} onBlur={() => setDraft(parse(draft).join(', '))} />;
}
function TriggerVariablesEditor({ values, liveValues, types, zh, onChange }: {
values: TriggerStateVariableDefinition[]; liveValues: Record<string, unknown>; types: string[]; zh: boolean;
onChange: (values: TriggerStateVariableDefinition[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const add = (visibility: 'trigger' | 'global') => {
let index = values.length + 1;
let name = index === 1 ? 'value' : `value${index}`;
while (values.some((entry) => entry.name === name)) name = `value${++index}`;
onChange([...values, { name, type: 'string', initialValue: '', visibility,
storage: visibility === 'global' ? 'server' : 'trigger', lifetime: 'session', revision: 0 }]);
};
const renderGroup = (visibility: 'trigger' | 'global') => {
const groupValues = values.map((variable, index) => ({ variable, index }))
.filter(({ variable }) => (variable.visibility ?? 'trigger') === visibility);
const title = visibility === 'global' ? l('全局变量', 'Global variables') : l('触发器变量', 'Trigger variables');
const description = visibility === 'global'
? l('通过 {global.名称} 在所有触发器中访问;声明名称在整个服务器中唯一。',
'Use {global.name} from every trigger; declaration names are unique across the server.')
: l('通过 {var.名称} 访问,仅属于当前触发器;旧版变量会自动迁移到这里。',
'Use {var.name} in this trigger only; legacy variables migrate here automatically.');
const paste = async () => {
const variable = await pasteTriggerModule<TriggerStateVariableDefinition>('variable');
if (!variable) return;
onChange([...values, { ...variable, visibility,
storage: variable.storage ?? (visibility === 'global' ? 'server' : 'trigger'),
lifetime: variable.lifetime ?? 'session', revision: variable.revision ?? 0 }]);
};
return <section className="trigger-variable-group" key={visibility}>
<div className="trigger-command-arguments__heading"><span><strong>{title}</strong><small>{description}</small></span>
<div className="button-row"><Button type="button" variant="ghost"
title={l(`粘贴${title}`, `Paste ${title}`)} aria-label={l(`粘贴${title}`, `Paste ${title}`)}
onClick={() => void paste()}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost" onClick={() => add(visibility)}>
<Plus size={14} />{l('添加变量', 'Add variable')}</Button></div></div>
<div className="trigger-variable-list">{groupValues.map(({ variable, index }) => {
const duplicate = values.some((entry, current) => current !== index
&& (entry.visibility ?? 'trigger') === visibility && entry.name === variable.name);
const key = visibility === 'global' ? `global.${variable.name}` : variable.name;
const template = visibility === 'global' ? `{global.${variable.name || 'name'}}` : `{var.${variable.name || 'name'}}`;
const current = Object.prototype.hasOwnProperty.call(liveValues, key)
? liveValues[key] : variable.initialValue;
return <article className="trigger-variable-card" key={index}>
<div className="trigger-variable-card__fields">
<Field label={l('变量名称', 'Variable name')} hint={template}><Input
value={variable.name} maxLength={48} pattern="[A-Za-z_][A-Za-z0-9_-]{0,47}"
aria-invalid={!triggerVariableNameIsValid(variable.name) || duplicate}
onChange={(event) => onChange(replace(values, index, { ...variable, name: event.target.value }))} /></Field>
<Field label={l('值类型(支持嵌套泛型)', 'Value type (nested generics supported)')}>
<VariableTypeEditor value={variable.type} types={types} zh={zh} onChange={(type) =>
onChange(replace(values, index, { ...variable, type, initialValue: defaultTriggerVariableValue(type) }))} />
</Field>
<Field label={l('存储位置', 'Storage scope')}><Select value={variable.storage ?? (visibility === 'global' ? 'server' : 'trigger')}
onChange={(event) => onChange(replace(values, index, { ...variable,
storage: event.target.value as TriggerStateVariableDefinition['storage'] }))}>
<option value="server">{l('服务器:单一实例', 'Server: one instance')}</option>
<option value="player">{l('触发玩家:每名玩家独立', 'Event player: per player')}</option>
<option value="dimension">{l('维度:每个维度独立', 'Dimension: per dimension')}</option>
<option value="trigger">{l('执行触发器:每个触发器独立', 'Executing trigger: per trigger')}</option>
<option value="execution">{l('执行实例:每次运行独立', 'Execution: per run')}</option>
<option value="chunk">{l('区块:每个区块独立', 'Chunk: per chunk')}</option>
<option value="entity">{l('实体:每个 UUID 独立', 'Entity: per UUID')}</option>
</Select></Field>
<Field label={l('生命周期', 'Lifetime')}><Select value={variable.lifetime ?? 'session'}
onChange={(event) => onChange(replace(values, index, { ...variable,
lifetime: event.target.value as TriggerStateVariableDefinition['lifetime'],
ttlSeconds: event.target.value === 'ttl' ? (variable.ttlSeconds ?? 3_600) : null }))}>
<option value="session">{l('会话:重启后重置', 'Session: reset on restart')}</option>
<option value="ttl">{l('TTL:到期后重置', 'TTL: reset after expiry')}</option>
<option value="persistent">{l('持久:保留至修改', 'Persistent: retain until changed')}</option>
</Select></Field>
{(variable.lifetime ?? 'session') === 'ttl' && <Field label={l('有效期(秒)', 'TTL seconds')}>
<Input type="number" min={1} max={31_536_000} value={variable.ttlSeconds ?? 3_600}
onChange={(event) => onChange(replace(values, index, { ...variable,
ttlSeconds: Number(event.target.value) }))} /></Field>}
<Field label={l('初始值', 'Initial value')} hint={variableTypeIsContainer(variable.type)
? l('数组使用 JSON [],字典使用 JSON {};嵌套值按泛型严格校验。',
'Use JSON [] for arrays and {} for dictionaries; nested values are strictly typed.') : undefined}>
{variableTypeIsContainer(variable.type) ? <Textarea value={variable.initialValue} spellCheck={false}
onChange={(event) => onChange(replace(values, index, { ...variable, initialValue: event.target.value }))} />
: <Input value={variable.initialValue} inputMode={['integer', 'float'].includes(variable.type) ? 'decimal' : undefined}
onChange={(event) => onChange(replace(values, index, { ...variable, initialValue: event.target.value }))} />}
</Field>
<div className="trigger-variable-current"><span>{l('当前值', 'Current value')}</span>
<code title={formatLiveVariable(current)}>{formatLiveVariable(current)}</code></div>
</div>
<div className="button-row"><ModuleCopyButton kind="variable" value={variable} zh={zh} name={`${title} ${variable.name}`} />
<Button type="button" variant="ghost" onClick={() => onChange(remove(values, index))}
aria-label={l(`删除变量 ${variable.name}`, `Delete variable ${variable.name}`)}><Trash2 size={14} /></Button></div>
</article>;
})}
{!groupValues.length && <div className="trigger-empty">{l('此分类还没有变量。', 'No variables in this category.')}</div>}
</div>
</section>;
};
return <div className="trigger-variable-editor">
<p className="field__hint">{l('当前值每秒刷新;持久与 TTL 写入在后台有界批处理,并使用修订号防止覆盖并发修改。',
'Live values refresh every second. Persistent and TTL writes are bounded, batched off-thread, and revision protected.')}</p>
{renderGroup('global')}{renderGroup('trigger')}
</div>;
}
interface VariableTypeNode { name: string; arguments: VariableTypeNode[] }
function parseVariableTypeNode(value: string): VariableTypeNode {
let position = 0;
const parse = (): VariableTypeNode => {
while (/\s/.test(value[position] ?? '')) position += 1;
const start = position;
while (/[A-Za-z0-9_]/.test(value[position] ?? '')) position += 1;
const name = value.slice(start, position) || 'string';
const args: VariableTypeNode[] = [];
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === '<') {
position += 1; args.push(parse());
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === ',') { position += 1; args.push(parse()); }
while (/\s/.test(value[position] ?? '')) position += 1;
if (value[position] === '>') position += 1;
}
return { name, arguments: args };
};
try { return parse(); } catch { return { name: 'string', arguments: [] }; }
}
function serializeVariableTypeNode(value: VariableTypeNode): string {
return value.arguments.length ? `${value.name}<${value.arguments.map(serializeVariableTypeNode).join(',')}>` : value.name;
}
function VariableTypeEditor({ value, types, zh, onChange, depth = 0, allowContainers = true }: {
value: string; types: string[]; zh: boolean; depth?: number; allowContainers?: boolean; onChange: (value: string) => void;
}) {
const node = parseVariableTypeNode(value);
const scalarTypes = [...new Set(types.filter((type) => !['array', 'set', 'optional', 'dictionary', 'list'].includes(type) && !type.includes('<')))];
const choices = !allowContainers ? scalarTypes
: [...scalarTypes, 'array', 'set', 'optional', 'dictionary', ...(depth === 0 && types.includes('list') ? ['list'] : [])];
const changeName = (name: string) => {
const next: VariableTypeNode = ['array', 'set', 'optional'].includes(name)
? { name, arguments: [{ name: 'string', arguments: [] }] }
: name === 'dictionary' ? { name, arguments: [{ name: 'string', arguments: [] }, { name: 'string', arguments: [] }] }
: { name, arguments: [] };
onChange(serializeVariableTypeNode(next));
};
const changeArgument = (index: number, next: string) => {
const args = [...node.arguments]; args[index] = parseVariableTypeNode(next);
onChange(serializeVariableTypeNode({ ...node, arguments: args }));
};
return <div className="trigger-variable-type-editor">
<Select aria-label={zh ? `泛型第 ${depth + 1} 层类型` : `Generic type level ${depth + 1}`}
value={node.name} onChange={(event) => changeName(event.target.value)}>
{choices.map((type) => <option value={type} key={type}>{triggerVariableTypeLabel(type, zh)}</option>)}</Select>
{['array', 'set', 'optional'].includes(node.name) && <div className="trigger-variable-generic"><span>
{node.name === 'optional' ? (zh ? '内部值' : 'Inner value') : (zh ? '元素' : 'Element')}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[0] ?? { name: 'string', arguments: [] })}
types={types} zh={zh} depth={depth + 1} onChange={(next) => changeArgument(0, next)} /></div>}
{node.name === 'dictionary' && <div className="trigger-variable-generic-grid"><span>{zh ? '键' : 'Key'}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[0] ?? { name: 'string', arguments: [] })}
types={scalarTypes} zh={zh} depth={depth + 1} allowContainers={false} onChange={(next) => changeArgument(0, next)} />
<span>{zh ? '值' : 'Value'}</span>
<VariableTypeEditor value={serializeVariableTypeNode(node.arguments[1] ?? { name: 'string', arguments: [] })}
types={types} zh={zh} depth={depth + 1} onChange={(next) => changeArgument(1, next)} /></div>}
</div>;
}
const triggerVariableTypeLabels: Record<string, [string, string]> = {
bool: ['布尔值', 'Boolean'], integer: ['整型', 'Integer'], float: ['浮点数(兼容整型)', 'Float'],
string: ['字符串', 'String'], coordinate: ['坐标', 'Coordinates'], uuid: ['UUID', 'UUID'],
player: ['玩家名称', 'Player name'], resource_location: ['资源位置', 'Resource location'],
block_state: ['方块状态', 'Block state'], item_stack: ['物品堆', 'Item stack'],
component: ['文本组件', 'Text component'], nbt: ['NBT', 'NBT'], list: ['旧版字符串列表', 'Legacy string list'],
position: ['位置', 'Position'], rotation: ['旋转', 'Rotation'], duration: ['时长', 'Duration'],
instant: ['时间点', 'Instant'], region_ref: ['区域引用', 'Region reference'],
player_ref: ['玩家引用', 'Player reference'], entity_ref: ['实体引用', 'Entity reference'],
block_ref: ['方块引用', 'Block reference'], item_ref: ['物品引用', 'Item reference'],
record: ['记录', 'Record'], array: ['数组', 'Array'], set: ['集合', 'Set'],
optional: ['可选值', 'Optional'], dictionary: ['字典', 'Dictionary'],
};
function triggerVariableTypeLabel(type: string, zh: boolean): string {
return `${label(triggerVariableTypeLabels[type], zh, type)} · ${type}`;
}
function triggerVariableNameIsValid(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test(value);
}
function defaultTriggerVariableValue(type: string): string {
if (type.startsWith('array<') || type.startsWith('set<') || type === 'array' || type === 'set') return '[]';
if (type.startsWith('optional<') || type === 'optional') return 'null';
if (type.startsWith('dictionary<') || type === 'dictionary') return '{}';
if (type === 'bool') return 'false';
if (type === 'integer' || type === 'float') return '0';
if (['uuid', 'player_ref', 'entity_ref'].includes(type)) return '00000000-0000-0000-0000-000000000000';
if (type === 'coordinate') return '0 0 0';
if (type === 'rotation') return '0 0';
if (type === 'position' || type === 'block_ref') return 'minecraft:overworld 0 0 0';
if (type === 'duration') return 'PT0S';
if (type === 'instant') return '1970-01-01T00:00:00Z';
if (type === 'player') return 'Player';
if (['resource_location', 'region_ref', 'item_ref'].includes(type)) return 'minecraft:stone';
if (type === 'block_state') return 'minecraft:stone';
if (type === 'item_stack') return 'minecraft:stone';
if (type === 'component') return '{"text":""}';
if (type === 'nbt' || type === 'record') return '{}';
return '';
}
function variableTypeIsContainer(type: string): boolean {
return /^(?:array|set|optional|dictionary)</.test(type) || ['array', 'set', 'optional', 'dictionary', 'record'].includes(type);
}
function formatLiveVariable(value: unknown): string {
if (Array.isArray(value)) return value.join(', ');
if (value !== null && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
function sampleSimulationContext(event: TriggerEventSpec, catalog: TriggerCatalog): Record<string, unknown> {
const result: Record<string, unknown> = { 'event.type': event.type };
(catalog.variables ?? []).filter((variable) => variable.sensitive !== true
&& variable.conditionAllowed !== false && variableAppliesToEvent(variable, event.type))
.forEach((variable) => {
if (variable.sampleValue === undefined) return;
const key = normalizedVariableKey(variable.key);
const sample = variable.sampleValue;
if (variable.type === 'bool') result[key] = sample === 'true';
else if (['integer', 'float', 'number'].includes(variable.type ?? '') && Number.isFinite(Number(sample))) {
result[key] = Number(sample);
} else result[key] = sample;
});
return result;
}
function ConditionRow({ value, catalog, eventType, position, count, zh, onChange, onMove, onDelete }: {
value: TriggerCondition; catalog: TriggerCatalog; eventType: string; position: number; count: number; zh: boolean;
onChange: (value: TriggerCondition) => void; onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
const number = position + 1;
const prefix = zh ? `条件 ${number}` : `Condition ${number}`;
return <div className="trigger-condition-row" role="group" aria-label={prefix}><GripVertical size={14} />
<ConditionFields value={value} catalog={catalog} eventType={eventType} zh={zh} prefix={prefix} onChange={onChange} />
<div className="trigger-row-actions">
<ModuleCopyButton kind="condition" value={value} zh={zh} name={prefix} />
<Button type="button" variant="ghost" disabled={position === 0} onClick={() => onMove(-1)}
aria-label={zh ? `上移条件 ${number}` : `Move condition ${number} up`}><ArrowUp size={14} /></Button>
<Button type="button" variant="ghost" disabled={position === count - 1} onClick={() => onMove(1)}
aria-label={zh ? `下移条件 ${number}` : `Move condition ${number} down`}><ArrowDown size={14} /></Button>
<Button type="button" variant="ghost" onClick={onDelete}
aria-label={zh ? `删除条件 ${number}` : `Delete condition ${number}`}><Trash2 size={14} /></Button>
</div></div>;
}
function ConditionFields({ value, catalog, eventType, zh, prefix, onChange }: {
value: TriggerCondition; catalog: TriggerCatalog; eventType: string; zh: boolean; prefix: string;
onChange: (value: TriggerCondition) => void;
}) {
const operatorGroups = groupConditionOperators(catalog.operators);
const valueKind = conditionValueKind(value.operator);
const fieldDescription = conditionFieldLabel(value.field, zh, catalog);
const fieldWarning = conditionFieldWarning(value.field, value.operator, eventType, catalog, zh);
const warningId = `${prefix.replace(/\s+/g, '-')}-field-warning`;
return <><div className="field"><Input aria-label={`${prefix} ${zh ? '字段' : 'field'}`}
aria-describedby={fieldWarning ? `trigger-condition-help ${warningId}` : 'trigger-condition-help'}
aria-invalid={fieldWarning ? true : undefined} list="trigger-fields" value={value.field}
placeholder={zh ? '选择或输入事件字段' : 'Choose or enter an event field'}
title={`${fieldDescription} · ${value.field}`} onChange={(event) => onChange({ ...value, field: event.target.value })} />
{fieldWarning && <span className="form-error" id={warningId} role="status">{fieldWarning}</span>}</div>
<Select aria-label={`${prefix} ${zh ? '运算符' : 'operator'}`} value={value.operator}
aria-describedby="trigger-condition-help"
onChange={(event) => onChange({ ...value, operator: event.target.value })}>{operatorGroups.map((group) =>
<optgroup key={group.category} label={label(group.labels, zh, group.category)}>{group.operators.map((operator) =>
<option value={operator} key={operator}>{conditionOperatorLabel(operator, zh)}</option>)}</optgroup>)}</Select>
{valueKind !== 'none' && <Input aria-label={`${prefix} ${zh ? '值' : 'value'}`}
aria-describedby="trigger-condition-help" value={value.value}
placeholder={conditionValuePlaceholder(value.operator, value.field, zh)}
onChange={(event) => onChange({ ...value, value: event.target.value })} />}</>;
}
function ActionTreeEditor({ actions, catalog, eventType, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
depth, path, totalNodes, root = false, onChange }: {
actions: TriggerAction[]; catalog: TriggerCatalog; eventType: string; canExecuteCommands: boolean;
defaultSenderName: string; triggerChoices: Array<{ id: string; name: string }>;
menuChoices: Array<{ id: string; name: string }>;
variables: string[]; variableCatalog: readonly TriggerVariableDefinition[];
zh: boolean; depth: number; path: number[];
totalNodes: number; root?: boolean; onChange: (actions: TriggerAction[]) => void;
}) {
const l = (chinese: string, english: string) => zh ? chinese : english;
const containerName = root ? l('根层', 'root') : l(`条件操作 ${path.join('.')} 内`, `condition ${path.join('.')}`);
const appendAction = () => {
const type = actionCompatibleWithEvent('send_player', eventType) ? 'send_player' : 'broadcast';
onChange([...actions, defaultAction(type)]);
};
return <div className={`trigger-action-tree${root ? ' trigger-action-tree--root' : ''}`}>
<div className="trigger-action-tree__toolbar"><span>{root
? l('根层操作', 'Root actions') : l('条件通过后执行', 'Runs when this condition matches')}</span>
<div className="button-row">
<Button type="button" variant="ghost" title={l(`粘贴操作到${containerName}`, `Paste action into ${containerName}`)}
aria-label={l(`粘贴操作到${containerName}`, `Paste action into ${containerName}`)}
onClick={() => void pasteTriggerModule<TriggerAction>('action').then((action) => {
if (action) onChange([...actions, action]);
})}><ClipboardPaste size={14} /></Button>
<Button type="button" variant="ghost" onClick={appendAction}
aria-label={l(`在${containerName}添加操作`, `Add action to ${containerName}`)}><Plus size={14} />{l('添加操作', 'Add action')}</Button>
<Button type="button" variant="ghost"
onClick={() => onChange([...actions, defaultConditionAction()])}
aria-label={l(`在${containerName}添加条件`, `Add condition to ${containerName}`)}><Plus size={14} />{l('添加条件', 'Add condition')}</Button>
</div>
</div>
<div className="trigger-block-list">{actions.map((action, index) => {
const nodePath = [...path, index + 1];
const common = { value: action, catalog, eventType, canExecuteCommands, defaultSenderName,
triggerChoices, menuChoices, variables, variableCatalog, zh, path: nodePath, position: index, count: actions.length,
onChange: (next: TriggerAction) => onChange(replace(actions, index, next)),
onMove: (offset: -1 | 1) => onChange(move(actions, index, offset)),
onDelete: () => onChange(remove(actions, index)) };
return action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE
? <ConditionActionCard key={index} {...common} depth={depth + 1} totalNodes={totalNodes} />
: <ActionCard key={index} {...common} />;
})}
{!actions.length && <div className="trigger-empty is-danger">{root
? l('至少需要一个可执行操作。', 'At least one executable action is required.')
: l('该条件至少需要一个内部操作。', 'This condition needs at least one contained action.')}</div>}
</div>
</div>;
}
interface ActionNodeEditorProps {
value: TriggerAction; catalog: TriggerCatalog; eventType: string; canExecuteCommands: boolean; zh: boolean;
path: number[]; position: number; count: number; defaultSenderName: string; variables: string[];
triggerChoices: Array<{ id: string; name: string }>;
menuChoices: Array<{ id: string; name: string }>;
variableCatalog: readonly TriggerVariableDefinition[];
onChange: (value: TriggerAction) => void; onMove: (offset: -1 | 1) => void; onDelete: () => void;
}
function ConditionActionCard({ value, catalog, eventType, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
path, position, count, depth, totalNodes, onChange, onMove, onDelete }: ActionNodeEditorProps & {
depth: number; totalNodes: number;
}) {
const pathLabel = path.join('.');
const prefix = zh ? `条件操作 ${pathLabel}` : `Action condition ${pathLabel}`;
const condition: TriggerCondition = { field: value.parameters.field ?? '',
operator: value.parameters.operator ?? '', value: value.parameters.value ?? '' };
const children = actionChildren(value);
return <article className="trigger-action-condition" aria-label={prefix}>
<div className="trigger-action-condition__head"><GripVertical size={14} /><span><strong>{prefix}</strong>
<small>{zh ? '判断通过后才执行内部节点' : 'Contained nodes run only when this matches'}</small></span>
<div className="button-row"><ModuleCopyButton kind="action" value={value} zh={zh} name={prefix} />
<ActionMoveButtons zh={zh} name={prefix} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-action-condition__fields">
<ConditionFields value={condition} catalog={catalog} eventType={eventType} zh={zh} prefix={prefix}
onChange={(next) => onChange({ ...value, type: CONDITION_ACTION_TYPE,
parameters: { field: next.field, operator: next.operator, value: next.value }, children })} />
</div>
<ActionTreeEditor actions={children} catalog={catalog} eventType={eventType}
canExecuteCommands={canExecuteCommands} defaultSenderName={defaultSenderName}
triggerChoices={triggerChoices} menuChoices={menuChoices}
variables={variables} variableCatalog={variableCatalog} zh={zh} depth={depth} path={path} totalNodes={totalNodes}
onChange={(next) => onChange({ ...value, children: next })} />
</article>;
}
function ActionCard({ value, catalog, canExecuteCommands, defaultSenderName, triggerChoices, menuChoices, variables, variableCatalog, zh,
path, position, count, onChange, onMove, onDelete }: ActionNodeEditorProps) {
const pathLabel = path.join('.');
const parameters = catalog.actionParameters[value.type] ?? fallbackCatalog.actionParameters[value.type] ?? ['message'];
const visibleParameters = parameters.filter((parameter) => {
if (value.type === 'variable') {
if (['name', 'operation'].includes(parameter)) return true;
const operation = value.parameters.operation ?? 'set';
if (containsWellFormedVariable(operation)) return ['value', 'extra'].includes(parameter);
if (['increment', 'decrement', 'trim', 'upper', 'lower', 'escape_json', 'escape_command',
'escape_regex', 'toggle', 'clear'].includes(operation)) return false;
return parameter === 'value' || (parameter === 'extra'
&& ['replace', 'regex_replace', 'array_insert', 'array_set', 'dictionary_put'].includes(operation));
}
if (value.type !== 'wait') return true;
if (['mode', 'timeout', 'pollTicks'].includes(parameter)) return true;
if (containsWellFormedVariable(value.parameters.mode ?? '')) {
return ['value', 'field', 'operator', 'expected'].includes(parameter);
}
return (value.parameters.mode ?? 'duration') === 'condition'
? ['field', 'operator', 'expected'].includes(parameter) : parameter === 'value';
});
const actionTypes = catalog.actions.includes(value.type) ? catalog.actions : [value.type, ...catalog.actions];
const changeType = (type: string) => onChange({ type, parameters: defaultAction(type).parameters });
const name = zh ? `操作 ${pathLabel}` : `Action ${pathLabel}`;
const parameterHint = ['{player.name}', '{server.online}', '{date}', ...variables].join(' · ');
const declaredVariables = variables.filter((entry) => /^\{(?:var|global)\.[A-Za-z_][A-Za-z0-9_-]*}$/.test(entry))
.map((entry) => ({ value: entry.startsWith('{global.') ? entry.slice(1, -1) : entry.slice(5, -1), token: entry }));
const setParameter = (parameter: string, next: string) => onChange({ ...value,
parameters: { ...value.parameters, [parameter]: next } });
return <article className="trigger-action-card" aria-label={name}>
<div className="trigger-action-card__head"><GripVertical size={14} /><Select aria-label={`${name} ${zh ? '类型' : 'type'}`}
value={value.type} onChange={(event) => changeType(event.target.value)}>
{actionTypes.filter((action) => canExecuteCommands || !commandActions.has(action) || action === value.type)
.map((action) => <option value={action} key={action} disabled={!canExecuteCommands && commandActions.has(action)}>
{label(actionLabels[action], zh, action)}{!canExecuteCommands && commandActions.has(action) ? ` · Owner` : ''}
</option>)}</Select>
<div className="button-row"><ModuleCopyButton kind="action" value={value} zh={zh} name={name} />
<ActionMoveButtons zh={zh} name={name} position={position} count={count}
onMove={onMove} onDelete={onDelete} /></div></div>
<div className="trigger-action-card__body">{visibleParameters.map((parameter) => <Field key={parameter} label={actionParameterLabel(parameter, zh)}
hint={parameter === 'message' || parameter === 'command' ? parameterHint : undefined}>
{parameter === 'message' && value.type !== 'log' ? <RichTextEditor compact value={value.parameters[parameter] ?? ''}
defaultSenderName={defaultSenderName} variables={variables} variableCatalog={variableCatalog}
onChange={(next) => setParameter(parameter, next)} />
: value.type === 'variable' && parameter === 'name' ? <Select value={value.parameters.name ?? ''}
onChange={(event) => setParameter('name', event.target.value)}><option value="">{zh ? '选择已声明变量' : 'Select a declared variable'}</option>
{declaredVariables.map((entry) => <option value={entry.value} key={entry.token}>{entry.token}</option>)}</Select>
: value.type === 'variable' && parameter === 'operation' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-operation`} parameter={parameter} zh={zh}
value={value.parameters.operation ?? 'set'} variables={variables} variableCatalog={variableCatalog}
choices={variableOperations.map((operation) => ({ value: operation, label: variableOperationLabel(operation, zh) }))}
onValueChange={(next) => setParameter('operation', next)} />
: value.type === 'wait' && parameter === 'mode' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-mode`} parameter={parameter} zh={zh}
value={value.parameters.mode ?? 'duration'} variables={variables} variableCatalog={variableCatalog}
choices={waitModes.map((mode) => ({ value: mode, label: waitModeLabel(mode, zh) }))}
onValueChange={(next) => setParameter('mode', next)} />
: value.type === 'wait' && parameter === 'operator' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-operator`} parameter={parameter} zh={zh}
value={value.parameters.operator ?? 'eq'} variables={variables} variableCatalog={variableCatalog}
choices={catalog.operators.map((operator) => ({ value: operator, label: conditionOperatorLabel(operator, zh) }))}
onValueChange={(next) => setParameter('operator', next)} />
: value.type === 'run_trigger' && parameter === 'triggerId' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-trigger`} parameter={parameter} zh={zh}
value={value.parameters.triggerId ?? ''} variables={variables} variableCatalog={variableCatalog}
choices={triggerChoices.filter((trigger) => trigger.id)
.map((trigger) => ({ value: trigger.id, label: `${trigger.name} · ${trigger.id}` }))}
onValueChange={(next) => setParameter('triggerId', next)} />
: value.type === 'open_menu' && parameter === 'menuId' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-menu`} parameter={parameter} zh={zh}
value={value.parameters.menuId ?? ''} variables={variables} variableCatalog={variableCatalog}
choices={menuChoices.filter((menu) => menu.id)
.map((menu) => ({ value: menu.id, label: `${menu.name} · ${menu.id}` }))}
onValueChange={(next) => setParameter('menuId', next)} />
: value.type === 'run_trigger' && parameter === 'waitForCompletion' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-completion`} parameter={parameter} zh={zh}
value={value.parameters.waitForCompletion ?? 'false'} variables={variables} variableCatalog={variableCatalog}
choices={[{ value: 'false', label: zh ? '启动后继续' : 'Continue after starting' },
{ value: 'true', label: zh ? '等待执行完成' : 'Wait for completion' }]}
onValueChange={(next) => setParameter('waitForCompletion', next)} />
: ['server_command', 'player_command'].includes(value.type) && parameter === 'showFeedback'
? <VariableChoiceInput id={`trigger-choice-${path.join('-')}-feedback`}
parameter={parameter} zh={zh} value={value.parameters.showFeedback ?? 'false'}
variables={variables} variableCatalog={variableCatalog}
choices={[{ value: 'false', label: zh ? '关闭(推荐)' : 'Off (recommended)' },
{ value: 'true', label: zh ? '显示' : 'Show' }]}
onValueChange={(next) => setParameter('showFeedback', next)} />
: value.type === 'title' && parameter === 'numberPrecision' ? <VariableChoiceInput
id={`trigger-choice-${path.join('-')}-number-precision`} parameter={parameter} zh={zh}
value={value.parameters.numberPrecision ?? '2'} variables={variables}
variableCatalog={variableCatalog}
choices={Array.from({ length: 11 }, (_, digits) => ({
value: String(digits), label: `${digits} ${zh ? '位小数' : 'fraction digits'}`,
}))}
onValueChange={(next) => setParameter('numberPrecision', next)} />
: <VariableTextInput parameter={parameter} zh={zh}
list={parameter === 'currency' ? 'trigger-economy-currencies' : undefined}
inputMode={['volume', 'pitch'].includes(parameter) ? 'decimal'
: parameter === 'amount' ? 'decimal'
: ['fadeIn', 'stay', 'fadeOut', 'count', 'maxCount', 'duration', 'amplifier'].includes(parameter)
? 'numeric' : undefined}
spellCheck={!['command', 'destination', 'item', 'sound', 'effect', 'player', 'source', 'target'].includes(parameter)}
value={value.parameters[parameter] ?? ''} variables={variables} variableCatalog={variableCatalog}
onValueChange={(next) => setParameter(parameter, next)} />}
</Field>)}</div>
</article>;
}
function VariableChoiceInput({ id, value, parameter, choices, variables, variableCatalog, zh, onValueChange }: {
id: string; value: string; parameter: string; choices: Array<{ value: string; label: string }>;
variables: string[]; variableCatalog: readonly TriggerVariableDefinition[]; zh: boolean;
onValueChange: (value: string) => void;
}) {
return <><VariableTextInput list={id} parameter={parameter} value={value} variables={variables}
variableCatalog={variableCatalog} zh={zh} onValueChange={onValueChange} />
<datalist id={id}>{choices.map((choice) => <option key={choice.value}
value={choice.value} label={choice.label} />)}</datalist></>;
}
function ActionMoveButtons({ zh, name, position, count, onMove, onDelete }: {
zh: boolean; name: string; position: number; count: number;
onMove: (offset: -1 | 1) => void; onDelete: () => void;
}) {
return <div className="trigger-row-actions">
<Button type="button" variant="ghost" disabled={position === 0} onClick={() => onMove(-1)}
aria-label={zh ? `上移${name}` : `Move ${name} up`}><ArrowUp size={14} /></Button>
<Button type="button" variant="ghost" disabled={position === count - 1} onClick={() => onMove(1)}
aria-label={zh ? `下移${name}` : `Move ${name} down`}><ArrowDown size={14} /></Button>
<Button type="button" variant="ghost" onClick={onDelete}
aria-label={zh ? `删除${name}` : `Delete ${name}`}><Trash2 size={14} /></Button>
</div>;
}
function regionConfiguration(shape: string): Record<string, string> {
const shared = { regionId: 'example.region', shape, dimension: 'minecraft:overworld', frequencyTicks: '20' };
if (shape === 'sphere') return { ...shared, centerX: '0', centerY: '64', centerZ: '0', radius: '8' };
if (shape === 'cylinder') return { ...shared, centerX: '0', centerZ: '0', radius: '8', minY: '0', maxY: '320' };
return { ...shared, shape: 'cuboid', minX: '-8', minY: '0', minZ: '-8', maxX: '8', maxY: '320', maxZ: '8' };
}
function defaultV2EventConfiguration(type: string): Record<string, string> {
if (type.startsWith('region.')) return regionConfiguration('cuboid');
return defaultEvent(type).configuration;
}
function defaultEvent(type: string): TriggerEventSpec {
if (type === 'schedule.daily') return { type, configuration: { time: '08:00', timezone: 'Asia/Shanghai' } };
if (type === 'schedule.interval') return { type, configuration: { seconds: '60' } };
if (type.startsWith('protection.')) {
const thresholds: Record<string, string> = {
'protection.item_overflow': '2000', 'protection.mob_overflow': '2000',
'protection.entity_overflow': '5000', 'protection.mod_entity_overflow': '512',
'protection.spawn_burst': '400', 'protection.command_block_rate': '100',
'protection.slow_tick': '200', 'protection.loaded_chunk_overflow': '8000',
'protection.memory_pressure': '90',
};
const configuration: Record<string, string> = {
threshold: thresholds[type] ?? '1', cooldownSeconds: '60',
};
if (['protection.item_overflow', 'protection.mob_overflow',
'protection.entity_overflow'].includes(type)) configuration.scope = 'dimension';
if (type === 'protection.mod_entity_overflow') configuration.namespace = 'create';
if (type === 'protection.slow_tick') configuration.consecutive = '3';
return { type, configuration };
}
if (type === 'player.command_trigger') return { type, configuration: { command: '' }, arguments: [] };
return { type, configuration: {} };
}
function defaultConditionAction(): TriggerAction {
return { type: CONDITION_ACTION_TYPE,
parameters: { field: 'player.name', operator: 'eq', value: '' }, children: [] };
}
function actionChildren(action: TriggerAction): TriggerAction[] {
return Array.isArray(action.children) ? action.children : [];
}
function actionLeaves(actions: TriggerAction[]): TriggerAction[] {
return actions.flatMap((action) => action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE
? actionLeaves(actionChildren(action)) : [action, ...actionLeaves(actionChildren(action))]);
}
function countActionNodes(actions: TriggerAction[]): number {
return actions.reduce((total, action) => total + 1 + countActionNodes(actionChildren(action)), 0);
}
function mapActionTree(actions: TriggerAction[], transform: (action: TriggerAction) => TriggerAction): TriggerAction[] {
return actions.map((action) => {
if (action.type.trim().toLowerCase() !== CONDITION_ACTION_TYPE) return transform(action);
return { ...action, children: mapActionTree(actionChildren(action), transform) };
});
}
function commandArgumentNames(event: TriggerEventSpec): string[] {
if (event.type.trim().toLowerCase() !== 'player.command_trigger') return [];
if ((event.arguments?.length ?? 0) > 0) return flattenCommandArguments(event.arguments ?? []).map((item) => item.name);
const raw = event.configuration.arguments ?? '';
return raw.trim() ? raw.trim().split(/\s+/) : [];
}
function commandArgumentTree(event: TriggerEventSpec): TriggerCommandArgument[] {
if ((event.arguments?.length ?? 0) > 0) return event.arguments ?? [];
const legacy = (event.configuration.arguments ?? '').trim();
if (!legacy) return [];
let children: TriggerCommandArgument[] = [];
legacy.split(/\s+/).reverse().forEach((name) => {
children = [{ ...defaultCommandArgument(1), name, children }];
});
return children;
}
function flattenCommandArguments(values: TriggerCommandArgument[]): TriggerCommandArgument[] {
return values.flatMap((value) => [value, ...flattenCommandArguments(value.children ?? [])]);
}
function duplicateCommandArgumentNames(values: TriggerCommandArgument[]): Set<string> {
const counts = new Map<string, number>();
flattenCommandArguments(values).forEach((value) => counts.set(value.name, (counts.get(value.name) ?? 0) + 1));
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name));
}
function countCommandArgumentNodes(values: TriggerCommandArgument[]): number {
return values.reduce((total, value) => total + 1 + countCommandArgumentNodes(value.children ?? []), 0);
}
function defaultCommandArgument(index: number): TriggerCommandArgument {
return { name: index === 1 ? 'arg' : `arg${index}`, type: 'word', literal: '', optional: false,
errorMessage: '', minimum: '', maximum: '', suggestions: [], children: [] };
}
function commandArgumentPreview(values: TriggerCommandArgument[]): string {
if (!values.length) return '';
return ` ${values.map((value) => value.type === 'literal' ? value.literal || 'literal'
: `${value.optional ? '[' : '<'}${value.name || 'arg'}${value.type === 'word' ? '' : `:${value.type}`}${value.optional ? ']' : '>'}`
+ commandArgumentPreview(value.children ?? [])).join(' | ')}`;
}
const commandArgumentTypeLabels: Record<string, [string, string]> = {
literal: ['固定字面量', 'Literal'], bool: ['布尔值', 'Boolean'], integer: ['整型', 'Integer'],
long: ['长整型', 'Long integer'],
float: ['浮点数(兼容整型)', 'Float (accepts integers)'], double: ['双精度浮点数', 'Double'],
coordinate: ['单个坐标', 'Coordinate'], block_pos: ['方块坐标', 'Block position'],
column_pos: ['二维方块坐标', 'Column position'], vec2: ['二维坐标', '2D vector'], vec3: ['三维坐标', '3D vector'],
rotation: ['旋转角度', 'Rotation'], angle: ['角度', 'Angle'], string: ['可引号字符串', 'Quoted string'],
word: ['单词字符串', 'Single word'], greedy_string: ['剩余字符串', 'Greedy string'],
player: ['单个玩家', 'Single player'], players: ['多个玩家', 'Players'], entity: ['单个实体', 'Single entity'],
entities: ['多个实体', 'Entities'], game_profile: ['游戏档案', 'Game profile'],
block_state: ['方块状态', 'Block state'], block_predicate: ['方块条件', 'Block predicate'],
item_stack: ['物品堆', 'Item stack'], item_predicate: ['物品条件', 'Item predicate'],
color: ['聊天颜色', 'Chat color'], component: ['文本组件', 'Text component'], message: ['聊天消息', 'Message'],
nbt: ['NBT 复合标签', 'NBT compound'], nbt_tag: ['NBT 标签', 'NBT tag'], compound_tag: ['NBT 复合标签', 'Compound NBT tag'], nbt_path: ['NBT 路径', 'NBT path'],
objective: ['计分板目标', 'Objective'], objective_criteria: ['计分准则', 'Objective criteria'],
operation: ['计分操作', 'Score operation'], score_holder: ['计分持有者', 'Score holder'],
scoreboard_slot: ['计分板显示槽', 'Scoreboard slot'], swizzle: ['坐标轴组合', 'Axes/swizzle'], team: ['队伍', 'Team'],
int_range: ['整数区间', 'Integer range'], float_range: ['浮点区间', 'Float range'],
particle: ['粒子', 'Particle'], resource_location: ['资源位置', 'Resource location'], resource: ['注册表资源', 'Registry resource'],
resource_key: ['资源键', 'Resource key'], resource_or_tag: ['资源或标签', 'Resource or tag'],
resource_or_tag_key: ['资源键或标签键', 'Resource/tag key'], dimension: ['维度', 'Dimension'],
gamemode: ['游戏模式', 'Game mode'], time: ['时间', 'Time'], uuid: ['UUID', 'UUID'], function: ['函数', 'Function'],
entity_anchor: ['实体锚点', 'Entity anchor'], enchantment: ['附魔', 'Enchantment'], mob_effect: ['状态效果', 'Mob effect'],
item_slot: ['物品栏槽位', 'Item slot'], item_slots: ['物品栏槽位集合', 'Item slots'], template_mirror: ['结构镜像', 'Template mirror'],
template_rotation: ['结构旋转', 'Template rotation'], heightmap: ['高度图', 'Heightmap'],
loot_table: ['战利品表', 'Loot table'], loot_predicate: ['战利品条件', 'Loot predicate'],
loot_modifier: ['战利品修改器', 'Loot modifier'], biome: ['生物群系', 'Biome'], structure: ['结构', 'Structure'],
advancement: ['进度', 'Advancement'], recipe: ['配方', 'Recipe'],
};
function commandArgumentTypeLabel(type: string, zh: boolean): string {
return `${label(commandArgumentTypeLabels[type], zh, type)} · ${type}`;
}
function eventFieldSuggestions(event: TriggerEventSpec, catalog: TriggerCatalog): string[] {
const dynamic = commandArgumentNames(event).filter((argument) => commandPartPattern.test(argument))
.map((argument) => `args.${argument}`);
const triggerVariables = (event.variables ?? []).filter((variable) => triggerVariableNameIsValid(variable.name))
.map((variable) => (variable.visibility ?? 'trigger') === 'global'
? `global.${variable.name}` : `var.${variable.name}`);
if (!catalog.variables) return [...new Set([...baseFieldSuggestions, ...dynamic, ...triggerVariables])];
const catalogFields = catalog.variables
.filter((variable) => variable.sensitive !== true && variable.conditionAllowed !== false
&& ordinaryContextKey(variable.key) && variableAppliesToEvent(variable, event.type))
.map((variable) => normalizedVariableKey(variable.key));
return [...new Set([...catalogFields, ...dynamic, ...triggerVariables])];
}
function defaultAction(type: string): TriggerAction {
if (type === 'title') return { type, parameters: {
title: 'Welcome!', subtitle: '', fadeIn: '10', stay: '70', fadeOut: '20' } };
if (type === 'sound') return { type, parameters: { sound: 'minecraft:entity.experience_orb.pickup', volume: '1', pitch: '1' } };
if (type === 'server_command' || type === 'player_command') {
return { type, parameters: { command: '', showFeedback: 'false' } };
}
if (type === 'teleport') return { type, parameters: { destination: '0 80 0' } };
if (type === 'give_item') return { type, parameters: { item: 'minecraft:bread', count: '1' } };
if (type === 'clear_inventory') return { type, parameters: { item: '', maxCount: '' } };
if (type === 'set_gamemode') return { type, parameters: { gamemode: 'survival' } };
if (type === 'add_effect') return { type, parameters: { effect: 'minecraft:speed', duration: '30', amplifier: '0' } };
if (['remove_effects', 'heal', 'feed', 'whitelist_add', 'whitelist_remove', 'pardon'].includes(type)) return { type, parameters: {} };
if (type === 'set_time') return { type, parameters: { time: 'day' } };
if (type === 'set_weather') return { type, parameters: { weather: 'clear', duration: '300' } };
if (type === 'ban') return { type, parameters: { reason: '' } };
if (type === 'log') return { type, parameters: { message: '', level: 'info' } };
if (type === 'variable') return { type, parameters: { name: '', operation: 'set', value: '', extra: '' } };
if (type === 'wait') return { type, parameters: { mode: 'duration', value: '1', timeout: '0', pollTicks: '20',
field: 'var.value', operator: 'eq', expected: 'true' } };
if (type === 'run_trigger') return { type, parameters: { triggerId: '', waitForCompletion: 'false' } };
if (type === 'open_menu') return { type, parameters: { menuId: '' } };
if (type === 'close_menu') return { type, parameters: {} };
if (['economy_deposit', 'economy_withdraw'].includes(type)) return { type, parameters: { currency: 'coins', amount: '1', reason: 'Trigger economy operation' } };
if (type === 'economy_set_balance') return { type, parameters: { currency: 'coins', amount: '0', reason: 'Trigger economy operation' } };
if (type === 'economy_transfer') return { type, parameters: { currency: 'coins', amount: '1', target: '', reason: 'Trigger economy transfer' } };
if (['economy_deposit_player', 'economy_withdraw_player'].includes(type)) return { type,
parameters: { player: '', currency: 'coins', amount: '1', reason: 'Trigger global economy operation' } };
if (type === 'economy_set_player_balance') return { type,
parameters: { player: '', currency: 'coins', amount: '0', reason: 'Trigger global economy operation' } };
if (type === 'economy_transfer_players') return { type,
parameters: { source: '', target: '', currency: 'coins', amount: '1', reason: 'Trigger global economy transfer' } };
return { type, parameters: { message: '' } };
}
function eventLabel(event: string, zh: boolean): string { return label(eventLabels[event], zh, event); }
function eventDescription(event: string, zh: boolean): string {
return label(eventDescriptions[event], zh, zh
? `当“${eventLabel(event, true)}”发生时执行此触发器;可用字段取决于事件上下文。`
: `Runs when “${eventLabel(event, false)}” occurs; available fields depend on the event context.`);
}
function actionLabel(action: string, zh: boolean): string { return label(actionLabels[action], zh, action); }
// Describe the program, never evaluate it: references and templates remain visible until execution.
function expressionPreview(expression: TriggerExpression | undefined, catalog: TriggerCatalog, zh: boolean, depth = 0): string {
const l = (chinese: string, english: string) => zh ? chinese : english;
if (!expression) return l('未设置表达式', 'Expression not set');
if (depth > 32) return l('…(嵌套过深)', '… (nesting limit)');
const argumentsList = expression.arguments ?? [];
const argument = (index: number) => expressionPreview(argumentsList[index], catalog, zh, depth + 1);
const reference = (name: string) => {
const key = normalizedVariableKey(name);
const known = catalog.variables?.some((entry) => normalizedVariableKey(entry.key) === key)
|| conditionFieldLabels[key] || key.startsWith('args.');
return known ? `${conditionFieldLabel(key, zh, catalog)} {${key}}` : `{${key}}`;
};
const literal = (value: unknown): string => {
if (typeof value === 'string') return value === '' ? l('空文本 ""', 'Empty text ""')
: `“${richTextPlainText(value)}”`;
if (value == null) return l('空值(null)', 'Null');
if (typeof value === 'boolean') return value ? l('是(true)', 'True') : l('否(false)', 'False');
return typeof value === 'object' ? JSON.stringify(value) : String(value);
};
const name = expression.name.toLowerCase();
switch (expression.kind) {
case 'LITERAL': return literal(expression.literal);
case 'REFERENCE': return reference(expression.name);
case 'UNARY': return ['not', '!'].includes(name) ? `${l('非', 'Not')} (${argument(0)})`
: ['negate', '-'].includes(name) ? `−(${argument(0)})` : `${expression.name}(${argument(0)})`;
case 'BINARY': {
const operator = ({ and: l('并且', 'AND'), '&&': l('并且', 'AND'), or: l('或者', 'OR'), '||': l('或者', 'OR'),
add: '+', subtract: '−', multiply: '×', divide: '÷', mod: '%',
equals: l('等于', 'equals'), '==': l('等于', 'equals'), not_equals: l('不等于', 'does not equal'),
'!=': l('不等于', 'does not equal'), '>': l('大于', 'is greater than'), '>=': l('大于或等于', 'is at least'),
'<': l('小于', 'is less than'), '<=': l('小于或等于', 'is at most'), in: l('属于', 'is in'),
} as Record<string, string>)[name] ?? expression.name;
return `(${argument(0)} ${operator} ${argument(1)})`;
}
case 'FUNCTION': {
const legacy = argumentsList[0]?.kind === 'LITERAL' ? argumentsList[0].literal : undefined;
if (name === 'legacy_condition' && legacy && typeof legacy === 'object'
&& 'field' in legacy && 'operator' in legacy) {
const condition = legacy as { field: string; operator: string; value?: unknown };
const operator = String(condition.operator);
return `${reference(String(condition.field))} ${conditionOperatorLabel(operator, zh)}`
+ (conditionValueKind(operator) === 'none' ? '' : ` ${literal(condition.value ?? '')}`);
}
const descriptor = catalog.descriptors?.find((entry) => ['VALUE_FUNCTION', 'CONDITION_FUNCTION'].includes(entry.kind)
&& entry.id === expression.name);
const title = descriptor ? descriptorName(descriptor, catalog, zh) : label(functionLabels[name], zh, expression.name);
return `${title}(${argumentsList.map((_, index) => argument(index)).join(', ')})`;
}
case 'INDEX': return `${argument(0)}[${argument(1)}]`;
case 'COALESCE': return `${l('首个非空值', 'First non-null')}(${argumentsList.map((_, index) => argument(index)).join(', ')})`;
case 'CONVERT': return `${l('转换为', 'Convert to')} ${label(triggerTypeLabels[expression.name], zh, expression.name)}(${argument(0)})`;
default: return expression.name || l('未知表达式', 'Unknown expression');
}
}
function statementPreview(statement: TriggerStatement, catalog: TriggerCatalog, zh: boolean): { title: string; details: string[] } {
const l = (chinese: string, english: string) => zh ? chinese : english;
const expression = expressionPreview(statement.expression, catalog, zh);
const parameters = () => Object.entries(statement.inputs).map(([name, value]) =>
`${statement.kind === 'ACTION' ? actionParameterLabel(name, zh) : name}:${expressionPreview(value, catalog, zh)}`);
switch (statement.kind) {
case 'ACTION': {
const descriptor = catalog.descriptors?.find((entry) => entry.kind === 'ACTION' && entry.id === statement.name);
return { title: descriptor ? descriptorName(descriptor, catalog, zh) : actionLabel(statement.name, zh), details: parameters() };
}
case 'IF': return { title: `${l('如果', 'If')} ${expression}`, details: [] };
case 'SWITCH': return { title: `${l('按此值匹配分支', 'Match branches against')}:${expression}`, details: [] };
case 'REPEAT': return { title: zh ? `重复 ${expression} 次` : `Repeat ${expression} times`, details: [] };
case 'WHILE': return { title: `${l('当以下条件成立时循环', 'Repeat while')}:${expression}`, details: [] };
case 'FOREACH': return { title: `${statement.name} ← ${l('逐项遍历', 'each item in')} ${expression}`, details: [] };
case 'SET': return { title: `${statement.name} ← ${expression}`, details: [] };
case 'CALL': return { title: `${l('调用', 'Call')} ${statement.name}`, details: parameters() };
case 'RETURN': return { title: statement.expression ? `${l('返回', 'Return')} ${expression}` : l('结束当前过程', 'End this procedure'), details: [] };
case 'TRY': return { title: l('尝试执行;失败时转入错误分支', 'Try the body; run the error branch on failure'), details: [] };
case 'BREAK': return { title: l('退出当前循环', 'Exit the current loop'), details: [] };
case 'CONTINUE': return { title: l('跳过本轮,继续下一轮循环', 'Skip to the next loop iteration'), details: [] };
}
}
function actionDescription(action: string, zh: boolean): string {
return label(actionDescriptions[action], zh, zh
? `执行“${actionLabel(action, true)}”并记录到本次执行追踪。`
: `Performs “${actionLabel(action, false)}” and records it in the execution trace.`);
}
function label(value: [string, string] | undefined, zh: boolean, fallback: string): string { return value ? value[zh ? 0 : 1] : fallback; }
function executionStatusLabel(status: string, zh: boolean): string {
return label(executionStatusLabels[status.toUpperCase()], zh, status);
}
function traceKindLabel(kind: string, zh: boolean): string {
return label(traceKindLabels[kind.toUpperCase()], zh, kind);
}
type TriggerCatalogDescriptor = NonNullable<TriggerCatalog['descriptors']>[number];
function responseVariable(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog) {
return catalog.variables?.find((variable) => `response.${variable.key.toLowerCase()
.replace(/[^a-z0-9_.:_-]/g, '_')}` === descriptor.id);
}
function descriptorName(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog, zh: boolean): string {
const localized = descriptor.metadata?.[zh ? 'displayNameZh' : 'displayNameEn'];
if (typeof localized === 'string' && localized.trim()) return localized;
if (descriptor.kind === 'EVENT') return eventLabel(descriptor.id, zh);
if (descriptor.kind === 'ACTION') return actionLabel(descriptor.id, zh);
if (descriptor.kind === 'EVENT_RESPONSE') {
const variable = responseVariable(descriptor, catalog);
if (variable) return zh ? variable.nameZh : variable.nameEn;
}
if (descriptor.kind === 'TYPE') return label(triggerTypeLabels[descriptor.id], zh, descriptor.displayName);
if (descriptor.kind === 'CONDITION_FUNCTION' && descriptor.id.startsWith('compare.')) {
return conditionOperatorLabel(descriptor.id.slice('compare.'.length), zh);
}
if (descriptor.kind === 'VALUE_FUNCTION' || descriptor.kind === 'CONDITION_FUNCTION') {
return label(functionLabels[descriptor.id], zh, descriptor.displayName);
}
return descriptor.displayName;
}
function descriptorDescription(descriptor: TriggerCatalogDescriptor, catalog: TriggerCatalog, zh: boolean): string {
const localized = descriptor.metadata?.[zh ? 'descriptionZh' : 'descriptionEn'];
if (typeof localized === 'string' && localized.trim()) return localized;
if (descriptor.kind === 'EVENT') return eventDescription(descriptor.id, zh);
if (descriptor.kind === 'ACTION') return actionDescription(descriptor.id, zh);
if (descriptor.kind === 'EVENT_RESPONSE') {
const variable = responseVariable(descriptor, catalog);
if (variable) return zh ? variable.descriptionZh : variable.descriptionEn;
}
if (descriptor.kind === 'TYPE') return label(triggerTypeDescriptions[descriptor.id], zh, zh
? `${descriptorName(descriptor, catalog, true)}类型;技术类型名为 ${descriptor.id}。`
: `${descriptorName(descriptor, catalog, false)} value type; technical type name: ${descriptor.id}.`);
if (descriptor.kind === 'CONDITION_FUNCTION' && descriptor.id.startsWith('compare.')) return zh
? `使用强类型“${descriptorName(descriptor, catalog, true)}”条件比较两个表达式。`
: `Compares two expressions with the typed “${descriptorName(descriptor, catalog, false)}” condition.`;
if (descriptor.kind === 'VALUE_FUNCTION' || descriptor.kind === 'CONDITION_FUNCTION') {
return label(functionDescriptions[descriptor.id], zh, zh
? `在不可变事件快照上计算“${descriptorName(descriptor, catalog, true)}”,不会修改世界。`
: `Calculates “${descriptorName(descriptor, catalog, false)}” on an immutable event snapshot without changing the world.`);
}
return descriptor.description;
}
function conditionFieldLabel(field: string, zh: boolean, catalog?: TriggerCatalog): string {
const catalogVariable = catalog?.variables?.find((variable) =>
normalizedVariableKey(variable.key) === field.trim());
if (catalogVariable) return zh ? catalogVariable.nameZh : catalogVariable.nameEn;
const known = conditionFieldLabels[field];
if (known) return label(known, zh, field);
if (field.startsWith('args.') && field.length > 'args.'.length) {
const argument = field.slice('args.'.length);
return zh ? `指令参数 ${argument}` : `Command argument ${argument}`;
}
return zh ? '扩展事件字段' : 'Extension event field';
}
function normalizedVariableKey(key: string): string {
const trimmed = key.trim();
return trimmed.startsWith('{') && trimmed.endsWith('}') ? trimmed.slice(1, -1).trim() : trimmed;
}
function ordinaryContextKey(key: string): boolean {
return /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(normalizedVariableKey(key));
}
function conditionFieldWarning(field: string, operator: string, eventType: string,
catalog: TriggerCatalog, zh: boolean): string | undefined {
const normalized = field.trim();
if (!normalized) return undefined;
if (normalized.startsWith('args.')) {
// An args field outside a command trigger is unambiguously unavailable. A command trigger may
// deliberately receive additional argument keys from an extension, so it remains unrestricted.
if (eventType.trim().toLowerCase() !== 'player.command_trigger') return zh
? '指令参数仅在“玩家输入自定义指令”事件中可用;此字段仍会保留并允许保存。'
: 'Command arguments are only available to the custom player-command event. This field is retained and may still be saved.';
}
const known = catalog.variables?.find((variable) => normalizedVariableKey(variable.key) === normalized);
const formattedTimeTemplate = /^(?:server|event)\.time:/i.test(normalized);
if (formattedTimeTemplate || known?.conditionAllowed === false || (known && !ordinaryContextKey(known.key))) return zh
? '这是消息模板变量,不能作为条件字段;请选择对应的普通字段(例如 server.time)。'
: 'This is a message-template variable, not a condition field. Choose its ordinary field instead (for example, server.time).';
if (!known || variableAppliesToEvent(known, eventType)) return undefined;
const legacyNegative = ['neq', 'not_contains', 'not_in'].includes(operator.trim().toLowerCase());
if (zh) return legacyNegative
? '此字段在当前事件中通常不存在;为兼容旧触发器,该负向判断仍可能成立。建议先添加“字段存在”条件。仍可保存供模组扩展使用。'
: '此字段在当前事件中通常不可用。仍可保存供模组扩展上下文使用。';
return legacyNegative
? 'This field is normally absent from the selected event. For legacy compatibility this negative test may still match; add a “Field exists” condition first. It may still be saved for mod extensions.'
: 'This field is not normally available to the selected event. It may still be saved for a mod-provided context.';
}
function conditionValueKind(operator: string): ConditionValueKind {
return conditionOperatorMetadata[operator.trim().toLowerCase()]?.valueKind ?? 'text';
}
function conditionOperatorLabel(operator: string, zh: boolean): string {
const normalized = operator.trim().toLowerCase();
const metadata = conditionOperatorMetadata[normalized];
if (metadata) return label(metadata.labels, zh, operator);
const readable = normalized.split(/[_-]+/).filter(Boolean).join(' ') || operator;
if (zh) return `扩展判断:${readable}`;
return `Extension operator: ${readable.replace(/\b\w/g, (character) => character.toUpperCase())}`;
}
function groupConditionOperators(operators: string[]) {
return conditionOperatorCategories.map((group) => ({ ...group,
operators: operators.filter((operator) =>
(conditionOperatorMetadata[operator.trim().toLowerCase()]?.category ?? 'extension') === group.category),
})).filter((group) => group.operators.length > 0);
}
function conditionValuePlaceholder(operator: string, field: string, zh: boolean): string {
const kind = conditionValueKind(operator);
if (kind === 'number') return zh ? '输入有限数字,例如 10' : 'Enter a finite number, for example 10';
if (kind === 'range') return zh
? '下限, 上限(含边界),例如 1, 10'
: 'Lower bound, upper bound (inclusive), for example 1, 10';
if (kind === 'list') return zh
? '用英文逗号分隔,例如 Alex, Steve'
: 'Separate values with commas, for example Alex, Steve';
if (kind === 'regex') return zh
? '输入 Java 正则表达式,例如 ^Alex$'
: 'Enter a Java regular expression, for example ^Alex$';
if (booleanConditionFields.has(field)) return zh ? '布尔值:true 或 false' : 'Boolean value: true or false';
if (numericConditionFields.has(field)) return zh ? '输入数值,例如 10' : 'Enter a number, for example 10';
if (field === 'date') return zh ? '日期,例如 2026-09-04' : 'Date, for example 2026-09-04';
return zh ? '输入要比较的值' : 'Enter the value to compare';
}
function eventProvidesPlayerContext(type: string): boolean {
const normalized = type.trim().toLowerCase();
return normalized.startsWith('player.') || normalized === 'block.break' || normalized === 'block.place'
|| normalized === 'block.tool_modify' || normalized.startsWith('custom.') || normalized.startsWith('menu.')
|| normalized.startsWith('economy.');
}
function actionCompatibleWithEvent(action: string, event: string): boolean {
const normalizedAction = action.trim().toLowerCase();
const normalizedEvent = event.trim().toLowerCase();
if (playerContextActions.has(normalizedAction) && !eventProvidesPlayerContext(normalizedEvent)) return false;
return normalizedEvent !== 'player.leave' || !onlinePlayerActions.has(normalizedAction);
}
function triggerUsesCommandAction(value: TriggerDefinition): boolean {
return actionLeaves(value.actions).some((action) => commandActions.has(action.type.trim().toLowerCase()))
|| [...(value.statements ?? []), ...(value.functions ?? []).flatMap((entry) => entry.statements)]
.some((statement) => statementTreeContains(statement,
(entry) => entry.kind === 'ACTION' && commandActions.has(entry.name.trim().toLowerCase())))
|| (value.mode === 'code' && /^\s*do\s+(?:server_command|player_command)\b/im.test(value.script));
}
function triggerIsSavable(value: TriggerDefinition, canExecuteCommands: boolean, catalog: TriggerCatalog): boolean {
const name = value.name.trim();
if (!name || name.length > 120 || value.description.trim().length > 500) return false;
if (!canExecuteCommands && triggerUsesCommandAction(value)) return false;
const v2 = triggerV2Program(value);
if (v2) return v2.events.length > 0 && v2.events.length <= 32
&& v2.events.every((event) => Boolean(event.nodeId && event.type.trim()))
&& uniqueNodeIds(v2).size === countV2Nodes(v2);
if (value.mode === 'code') return Boolean(value.script.trim()) && value.script.length <= 65_536;
const eventType = value.event.type.trim().toLowerCase();
const builtInEvent = catalog.events.some((event) => event.toLowerCase() === eventType)
&& eventType !== 'custom';
const eventValid = eventType.length > 0 && eventType.length <= 80
&& (builtInEvent || /^custom\.[a-z0-9_.-]+$/.test(eventType));
return eventValid && eventConfigurationIsValid(value.event)
&& value.conditions.every((condition) => conditionIsValid(condition, catalog))
&& actionTreeIsValid(value.actions, value.event.type, catalog);
}
function eventConfigurationIsValid(event: TriggerEventSpec): boolean {
const variables = event.variables ?? [];
if (variables.some((variable, index) => !triggerVariableNameIsValid(variable.name)
|| variables.some((other, current) => current !== index && other.name === variable.name
&& (other.visibility ?? 'trigger') === (variable.visibility ?? 'trigger'))
|| !['trigger', 'global'].includes(variable.visibility ?? 'trigger')
|| !['server', 'player', 'dimension', 'trigger', 'execution', 'chunk', 'entity'].includes(variable.storage
?? ((variable.visibility ?? 'trigger') === 'global' ? 'server' : 'trigger'))
|| !['session', 'ttl', 'persistent'].includes(variable.lifetime ?? 'session')
|| (variable.lifetime ?? 'session') === 'ttl'
&& (!Number.isInteger(variable.ttlSeconds ?? 0) || (variable.ttlSeconds ?? 0) < 1
|| (variable.ttlSeconds ?? 0) > 31_536_000)
|| !variableTypeIsValid(variable.type)
|| !triggerVariableInitialValueIsValid(variable))) return false;
const eventType = event.type.trim().toLowerCase();
const keys = Object.keys(event.configuration);
if (keys.length > 16 || Object.entries(event.configuration).some(([key, entry]) =>
!key.trim() || key.trim().length > 80 || entry.length > 4_096)) return false;
if (eventType === 'schedule.daily') {
if (keys.some((key) => !['time', 'timezone'].includes(key))) return false;
const time = event.configuration.time ?? '';
const timezone = event.configuration.timezone ?? 'UTC';
return /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/.test(time)
&& timeZoneIsValid(timezone);
}
if (eventType === 'schedule.interval') {
return keys.every((key) => key === 'seconds')
&& integerInRange(event.configuration.seconds ?? '0', 1, 31_536_000);
}
if (eventType.startsWith('protection.')) {
const countEvents = ['protection.item_overflow', 'protection.mob_overflow', 'protection.entity_overflow'];
const accepted = eventType === 'protection.mod_entity_overflow'
? ['threshold', 'namespace', 'cooldownSeconds']
: eventType === 'protection.slow_tick'
? ['threshold', 'consecutive', 'cooldownSeconds']
: countEvents.includes(eventType) ? ['threshold', 'scope', 'cooldownSeconds']
: ['threshold', 'cooldownSeconds'];
if (keys.some((key) => !accepted.includes(key))) return false;
const minimum = eventType === 'protection.memory_pressure' || eventType === 'protection.slow_tick' ? 50 : 1;
const maximum = eventType === 'protection.memory_pressure' ? 99 : 2_147_483_647;
if (!integerInRange(event.configuration.threshold ?? '', minimum, maximum)
|| !integerInRange(event.configuration.cooldownSeconds ?? '60', 1, 86_400)) return false;
if (countEvents.includes(eventType) && !['dimension', 'chunk'].includes(event.configuration.scope ?? 'dimension')) return false;
if (eventType === 'protection.mod_entity_overflow'
&& !/^[a-z0-9_.-]{1,64}$/.test(event.configuration.namespace ?? '')) return false;
return eventType !== 'protection.slow_tick'
|| integerInRange(event.configuration.consecutive ?? '1', 1, 1_200);
}
if (eventType === 'player.command_trigger') {
if (keys.some((key) => !['command', 'arguments'].includes(key))) return false;
const command = event.configuration.command ?? '';
const rawArguments = (event.configuration.arguments ?? '').trim();
if (!commandPartPattern.test(command)) return false;
if ((event.arguments?.length ?? 0) > 0) {
if (rawArguments) return false;
return commandArgumentTreeIsValid(event.arguments ?? []);
}
const argumentsList = rawArguments ? rawArguments.split(/\s+/) : [];
return argumentsList.every((argument) => commandPartPattern.test(argument))
&& new Set(argumentsList).size === argumentsList.length;
}
return keys.length === 0;
}
function conditionIsValid(condition: TriggerCondition, catalog: TriggerCatalog): boolean {
const field = condition.field.trim();
const operator = condition.operator.trim().toLowerCase();
if (!field || field.length > 120 || !catalog.operators.some((item) => item.toLowerCase() === operator)
|| condition.value.length > 4_096) return false;
if (['matches', 'not_matches'].includes(operator)) return safeRegexIsValid(condition.value);
if (['gt', 'gte', 'lt', 'lte'].includes(operator)) return javaDecimalIsValid(condition.value);
if (['between', 'not_between'].includes(operator)) return numericRangeIsValid(condition.value);
return true;
}
function actionTreeIsValid(actions: TriggerAction[], eventType: string, catalog: TriggerCatalog): boolean {
let leaves = 0;
const visit = (action: TriggerAction): boolean => {
const actionType = action.type.trim().toLowerCase();
const children = actionChildren(action);
if (actionType === CONDITION_ACTION_TYPE) {
const parameterKeys = Object.keys(action.parameters);
if (children.length === 0
|| parameterKeys.some((key) => !['field', 'operator', 'value'].includes(key))) return false;
const condition = { field: action.parameters.field ?? '', operator: action.parameters.operator ?? '',
value: action.parameters.value ?? '' };
return conditionIsValid(condition, catalog) && children.every((child) => visit(child));
}
leaves += 1;
return children.length === 0 && actionCompatibleWithEvent(actionType, eventType)
&& actionIsValid(action, catalog);
};
return actions.length > 0 && actions.every((action) => visit(action)) && leaves > 0;
}
function actionIsValid(action: TriggerAction, catalog: TriggerCatalog): boolean {
const actionType = action.type.trim().toLowerCase();
if (!catalog.actions.some((item) => item.toLowerCase() === actionType)) return false;
const parameters = Object.entries(action.parameters);
const catalogType = Object.keys(catalog.actionParameters)
.find((item) => item.toLowerCase() === actionType) ?? actionType;
const accepted = catalog.actionParameters[catalogType] ?? fallbackCatalog.actionParameters[actionType];
if (!accepted || parameters.length > 16 || parameters.some(([key, entry]) =>
!key.trim() || key.trim().length > 80 || !accepted.includes(key) || entry.length > 65_536)) return false;
if (parameters.some(([, entry]) => !templateTimeFormatsAreValid(entry))) return false;
const primary = actionPrimaryParameter(actionType);
if (primary && !(action.parameters[primary] ?? '').trim()) return false;
if (['send_player', 'broadcast', 'actionbar', 'kick'].includes(actionType)
&& !richTextIsValid(action.parameters.message ?? '')) return false;
switch (actionType) {
case 'server_command':
case 'player_command': {
const command = action.parameters.command ?? '';
return command.length <= 32_768 && !/[\r\n]/.test(command)
&& templateOr(action.parameters.showFeedback ?? 'false',
(entry) => ['true', 'false'].includes(entry.trim().toLowerCase()));
}
case 'sound':
return templateOr(action.parameters.sound ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.volume ?? '1', (entry) => finiteNumberInRange(entry, 0, 1_000))
&& templateOr(action.parameters.pitch ?? '1', (entry) => finiteNumberInRange(entry, 0, 2));
case 'title':
return templateOr(action.parameters.fadeIn ?? '10', (entry) => integerInRange(entry, 0, 12_000))
&& templateOr(action.parameters.stay ?? '70', (entry) => integerInRange(entry, 0, 12_000))
&& templateOr(action.parameters.fadeOut ?? '20', (entry) => integerInRange(entry, 0, 12_000));
case 'teleport': {
const destination = action.parameters.destination ?? '';
return destination.trim().length <= 256 && !/[\r\n]/.test(destination);
}
case 'give_item':
return templateOr(action.parameters.item ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.count ?? '1', (entry) => integerInRange(entry, 1, 6_400));
case 'clear_inventory': {
const item = (action.parameters.item ?? '').trim();
const maximum = (action.parameters.maxCount ?? '').trim();
return (!item || templateOr(item, resourceIdentifierIsValid))
&& (!maximum || templateOr(maximum, (entry) => integerInRange(entry, 0, 2_147_483_647)));
}
case 'set_gamemode':
return templateOr(action.parameters.gamemode ?? '', (entry) =>
['survival', 'creative', 'adventure', 'spectator'].includes(entry.trim().toLowerCase()));
case 'add_effect':
return templateOr(action.parameters.effect ?? '', resourceIdentifierIsValid)
&& templateOr(action.parameters.duration ?? '30', (entry) => integerInRange(entry, 1, 1_000_000))
&& templateOr(action.parameters.amplifier ?? '0', (entry) => integerInRange(entry, 0, 255));
case 'set_time': {
const time = (action.parameters.time ?? '').trim().toLowerCase();
return templateOr(time, (entry) => ['day', 'night', 'noon', 'midnight'].includes(entry)
|| integerInRange(entry, 0, 24_000));
}
case 'set_weather':
return templateOr(action.parameters.weather ?? '', (entry) =>
['clear', 'rain', 'thunder'].includes(entry.trim().toLowerCase()))
&& templateOr(action.parameters.duration ?? '300', (entry) => integerInRange(entry, 1, 1_000_000));
case 'log':
return templateOr(action.parameters.level ?? 'info', (entry) =>
['debug', 'info', 'warn', 'error'].includes(entry.trim().toLowerCase()));
case 'economy_deposit':
case 'economy_withdraw':
case 'economy_set_balance':
case 'economy_transfer':
case 'economy_deposit_player':
case 'economy_withdraw_player':
case 'economy_set_player_balance':
case 'economy_transfer_players': {
const setOperation = ['economy_set_balance', 'economy_set_player_balance'].includes(actionType);
const identityParameters = actionType === 'economy_transfer_players' ? ['source', 'target']
: actionType === 'economy_transfer' ? ['target']
: ['economy_deposit_player', 'economy_withdraw_player', 'economy_set_player_balance'].includes(actionType)
? ['player'] : [];
const identityValid = identityParameters.every((parameter) => {
const identity = action.parameters[parameter] ?? '';
return Boolean(identity.trim()) && identity.length <= 128 && !/[\r\n]/.test(identity);
});
const reason = action.parameters.reason ?? 'Trigger economy operation';
return templateOr(action.parameters.currency ?? '', (entry) =>
/^[a-z][a-z0-9_]{0,31}$/.test(entry) || uuidIsValid(entry))
&& templateOr(action.parameters.amount ?? '', (entry) => economyAmountIsValid(entry, setOperation))
&& reason.trim().length > 0 && reason.length <= 512 && identityValid;
}
case 'variable':
return /^(?:global\.)?[A-Za-z_][A-Za-z0-9_-]{0,47}$/.test((action.parameters.name ?? '').trim())
&& templateOr(action.parameters.operation ?? 'set', (entry) => variableOperations.includes(entry.trim().toLowerCase()));
case 'wait': {
const mode = (action.parameters.mode ?? 'duration').trim().toLowerCase();
const dynamicMode = containsWellFormedVariable(mode);
return (dynamicMode || waitModes.includes(mode))
&& templateOr(action.parameters.timeout ?? '0', (entry) => integerInRange(entry, 0, 86_400))
&& templateOr(action.parameters.pollTicks ?? '20', (entry) => integerInRange(entry, 1, 72_000))
&& (mode === 'condition' || dynamicMode
? Boolean((action.parameters.field ?? '').trim())
&& templateOr(action.parameters.operator ?? 'eq', (entry) => catalog.operators.includes(entry))
&& (!dynamicMode || templateOr(action.parameters.value ?? '0',
(entry) => finiteNumberInRange(entry, 0, 86_400_000)))
: templateOr(action.parameters.value ?? '0', (entry) => mode === 'duration'
? finiteNumberInRange(entry, 0, 86_400_000)
: integerInRange(entry, 0, mode === 'game_time' ? 2_147_483_647 : 86_400_000)));
}
case 'run_trigger':
return templateOr(action.parameters.triggerId ?? '', (entry) => uuidIsValid(entry.trim()))
&& templateOr(action.parameters.waitForCompletion ?? 'false',
(entry) => ['true', 'false'].includes(entry.trim().toLowerCase()));
case 'open_menu':
return templateOr(action.parameters.menuId ?? '', (entry) => uuidIsValid(entry.trim()));
default:
return true;
}
}
function containsWellFormedVariable(value: string): boolean {
// Keep this in lock-step with TriggerEvaluator.TEMPLATE_VARIABLE/hasTemplateVariable.
const matcher = /\{([A-Za-z0-9_.-]+)(?::([^{}\r\n]{1,64}))?\}/g;
let match: RegExpExecArray | null;
while ((match = matcher.exec(value)) !== null) {
if (match[2] === undefined || match[1] === 'server.time' || match[1] === 'event.time') return true;
}
return false;
}
function templateOr(value: string, literalValidator: (value: string) => boolean): boolean {
return containsWellFormedVariable(value) || literalValidator(value);
}
function economyAmountIsValid(value: string, allowZeroOrNegative: boolean): boolean {
const normalized = value.trim();
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(normalized)) return false;
return allowZeroOrNegative || Number(normalized) > 0;
}
function actionPrimaryParameter(type: string): string | undefined {
if (['clear_inventory', 'remove_effects', 'heal', 'feed', 'whitelist_add', 'whitelist_remove',
'ban', 'pardon'].includes(type)) return undefined;
if (['server_command', 'player_command'].includes(type)) return 'command';
if (type === 'sound') return 'sound';
if (type === 'title') return 'title';
if (type === 'teleport') return 'destination';
if (type === 'give_item') return 'item';
if (type === 'set_gamemode') return 'gamemode';
if (type === 'add_effect') return 'effect';
if (type === 'set_time') return 'time';
if (type === 'set_weather') return 'weather';
if (type === 'variable') return 'name';
if (type === 'wait') return 'mode';
if (type === 'run_trigger') return 'triggerId';
if (type === 'open_menu') return 'menuId';
if (type.startsWith('economy_')) return 'currency';
return 'message';
}
function resourceIdentifierIsValid(value: string | undefined): boolean {
return /^(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+$/.test((value ?? '').trim());
}
function uuidIsValid(value: string): boolean {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(value)
|| value === '00000000-0000-0000-0000-000000000000';
}
function triggerVariableInitialValueIsValid(variable: TriggerStateVariableDefinition): boolean {
const value = variable.initialValue;
const structured = variableTypeIsContainer(variable.type);
if (value.length > (structured ? 65_536 : 8_192)) return false;
if (structured) {
try { return structuredVariableValueIsValid(parseVariableTypeNode(variable.type), JSON.parse(value), 0); }
catch { return false; }
}
switch (variable.type) {
case 'bool': return ['true', 'false'].includes(value.toLowerCase());
case 'integer': return /^[+-]?\d+$/.test(value.trim()) && Number.isSafeInteger(Number(value));
case 'float': return javaDecimalIsValid(value);
case 'uuid':
case 'player_ref':
case 'entity_ref': return uuidIsValid(value.trim());
case 'player': return /^[A-Za-z0-9_]{1,16}$/.test(value.trim());
case 'resource_location': return resourceIdentifierIsValid(value);
case 'region_ref':
case 'item_ref': return resourceIdentifierIsValid(value);
case 'block_state':
case 'item_stack': return /^(?:[a-z0-9_.-]+:)?[a-z0-9_./-]+(?:\[[^\r\n]*])?(?:\{[^\r\n]*})?$/.test(value.trim());
case 'component': return /^(?:\{.*}|\[.*]|".*")$/.test(value.trim());
case 'nbt': return /^\{.*}$/.test(value.trim());
case 'coordinate': return /^(?:[~^](?:-?(?:\d+(?:\.\d*)?|\.\d+))?|-?(?:\d+(?:\.\d*)?|\.\d+))(?:\s+(?:[~^](?:-?(?:\d+(?:\.\d*)?|\.\d+))?|-?(?:\d+(?:\.\d*)?|\.\d+))){0,2}$/.test(value.trim());
case 'rotation': return /^-?(?:\d+(?:\.\d*)?|\.\d+)\s+-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim());
case 'position':
case 'block_ref': return /^[a-z0-9_.-]+:[a-z0-9_./-]+(?:\s+-?(?:\d+(?:\.\d*)?|\.\d+)){3}$/.test(value.trim());
case 'duration': return /^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/.test(value.trim());
case 'instant': return /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value.trim())
&& Number.isFinite(Date.parse(value.trim()));
default: return !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value);
}
}
function variableTypeIsValid(type: string): boolean {
if (type === 'list' || (fallbackCatalog.triggerVariableTypes ?? []).includes(type)) return true;
try {
const node = parseVariableTypeNode(type);
const canonical = serializeVariableTypeNode(node);
if (canonical !== type || !['array', 'set', 'optional', 'dictionary'].includes(node.name)) return false;
const visit = (entry: VariableTypeNode): boolean =>
['array', 'set', 'optional'].includes(entry.name) ? entry.arguments.length === 1 && visit(entry.arguments[0])
: entry.name === 'dictionary' ? entry.arguments.length === 2 && !['array', 'set', 'optional', 'dictionary'].includes(entry.arguments[0].name)
&& visit(entry.arguments[0]) && visit(entry.arguments[1])
: entry.arguments.length === 0 && (fallbackCatalog.triggerVariableTypes ?? []).includes(entry.name)
&& !['array', 'set', 'optional', 'dictionary', 'list'].includes(entry.name);
return visit(node);
} catch { return false; }
}
function structuredVariableValueIsValid(type: VariableTypeNode, value: unknown, depth: number): boolean {
if (type.name === 'optional') return value === null
|| structuredVariableValueIsValid(type.arguments[0], value, depth + 1);
if (type.name === 'array' || type.name === 'set') return Array.isArray(value)
&& value.every((entry) => structuredVariableValueIsValid(type.arguments[0], entry, depth + 1));
if (type.name === 'dictionary') return value !== null && !Array.isArray(value) && typeof value === 'object'
&& Object.entries(value).every(([key, entry]) =>
scalarVariableValueIsValid(type.arguments[0].name, key)
&& structuredVariableValueIsValid(type.arguments[1], entry, depth + 1));
if (type.name === 'record') return value !== null && !Array.isArray(value) && typeof value === 'object';
return scalarVariableValueIsValid(type.name, value);
}
function scalarVariableValueIsValid(type: string, value: unknown): boolean {
if (type === 'bool') return typeof value === 'boolean';
if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
if (type === 'float') return typeof value === 'number' && Number.isFinite(value);
if (typeof value !== 'string') return false;
return triggerVariableInitialValueIsValid({ name: 'value', type, initialValue: value });
}
function commandArgumentTreeIsValid(values: TriggerCommandArgument[]): boolean {
const names = new Set<string>();
const visit = (items: TriggerCommandArgument[]): boolean => {
const branches = new Set<string>();
return items.every((item) => {
const branch = item.type === 'literal' ? `literal:${item.literal}` : `argument:${item.name}`;
if (!commandPartPattern.test(item.name) || names.has(item.name)
|| branches.has(branch) || !(fallbackCatalog.commandArgumentTypes ?? []).includes(item.type)
|| item.errorMessage.length > 256 || item.suggestions.length > 64
|| item.suggestions.some((entry) => !entry || entry.length > 128)
|| item.type === 'literal' && !item.literal.trim()
|| ['greedy_string', 'message'].includes(item.type) && item.children.length > 0) return false;
names.add(item.name); branches.add(branch);
if (['integer', 'long', 'float', 'double', 'time'].includes(item.type)) {
if (item.minimum && !javaDecimalIsValid(item.minimum)) return false;
if (item.maximum && !javaDecimalIsValid(item.maximum)) return false;
if (item.minimum && item.maximum && Number(item.minimum) > Number(item.maximum)) return false;
}
return visit(item.children ?? []);
});
};
return visit(values);
}
function integerInRange(value: string, minimum: number, maximum: number): boolean {
const normalized = value.trim();
if (!/^[+-]?\d+$/.test(normalized)) return false;
const parsed = Number(normalized);
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum;
}
function splitIntervalSeconds(value: string): IntervalParts {
if (!/^\d+$/.test(value.trim())) return { hours: '', minutes: '', seconds: '' };
const total = Number(value.trim());
if (!Number.isSafeInteger(total) || total < 0) return { hours: '', minutes: '', seconds: '' };
return {
hours: String(Math.floor(total / 3_600)),
minutes: String(Math.floor((total % 3_600) / 60)),
seconds: String(total % 60),
};
}
function joinIntervalSeconds(parts: IntervalParts): string {
const limits: Record<keyof IntervalParts, number> = { hours: 8_760, minutes: 59, seconds: 59 };
const parsed = {} as Record<keyof IntervalParts, number>;
for (const key of Object.keys(limits) as Array<keyof IntervalParts>) {
if (!/^\d+$/.test(parts[key])) return '';
const entry = Number(parts[key]);
if (!Number.isSafeInteger(entry) || entry < 0 || entry > limits[key]) return '';
parsed[key] = entry;
}
const total = parsed.hours * 3_600 + parsed.minutes * 60 + parsed.seconds;
return total <= 31_536_000 ? String(total) : '';
}
function finiteNumberInRange(value: string, minimum: number, maximum: number): boolean {
const normalized = value.trim();
if (!javaDecimalIsValid(normalized)) return false;
const parsed = Number(normalized.replace(/[fFdD]$/, ''));
return Number.isFinite(parsed) && parsed >= minimum && parsed <= maximum;
}
function javaDecimalIsValid(value: string): boolean {
const normalized = value.trim();
// The visual editor deliberately accepts Java's ordinary decimal/scientific forms while
// excluding Java-only hexadecimal floats that an HTML number input cannot faithfully edit.
return /^[+-]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)[fFdD]?$/.test(normalized)
&& Number.isFinite(Number(normalized.replace(/[fFdD]$/, '')));
}
function numericRangeIsValid(value: string): boolean {
const entries = value.split(',');
if (entries.length !== 2 || !entries.every(javaDecimalIsValid)) return false;
const [lower, upper] = entries.map((entry) => Number(entry.trim().replace(/[fFdD]$/, '')));
return lower <= upper;
}
function timeZoneIsValid(value: string): boolean {
// Browsers and java.time.ZoneId do not expose identical zone vocabularies. Keep the synchronous
// check non-destructive and let validateVisualTrigger provide the authoritative Java verdict.
return Boolean(value) && value === value.trim() && value.length <= 4_096;
}
function safeRegexIsValid(expression: string): boolean {
if (expression.length > 256) return false;
// JavaScript-only Unicode code-point spelling is not accepted by java.util.regex.
// Other dialect differences are caught by the authoritative server preflight before save.
if (/\\u\{/.test(expression)) return false;
let variableRepetitions = 0;
let repetitionBudget = 1;
let escaped = false;
let characterClass = false;
let groupDepth = 0;
let previousWasGroup = false;
for (let index = 0; index < expression.length; index += 1) {
const value = expression[index];
if (escaped) {
if (!characterClass && /\d/.test(value)) return false;
escaped = false;
previousWasGroup = false;
continue;
}
if (value === '\\') { escaped = true; continue; }
if (value === '[' && !characterClass) { characterClass = true; previousWasGroup = false; continue; }
if (value === ']' && characterClass) { characterClass = false; continue; }
if (characterClass) continue;
if (value === '(' && expression[index + 1] === '?') return false;
if (value === '(') { groupDepth += 1; previousWasGroup = false; continue; }
if (value === ')') {
if (groupDepth === 0) return false;
groupDepth -= 1;
previousWasGroup = true;
continue;
}
if (value === '*' || value === '+' || value === '?') {
if (previousWasGroup) return false;
variableRepetitions += 1;
repetitionBudget *= value === '?' ? 2 : 1_025;
previousWasGroup = false;
continue;
}
if (value === '{') {
const close = expression.indexOf('}', index + 1);
if (close >= 0) {
if (previousWasGroup) return false;
const bounds = expression.slice(index + 1, close).split(',');
if (bounds.length <= 2 && /^\d+$/.test(bounds[0])
&& (bounds.length === 1 || bounds[1] === '' || /^\d+$/.test(bounds[1]))) {
const minimum = Number(bounds[0]);
const maximum = bounds.length === 1 ? minimum : bounds[1] === '' ? 1_024 : Number(bounds[1]);
if (maximum < minimum || maximum > 1_024) return false;
if (maximum !== minimum) {
variableRepetitions += 1;
repetitionBudget *= maximum - minimum + 1;
}
index = close;
previousWasGroup = false;
continue;
}
}
}
previousWasGroup = false;
}
return !escaped && !characterClass && groupDepth === 0
&& variableRepetitions <= 8 && repetitionBudget <= 1_100_000;
}
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, current) => current === index ? value : item); }
function remove<T>(items: T[], index: number): T[] { return items.filter((_, current) => current !== index); }
function move<T>(items: T[], index: number, offset: -1 | 1): T[] {
const destination = index + offset;
if (destination < 0 || destination >= items.length) return items;
const result = [...items];
[result[index], result[destination]] = [result[destination], result[index]];
return result;
}
function groupFingerprint(value: TriggerGroup): string {
return JSON.stringify({ name: value.name, description: value.description, enabled: value.enabled });
}
function triggerFingerprint(value: TriggerDefinition): string {
return JSON.stringify({
groupId: value.groupId, name: value.name, description: value.description, enabled: value.enabled,
mode: value.mode, event: value.event, conditionMode: value.conditionMode,
conditions: value.conditions, actions: value.actions, script: value.script,
schemaVersion: value.schemaVersion, events: value.events, declarations: value.declarations,
functions: value.functions, statements: value.statements,
});
}
function newNodeId(): string {
return globalThis.crypto?.randomUUID?.() ?? 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (entry) => {
const value = Math.floor(Math.random() * 16);
return (entry === 'x' ? value : value & 0x3 | 0x8).toString(16);
});
}
function literalExpression(value: unknown, valueType = 'string'): TriggerExpression {
return { nodeId: newNodeId(), kind: 'LITERAL', valueType, literal: value, name: '', arguments: [] };
}
function expressionOfKind(kind: TriggerExpression['kind'], nodeId = newNodeId()): TriggerExpression {
if (kind === 'LITERAL') return { ...literalExpression('', 'string'), nodeId };
if (kind === 'REFERENCE') return { nodeId, kind, valueType: 'string', name: 'event.player.name', arguments: [] };
const arity = kind === 'BINARY' || kind === 'INDEX' ? 2 : kind === 'COALESCE' ? 2 : 1;
const name = kind === 'UNARY' ? 'not' : kind === 'BINARY' ? 'eq' : kind === 'FUNCTION' ? 'len' : '';
return { nodeId, kind, valueType: kind === 'UNARY' || kind === 'BINARY' ? 'bool' : 'string', name,
arguments: Array.from({ length: arity }, () => literalExpression('', 'string')) };
}
function triggerV2Program(value: TriggerDefinition): TriggerProgramV2 | undefined {
if (value.schemaVersion !== 2 || !value.events || !value.declarations || !value.functions || !value.statements) return undefined;
return { schemaVersion: 2, events: value.events, declarations: value.declarations,
functions: value.functions, statements: value.statements };
}
function v2VariableProjection(value: TriggerVariableDeclarationV2): TriggerStateVariableDefinition {
return { name: value.name, type: value.valueType,
initialValue: value.initialValue?.kind === 'LITERAL' ? String(value.initialValue.literal ?? '') : '',
visibility: value.visibility, storage: value.storage,
lifetime: value.lifetime.toLowerCase() as TriggerStateVariableDefinition['lifetime'],
ttlSeconds: value.ttlSeconds };
}
function actionStatement(descriptor: NonNullable<TriggerCatalog['descriptors']>[number]): TriggerStatement {
return { nodeId: newNodeId(), kind: 'ACTION', name: descriptor.id,
inputs: Object.fromEntries(descriptor.parameters.map((parameter) =>
[parameter.name, literalExpression(parameter.defaultValue ?? '', 'string')])),
cases: [], statements: [], elseStatements: [] };
}
function firstActionDescriptor(catalog: TriggerCatalog): NonNullable<TriggerCatalog['descriptors']>[number] {
return (catalog.descriptors ?? []).find((entry) => entry.kind === 'ACTION') ?? {
id: 'log', kind: 'ACTION', displayName: 'log', description: 'Trigger action', parameters: [
{ name: 'message', valueType: 'expression<any>', required: true, defaultValue: '' },
{ name: 'level', valueType: 'expression<any>', required: true, defaultValue: 'info' },
], returnType: 'void', purity: 'SIDE_EFFECT', threadAffinity: 'SERVER', risk: 'SAFE',
supportedPlatforms: [], applicableEvents: ['*'],
};
}
function defaultV2Statement(kind: TriggerStatement['kind'], program: TriggerProgramV2): TriggerStatement {
const base = { nodeId: newNodeId(), kind, name: '', inputs: {}, cases: [], statements: [], elseStatements: [] };
switch (kind) {
case 'ACTION': return { ...base, name: 'log', inputs: {
message: literalExpression('', 'string'), level: literalExpression('info', 'string') } };
case 'SET': return { ...base, name: program.declarations[0]?.name ?? 'value', expression: literalExpression('', 'string') };
case 'IF': return { ...base, expression: literalExpression(true, 'bool') };
case 'SWITCH': return { ...base, expression: literalExpression('', 'string') };
case 'REPEAT': return { ...base, expression: literalExpression(1, 'integer') };
case 'WHILE': return { ...base, expression: literalExpression(false, 'bool') };
case 'FOREACH': return { ...base, name: 'item', expression: literalExpression([], 'list') };
case 'CALL': return { ...base, name: program.functions[0]?.name ?? 'procedure' };
case 'RETURN': return base;
case 'BREAK': case 'CONTINUE': case 'TRY': return base;
}
}
function findV2Node(program: TriggerProgramV2, nodeId: string): V2Node | undefined {
const event = program.events.find((entry) => entry.nodeId === nodeId);
if (event) return { category: 'event', value: event };
const declaration = program.declarations.find((entry) => entry.nodeId === nodeId)
?? program.functions.flatMap((entry) => entry.locals).find((entry) => entry.nodeId === nodeId);
if (declaration) return { category: 'declaration', value: declaration };
const fn = program.functions.find((entry) => entry.nodeId === nodeId);
if (fn) return { category: 'function', value: fn };
const findStatement = (values: TriggerStatement[]): TriggerStatement | undefined => {
for (const statement of values) {
if (statement.nodeId === nodeId) return statement;
const nested = findStatement([...statement.statements,
...statement.cases.flatMap((branch) => branch.statements), ...statement.elseStatements]);
if (nested) return nested;
}
return undefined;
};
const statement = findStatement([...program.statements, ...program.functions.flatMap((entry) => entry.statements)]);
return statement ? { category: 'statement', value: statement } : undefined;
}
function replaceV2Node(program: TriggerProgramV2, nodeId: string, replacement: V2Node['value']): TriggerProgramV2 {
const replaceStatements = (values: TriggerStatement[]): TriggerStatement[] => values.map((statement) =>
statement.nodeId === nodeId ? replacement as TriggerStatement : { ...statement,
statements: replaceStatements(statement.statements),
cases: statement.cases.map((branch) => ({ ...branch, statements: replaceStatements(branch.statements) })),
elseStatements: replaceStatements(statement.elseStatements) });
return { ...program,
events: program.events.map((entry) => entry.nodeId === nodeId ? replacement as TriggerEventBindingLike : entry),
declarations: program.declarations.map((entry) => entry.nodeId === nodeId
? replacement as TriggerVariableDeclarationV2 : entry),
functions: program.functions.map((entry) => entry.nodeId === nodeId
? replacement as TriggerFunctionDeclaration : { ...entry,
locals: entry.locals.map((local) => local.nodeId === nodeId
? replacement as TriggerVariableDeclarationV2 : local),
statements: replaceStatements(entry.statements) }),
statements: replaceStatements(program.statements) };
}
function removeV2Node(program: TriggerProgramV2, nodeId: string): TriggerProgramV2 {
const removeStatements = (values: TriggerStatement[]): TriggerStatement[] => values.filter((entry) =>
entry.nodeId !== nodeId).map((statement) => ({ ...statement,
statements: removeStatements(statement.statements),
cases: statement.cases.map((branch) => ({ ...branch, statements: removeStatements(branch.statements) })),
elseStatements: removeStatements(statement.elseStatements) }));
return { ...program, events: program.events.filter((entry) => entry.nodeId !== nodeId),
declarations: program.declarations.filter((entry) => entry.nodeId !== nodeId),
functions: program.functions.filter((entry) => entry.nodeId !== nodeId).map((entry) => ({ ...entry,
locals: entry.locals.filter((local) => local.nodeId !== nodeId),
statements: removeStatements(entry.statements) })), statements: removeStatements(program.statements) };
}
function appendV2Child(program: TriggerProgramV2, nodeId: string,
branch: 'statements' | 'elseStatements', child: TriggerStatement): TriggerProgramV2 {
const append = (values: TriggerStatement[]): TriggerStatement[] => values.map((statement) => {
if (statement.nodeId === nodeId) return { ...statement, [branch]: [...statement[branch], child] };
return { ...statement, statements: append(statement.statements),
cases: statement.cases.map((entry) => ({ ...entry, statements: append(entry.statements) })),
elseStatements: append(statement.elseStatements) };
});
return { ...program, statements: append(program.statements),
functions: program.functions.map((entry) => ({ ...entry, statements: append(entry.statements) })) };
}
function regenerateNodeIds<T>(value: T): T {
const clone = structuredClone(value) as unknown;
const visit = (entry: unknown) => {
if (Array.isArray(entry)) { entry.forEach(visit); return; }
if (!entry || typeof entry !== 'object') return;
const object = entry as Record<string, unknown>;
if (typeof object.nodeId === 'string') object.nodeId = newNodeId();
Object.values(object).forEach(visit);
};
visit(clone);
return clone as T;
}
function uniqueVariableName(program: TriggerProgramV2, requested: string): string {
const names = new Set(program.declarations.map((entry) => entry.name));
let result = requested.slice(0, 48);
let suffix = 2;
while (names.has(result)) result = `${requested.slice(0, 43)}_${suffix++}`;
return result;
}
function uniqueFunctionName(program: TriggerProgramV2, requested: string): string {
const names = new Set(program.functions.map((entry) => entry.name));
let result = requested.slice(0, 128);
let suffix = 2;
while (names.has(result)) result = `${requested.slice(0, 122)}_${suffix++}`;
return result;
}
function statementTreeContains(statement: TriggerStatement, predicate: (value: TriggerStatement) => boolean): boolean {
return predicate(statement) || [...statement.statements, ...statement.elseStatements,
...statement.cases.flatMap((branch) => branch.statements)].some((child) => statementTreeContains(child, predicate));
}
function mapV2Expression(value: TriggerExpression | undefined,
transform: (value: TriggerExpression) => TriggerExpression): TriggerExpression | undefined {
if (!value) return undefined;
return transform({ ...value, arguments: value.arguments.map((entry) => mapV2Expression(entry, transform)!) });
}
function mapV2Statements(values: TriggerStatement[], transform: (value: TriggerStatement) => TriggerStatement): TriggerStatement[] {
return values.map((value) => transform({ ...value,
inputs: Object.fromEntries(Object.entries(value.inputs).map(([name, expression]) =>
[name, mapV2Expression(expression, (entry) => entry)!])),
expression: mapV2Expression(value.expression, (entry) => entry),
statements: mapV2Statements(value.statements, transform),
cases: value.cases.map((branch) => ({ ...branch,
match: mapV2Expression(branch.match, (entry) => entry)!, statements: mapV2Statements(branch.statements, transform) })),
elseStatements: mapV2Statements(value.elseStatements, transform) }));
}
function renameV2Variable(program: TriggerProgramV2, oldName: string, newName: string): TriggerProgramV2 {
const renameExpression = (value: TriggerExpression) => ({ ...value,
name: value.kind === 'REFERENCE' && [oldName, `var.${oldName}`, `global.${oldName}`, `local.${oldName}`].includes(value.name)
? value.name.includes('.') ? `${value.name.split('.')[0]}.${newName}` : newName : value.name });
const renameStatements = (statements: TriggerStatement[]) => mapV2Statements(statements, (statement) => ({ ...statement,
name: statement.kind === 'SET' && statement.name === oldName ? newName : statement.name,
inputs: Object.fromEntries(Object.entries(statement.inputs).map(([name, expression]) =>
[name, mapV2Expression(expression, renameExpression)!])),
expression: mapV2Expression(statement.expression, renameExpression),
cases: statement.cases.map((branch) => ({ ...branch, match: mapV2Expression(branch.match, renameExpression)! })) }));
return { ...program,
declarations: program.declarations.map((entry) => entry.name === oldName ? { ...entry, name: newName } : entry),
statements: renameStatements(program.statements),
functions: program.functions.map((fn) => {
const shadowed = fn.parameters.some((entry) => entry.name === oldName) || fn.locals.some((entry) => entry.name === oldName);
return shadowed ? fn : { ...fn, statements: renameStatements(fn.statements),
locals: fn.locals.map((local) => ({ ...local,
initialValue: mapV2Expression(local.initialValue, renameExpression) })) };
}) };
}
function variableReferenceCount(program: TriggerProgramV2, name: string): number {
const serialized = JSON.stringify({ statements: program.statements, functions: program.functions });
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return (serialized.match(new RegExp(`(?:var\\.|global\\.|local\\.)?${escaped}`, 'g')) ?? []).length;
}
function v2Dependencies(program: TriggerProgramV2): string[] {
const result = new Set<string>();
const visit = (statement: TriggerStatement) => {
if (statement.kind === 'CALL') result.add(`program → function:${statement.name}`);
if (statement.kind === 'ACTION' && statement.name === 'run_trigger') {
const target = statement.inputs.triggerId;
if (target?.kind === 'LITERAL') result.add(`program → trigger:${String(target.literal)}`);
}
[...statement.statements, ...statement.elseStatements,
...statement.cases.flatMap((branch) => branch.statements)].forEach(visit);
};
[...program.statements, ...program.functions.flatMap((entry) => entry.statements)].forEach(visit);
return [...result];
}
function visitV2NodeIds(program: TriggerProgramV2, visitor: (id: string) => void): void {
const expression = (value?: TriggerExpression) => { if (!value) return; visitor(value.nodeId); value.arguments.forEach(expression); };
const statements = (values: TriggerStatement[]) => values.forEach((statement) => {
visitor(statement.nodeId); Object.values(statement.inputs).forEach(expression); expression(statement.expression);
statement.cases.forEach((branch) => { visitor(branch.nodeId); expression(branch.match); statements(branch.statements); });
statements(statement.statements); statements(statement.elseStatements);
});
program.events.forEach((entry) => visitor(entry.nodeId));
const declaration = (entry: TriggerVariableDeclarationV2) => { visitor(entry.nodeId); expression(entry.initialValue); };
program.declarations.forEach(declaration);
program.functions.forEach((entry) => { visitor(entry.nodeId); entry.locals.forEach(declaration); statements(entry.statements); });
statements(program.statements);
}
function uniqueNodeIds(program: TriggerProgramV2): Set<string> {
const result = new Set<string>();
visitV2NodeIds(program, (id) => result.add(id));
return result;
}
function countV2Nodes(program: TriggerProgramV2): number {
let count = 0;
visitV2NodeIds(program, () => { count += 1; });
return count;
}
function toScript(value: TriggerDefinition): string {
const quote = (input: string) => `"${input.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"`;
const lines = [`# XFE Script v2`, `on ${value.event.type}`];
Object.entries(value.event.configuration).forEach(([key, entry]) => lines.push(`set ${key}=${quote(entry)}`));
(value.event.variables ?? []).forEach((variable) => lines.push(
`var ${variable.name} ${variable.type} initial=${quote(variable.initialValue)} visibility=${quote(variable.visibility ?? 'trigger')} storage=${quote(variable.storage ?? ((variable.visibility ?? 'trigger') === 'global' ? 'server' : 'trigger'))} lifetime=${quote(variable.lifetime ?? 'session')}${(variable.lifetime ?? 'session') === 'ttl' ? ` ttlSeconds=${variable.ttlSeconds ?? 3600}` : ''}`));
const appendArguments = (argumentsList: TriggerCommandArgument[], parent = '') => argumentsList.forEach((argument) => {
const fields = [parent && `parent=${quote(parent)}`, argument.literal && `literal=${quote(argument.literal)}`,
argument.optional && 'optional=true', argument.errorMessage && `error=${quote(argument.errorMessage)}`,
argument.minimum && `min=${quote(argument.minimum)}`, argument.maximum && `max=${quote(argument.maximum)}`,
argument.suggestions.length > 0 && `suggestions=${quote(argument.suggestions.join(','))}`].filter(Boolean).join(' ');
lines.push(`arg ${argument.name} ${argument.type}${fields ? ` ${fields}` : ''}`);
appendArguments(argument.children ?? [], argument.name);
});
appendArguments(value.event.arguments ?? []);
lines.push(`match ${value.conditionMode}`);
value.conditions.forEach((condition) => lines.push(`when ${condition.field} ${condition.operator}${conditionValueKind(condition.operator) === 'none' ? '' : ` ${quote(condition.value)}`}`));
const appendActions = (actions: TriggerAction[], depth: number) => actions.forEach((action) => {
const indentation = ' '.repeat(depth);
if (action.type.trim().toLowerCase() === CONDITION_ACTION_TYPE) {
const field = action.parameters.field ?? '';
const operator = action.parameters.operator ?? '';
const conditionValue = action.parameters.value ?? '';
lines.push(`${indentation}if ${field} ${operator}${conditionValueKind(operator) === 'none' ? '' : ` ${quote(conditionValue)}`}`);
appendActions(actionChildren(action), depth + 1);
lines.push(`${indentation}end`);
return;
}
const parameters = Object.entries(action.parameters).map(([key, entry]) => `${key}=${quote(entry)}`).join(' ');
lines.push(`${indentation}do ${action.type}${parameters ? ` ${parameters}` : ''}`);
});
appendActions(value.actions, 0);
return lines.join('\n');
}