import { Blocks, CheckCheck, CopyPlus, Download, FileBox, Music2, Plus, RotateCcw, Save, Search, Trash2, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select, Toast, formatBytes } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import { hasCapability } from '../lib/permissions';
import type { ContentArchetype, ContentAsset, ContentBehavior, ContentCatalog, ContentDefinition, ContentInput, ContentKind, ContentLocalized, ContentProperty, ContentValidation, ContentWorkspace, ContentWorkspaceView } from '../types';
const kindNames: Record<ContentKind, [string, string]> = { ITEM: ['物品', 'Items'], BLOCK: ['方块', 'Blocks'], ENTITY: ['生物', 'Entities'] };
const optionNames: Record<string, string> = {
common: '普通', uncommon: '优秀', rare: '稀有', epic: '史诗',
wood: '木质', wooden: '木质', stone: '石质', iron: '铁质', gold: '金质', golden: '金质', diamond: '钻石', netherite: '下界合金',
leather: '皮革', chainmail: '锁链', turtle: '海龟',
never: '从不', always: '总是', true: '是', false: '否',
};
const message = (cause: unknown) => cause instanceof Error ? cause.message : String(cause);
const localized = (entry: ContentLocalized, zh: boolean, description = false) => description
? (zh ? entry.descriptionZh : entry.descriptionEn) : (zh ? entry.nameZh : entry.nameEn);
const defaults = (properties: ContentProperty[]) => Object.fromEntries(properties.map((field) => [field.key, field.defaultValue]));
const toInput = (workspace: ContentWorkspace): ContentInput => ({
expectedRevision: workspace.revision, enabled: workspace.enabled, definitions: workspace.definitions,
});
const eventSupports = (event: ContentCatalog['events'][number], definition: ContentDefinition) => event.kinds.includes(definition.kind)
&& (event.archetypes.length === 0 || event.archetypes.includes(definition.archetype));
const actionSupports = (action: ContentCatalog['actions'][number], definition: ContentDefinition, event: string) =>
action.kinds.includes(definition.kind) && action.events.includes(event);
function compatibleValue(field: ContentProperty, value: string | undefined): string {
if (value === undefined) return field.defaultValue;
if (field.options.length && !field.options.includes(value)) return field.defaultValue;
if (['int', 'integer', 'number'].includes(field.type)
&& (value.trim() === '' || !Number.isFinite(Number(value)) || (field.type !== 'number' && !Number.isInteger(Number(value)))
|| (field.min !== null && Number(value) < field.min) || (field.max !== null && Number(value) > field.max))) return field.defaultValue;
return value;
}
function uniqueId(existing: ContentDefinition[], stem: string): string {
const base = stem.replace(/^.*:/, '').replace(/[^a-z0-9_]/g, '_').slice(0, 48) || 'content';
let number = 1;
while (existing.some((item) => item.id === `xfesmcontent:${base}_${number}`)) number += 1;
return `xfesmcontent:${base}_${number}`;
}
interface References {
menus: Array<{ id: string; name: string }>;
triggers: Array<{ id: string; name: string }>;
assets: ContentAsset[];
}
function PropertyField({ field, value, onChange, zh, disabled, references, idPrefix }: {
field: ContentProperty; value: string; onChange: (value: string) => void;
zh: boolean; disabled: boolean; references: References; idPrefix: string;
}) {
const name = localized(field, zh) || field.key;
const source = field.type === 'menu_ref' ? references.menus : field.type === 'trigger_ref' ? references.triggers : undefined;
const optionList = field.options ?? [];
const listId = `${idPrefix}-${field.key}`;
let control;
if (source) {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
<option value="">{zh ? '请选择已有对象' : 'Select an existing object'}</option>
{value && !source.some((item) => item.id === value) && <option value={value}>{zh ? '未找到 / 无权读取:' : 'Missing / unavailable: '}{value}</option>}
{source.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id.slice(0, 8)}</option>)}
</Select>;
} else if (field.type === 'boolean') {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
<option value="true">{zh ? '是' : 'Yes'}</option><option value="false">{zh ? '否' : 'No'}</option>
</Select>;
} else if (optionList.length > 0 || field.type === 'select') {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{!optionList.includes(value) && <option value={value}>{value || (zh ? '请选择' : 'Select')}</option>}
{optionList.map((option) => <option key={option} value={option}>{zh ? optionNames[option] ?? option : option}</option>)}
</Select>;
} else {
const numeric = ['int', 'integer', 'number'].includes(field.type);
control = <Input type={numeric ? 'number' : 'text'} value={value} disabled={disabled} maxLength={1024}
min={numeric ? field.min ?? undefined : undefined} max={numeric ? field.max ?? undefined : undefined}
step={numeric ? field.type === 'number' ? 'any' : '1' : undefined}
list={field.type === 'resource' ? listId : undefined}
onChange={(event) => onChange(event.target.value)} />;
}
return <div className="content-property"><Field label={<>{name}{field.requiresRestart && <small>{zh ? '重启生效' : 'Restart'}</small>}</>}
hint={localized(field, zh, true)}>{control}</Field>
{source?.length === 0 && <p className="content-help">{zh ? '请先在 UI 菜单或触发器页面创建对象,并确认读取权限。' : 'Create the menu or trigger first, and check read permissions.'}</p>}
{field.type === 'resource' && <datalist id={listId}>{references.assets
.filter((asset) => /sound|music/.test(field.key) ? asset.mimeType.startsWith('audio/') : asset.mimeType === 'image/png')
.map((asset) => <option key={asset.id} value={asset.resourceLocation}>{asset.name}</option>)}</datalist>}
</div>;
}
type Confirmation = { type: 'delete' | 'publish' | 'rollback' | 'reload' } | { type: 'archetype'; archetype: ContentArchetype };
export function ContentPage() {
const { api, session } = useServer();
const { locale } = useI18n();
const zh = locale === 'zh-CN';
const writable = session?.actor?.roles.includes('owner') === true;
const resource = useApiResource(() => Promise.all([api.contentCatalog(), api.contentWorkspace()]), [api]);
const related = useApiResource(async () => {
const [menus, triggers, assets] = await Promise.allSettled([
hasCapability(session?.actor, 'menu_read') ? api.menuWorkspace().then((value) => value.menus) : Promise.resolve([]),
api.triggerWorkspace().then((value) => value.groups.flatMap((group) => group.triggers)), api.contentAssets(),
]);
return {
menus: menus.status === 'fulfilled' ? menus.value : [], triggers: triggers.status === 'fulfilled' ? triggers.value : [],
assets: assets.status === 'fulfilled' ? assets.value : [],
unavailable: [menus, triggers, assets].some((result) => result.status === 'rejected'),
};
}, [api, session?.actor?.id]);
const catalog = resource.data?.[0];
const [view, setView] = useState<ContentWorkspaceView>();
const [draft, setDraft] = useState<ContentWorkspace>();
const [dirty, setDirty] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [kind, setKind] = useState<ContentKind | 'ALL'>('ALL');
const [query, setQuery] = useState('');
const [newArchetype, setNewArchetype] = useState('');
const [templateId, setTemplateId] = useState('');
const [validation, setValidation] = useState<ContentValidation>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [confirmation, setConfirmation] = useState<Confirmation>();
const dialogRef = useRef<HTMLElement>(null);
const [rollbackRevision, setRollbackRevision] = useState('');
const [assets, setAssets] = useState<ContentAsset[]>([]);
const references: References = { menus: related.data?.menus ?? [], triggers: related.data?.triggers ?? [], assets };
const selected = draft?.definitions[selectedIndex];
const archetype = catalog?.archetypes.find((item) => item.kind === selected?.kind && item.id === selected.archetype);
const editable = writable && !busy;
const candidateArchetypes = catalog?.archetypes.filter((item) => kind === 'ALL' || item.kind === kind) ?? [];
const candidateArchetype = candidateArchetypes.find((item) => `${item.kind}:${item.id}` === newArchetype) ?? candidateArchetypes[0];
const filtered = useMemo(() => draft?.definitions.map((item, index) => ({ item, index }))
.filter(({ item }) => (kind === 'ALL' || item.kind === kind)
&& `${item.name} ${item.id} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [], [draft, kind, query]);
useEffect(() => {
if (!resource.data) return;
setView((current) => current ?? resource.data![1]);
setDraft((current) => current ?? resource.data![1].workspace);
}, [resource.data]);
useEffect(() => {
if (!view?.update || !['SCHEDULED', 'ARMING', 'SAVING', 'RESTARTING'].includes(view.update.state)) return;
let cancelled = false;
let inFlight = false;
const timer = window.setInterval(() => {
if (inFlight) return;
inFlight = true;
void api.contentWorkspace().then((next) => {
if (!cancelled) setView(next); // never replace the user's unsaved draft during a countdown
}).catch(() => { /* a safe restart temporarily disconnects the control plane */ }).finally(() => { inFlight = false; });
}, 3000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [api, view?.update?.state]);
useEffect(() => {
if (related.data) setAssets((current) => Array.from(new Map([...related.data!.assets, ...current].map((asset) => [asset.id, asset])).values()));
}, [related.data]);
useEffect(() => {
if (!dirty) return;
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, [dirty]);
useEffect(() => {
if (!confirmation) return;
const previous = document.activeElement;
const dialog = dialogRef.current;
const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []);
focusable()[0]?.focus();
const handle = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); setConfirmation(undefined); }
if (event.key === 'Tab') {
const elements = focusable(); const first = elements[0]; const last = elements.at(-1);
if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) { event.preventDefault(); first?.focus(); }
}
};
document.addEventListener('keydown', handle);
return () => { document.removeEventListener('keydown', handle); if (previous instanceof HTMLElement) previous.focus(); };
}, [confirmation]);
const edit = (next: ContentWorkspace) => { setDraft(next); setDirty(true); setValidation(undefined); setNotice(''); };
const updateSelected = (next: ContentDefinition) => {
if (draft) edit({ ...draft, definitions: draft.definitions.map((item, index) => index === selectedIndex ? next : item) });
};
const accept = (next: ContentWorkspaceView) => { setView(next); setDraft(next.workspace); setDirty(false); setValidation(undefined); };
const perform = async (task: () => Promise<void>) => {
if (busy) return;
setBusy(true); setError(''); setNotice('');
try { await task(); } catch (cause) { setError(message(cause)); } finally { setBusy(false); }
};
const save = useCallback(async () => {
if (!draft || !writable || busy) return;
setBusy(true); setError(''); setNotice('');
try {
const result = await api.validateContent(toInput(draft));
setValidation(result);
if (!result.valid) return;
const saved = await api.saveContent(toInput(draft));
setView(saved); setDraft(saved.workspace); setDirty(false);
setNotice(zh ? '草稿已保存。发布、同步内容包并重启后才会在游戏中生效。' : 'Draft saved. Publish, sync the pack and restart to apply it in game.');
} catch (cause) { setError(message(cause)); } finally { setBusy(false); }
}, [api, draft, writable, busy, zh]);
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); if (!confirmation) void save(); }
};
document.addEventListener('keydown', handle);
return () => document.removeEventListener('keydown', handle);
}, [save, confirmation]);
const add = (definition?: ContentDefinition) => {
if (!draft || !editable || draft.definitions.length >= 256 || (!definition && !candidateArchetype)) return;
const target = candidateArchetype;
const created: ContentDefinition = definition ? structuredClone(definition) : {
id: '', kind: target.kind, archetype: target.id, name: localized(target, zh), description: '',
properties: defaults(target.properties), behaviors: [],
};
created.id = uniqueId(draft.definitions, definition?.id || created.kind.toLowerCase());
created.behaviors = created.behaviors.map((behavior, index) => ({ ...behavior, id: `behavior_${index + 1}` }));
edit({ ...draft, definitions: [...draft.definitions, created] });
setSelectedIndex(draft.definitions.length); setKind('ALL'); setQuery('');
};
const updateBehavior = (index: number, value: ContentBehavior) => {
if (selected) updateSelected({ ...selected, behaviors: selected.behaviors.map((item, current) => current === index ? value : item) });
};
const addBehavior = () => {
if (!selected || !catalog || selected.behaviors.length >= 32) return;
const event = catalog.events.find((item) => eventSupports(item, selected)
&& catalog.actions.some((action) => actionSupports(action, selected, item.id)));
const action = catalog.actions.find((item) => event && actionSupports(item, selected, event.id));
if (!event || !action) return;
let id = 1; while (selected.behaviors.some((item) => item.id === `behavior_${id}`)) id += 1;
updateSelected({ ...selected, behaviors: [...selected.behaviors, { id: `behavior_${id}`, event: event.id,
action: action.id, parameters: defaults(action.parameters), cooldownTicks: 0, cancelVanilla: false }] });
};
const confirm = () => {
if (!confirmation || !draft || busy || (!writable && confirmation.type !== 'reload')) return;
const current = confirmation; setConfirmation(undefined);
if (current.type === 'delete') {
edit({ ...draft, definitions: draft.definitions.filter((_, index) => index !== selectedIndex) });
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (current.type === 'archetype' && selected) {
const next = current.archetype;
updateSelected({ ...selected, archetype: next.id,
properties: Object.fromEntries(next.properties.map((field) => [field.key, compatibleValue(field, selected.properties[field.key])])) });
} else if (current.type === 'reload') {
void perform(async () => { accept(await api.contentWorkspace()); setSelectedIndex(0); });
} else if (current.type === 'publish') {
void perform(async () => {
const result = await api.validateContent(toInput(draft)); setValidation(result);
if (!result.valid) return;
const published = await api.publishContent(draft.revision);
accept(published);
setNotice(!draft.enabled
? (zh ? '已发布禁用草稿,不会注册内容。要启用新内容,请先开启完整内容模式,保存并重新发布。' : 'Published a disabled draft; no content will be registered. Enable full content mode, save and publish again to activate new content.')
: published.update?.state === 'SCHEDULED'
? (zh ? '版本已发布,服务器将在 30 秒后保存并自动重启。客户端再次连接时会检查并下载当前生效版本。' : 'Published. The managed server will save and restart in 30 seconds. Clients check and download the active version when connecting.')
: (zh ? '版本已发布。客户端会自动检查和下载;服务端尚未接管自动重启,请查看下方检测结果。' : 'Published. Clients check and download automatically; inspect restart detection below for server activation.'));
});
} else if (current.type === 'rollback') {
void perform(async () => {
accept(await api.rollbackContent(draft.revision, Number(rollbackRevision))); setSelectedIndex(0);
setNotice(zh ? '已恢复历史修订。请核对发布状态;已发布的内容仍需重新同步并重启。' : 'Revision restored. Check publication status; published content still requires pack sync and restart.');
});
}
};
const download = () => perform(async () => {
const exported = await api.exportContent();
const bytes = Uint8Array.from(atob(exported.dataBase64), (character) => character.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: exported.mimeType }));
const link = document.createElement('a'); link.href = url; link.download = exported.fileName; link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice(`${zh ? '已导出修订' : 'Exported revision'} ${exported.revision} · SHA-256: ${exported.sha256}`);
});
const upload = (file?: File) => {
if (!file || !editable) return;
const mimeType = /\.ogg$/i.test(file.name) ? 'audio/ogg' : file.type;
if (!['image/png', 'audio/ogg'].includes(mimeType) || file.size > 8 * 1024 * 1024) {
setError(zh ? '仅支持不超过 8 MiB 的 PNG 贴图或 OGG Vorbis 音频。' : 'Only PNG textures or OGG Vorbis audio up to 8 MiB are supported.'); return;
}
void perform(async () => {
const dataBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]);
reader.onerror = () => reject(reader.error); reader.readAsDataURL(file);
});
const asset = await api.uploadContentAsset(file.name, mimeType, dataBase64);
setAssets((current) => [...current.filter((entry) => entry.id !== asset.id), asset]);
setNotice(zh ? '素材已上传,可在贴图或声音字段选择。保存、发布并重启生效后,客户端连接时会获取新资源。' : 'Asset uploaded. Select it, save and publish; clients fetch the new resources after the server activates them on restart.');
});
};
return <div className="page content-page">
<PageHeader eyebrow="CONTENT STUDIO" title={zh ? '内容创作工作台' : 'Content studio'}
description={zh ? '创建独立注册的物品、方块和生物;使用属性与事件行为组合玩法。' : 'Create registered items, blocks and entities; compose gameplay with properties and event behaviors.'}
actions={<><Badge tone={writable ? 'info' : 'neutral'}>{writable ? 'owner' : zh ? '只读' : 'Read only'}</Badge>
<Button variant="secondary" disabled={busy || !draft} onClick={() => void perform(async () => { if (draft) setValidation(await api.validateContent(toInput(draft))); })}><CheckCheck size={16} />{zh ? '校验' : 'Validate'}</Button>
<Button disabled={!editable || !draft || !dirty} onClick={() => void save()}><Save size={16} />{zh ? '保存草稿' : 'Save draft'} <small>Ctrl+S</small></Button></>} />
<ResourceState loading={resource.loading && !draft} error={resource.error} onRetry={resource.reload}>
{catalog && draft && view && <>
<section className="content-release-banner" aria-label={zh ? '发布状态' : 'Publication status'}>
<FileBox size={25} /><div><strong>{!draft.enabled ? (zh ? '当前草稿未启用内容模式' : 'Content mode is disabled in this draft') : view.restartRequired ? (zh ? '待同步内容包并重启' : 'Pack sync and restart required') : (zh ? '完整内容模式需要配套客户端' : 'Full content requires the companion client')}</strong>
<p>{zh ? '客户端连接前会从游戏端口检查并下载服务器当前生效的内容,无需管理员逐个分发 ZIP。首次需要玩家确认信任;注册项在下次启动时应用。受管进程支持自动重启,未接管时不会强制退出。' : 'Clients check and download active content through the game port before login, with first-use trust confirmation. Registries apply on next startup. Managed processes can restart automatically; unmanaged processes are never forced to exit.'}</p>
{view.update && <p role="status"><strong>{zh ? '自动更新检测:' : 'Update detection: '}{view.update.managed ? (zh ? '支持受管重启' : 'Managed restart ready') : (zh ? '尚未接管重启' : 'Restart not managed')}</strong>{' · '}{view.update.reason}
{view.update.state === 'SCHEDULED' && ` ${view.update.countdownSeconds} ${zh ? '秒' : 'seconds'}`}</p>}
{!draft.enabled && <p>{zh ? '当前仅能发布禁用草稿,不注册内容。请先在下方“工作台与版本恢复”中开启完整内容模式,再保存和发布。' : 'Publishing this draft will not register content. Enable full content mode under “Workspace and revision recovery” below, then save and publish.'}</p>}
<div className="content-revisions"><Badge>{zh ? '草稿' : 'Draft'} r{draft.revision}{dirty ? (zh ? ' · 未保存' : ' · Unsaved') : ''}</Badge>
<Badge>{zh ? '已发布' : 'Published'} {view.publishedRevision == null ? '—' : `r${view.publishedRevision}`}</Badge>
<Badge>{zh ? '本次启动已加载' : 'Loaded at startup'} {view.loadedRevision == null ? '—' : `r${view.loadedRevision}`}</Badge></div>
</div><div className="content-release-actions"><Button variant="secondary" disabled={!editable || dirty} onClick={() => setConfirmation({ type: 'publish' })}>{zh ? '发布已保存版本' : 'Publish saved revision'}</Button>
<Button variant="secondary" disabled={busy || view.publishedRevision == null} onClick={() => void download()}><Download size={16} />{zh ? '导出已发布安装包' : 'Export published pack'}</Button></div>
</section>
{!writable && <p className="content-help">{zh ? '仅 owner 可以修改、上传、发布或回滚内容;高风险操作由服务器要求近期双因素认证并写入审计。' : 'Only owners can edit, upload, publish or roll back content. The server requires recent two-factor authentication and audit logging.'}</p>}
{error && <div className="content-feedback content-feedback--error" role="alert">{error}<p>{zh ? '你的未保存修改仍保留在页面中。修订冲突时请先保留修改,再重新读取工作台。' : 'Your unsaved edits remain here. On a revision conflict, preserve them before reloading the workspace.'}</p></div>}
{validation && <section className={`content-feedback ${validation.valid ? '' : 'content-feedback--error'}`} aria-label={zh ? '校验结果' : 'Validation results'}>
<strong>{validation.valid ? (zh ? '校验通过' : 'Validation passed') : (zh ? '校验未通过' : 'Validation failed')}</strong>
{validation.errors.length > 0 && <ul>{validation.errors.map((item, index) => <li key={index}>{item}</li>)}</ul>}
{validation.warnings.length > 0 && <ul>{validation.warnings.map((item, index) => <li key={index}>{item}</li>)}</ul>}
</section>}
<div className="content-workspace">
<aside className="content-library panel">
<div className="content-section-heading"><h2>{zh ? '内容库' : 'Library'}</h2><Badge>{draft.definitions.length}</Badge></div>
<label className="content-search"><Search size={16} /><Input type="search" aria-label={zh ? '搜索内容' : 'Search content'} placeholder={zh ? '名称或注册 ID…' : 'Name or registry ID…'} value={query} onChange={(event) => setQuery(event.target.value)} /></label>
<div className="content-kind-filter" role="group" aria-label={zh ? '内容分类' : 'Content categories'}>
{(['ALL', 'ITEM', 'BLOCK', 'ENTITY'] as const).map((value) => <button key={value} type="button" aria-pressed={kind === value} onClick={() => setKind(value)}>{value === 'ALL' ? zh ? '全部' : 'All' : kindNames[value][zh ? 0 : 1]}</button>)}
</div>
<div className="content-list">{filtered.map(({ item, index }) => <button key={`${index}:${item.id}`} type="button" aria-pressed={selectedIndex === index} onClick={() => setSelectedIndex(index)}>
<Blocks size={18} /><span><strong>{item.name || (zh ? '未命名' : 'Untitled')}</strong><small>{item.id}</small></span><Badge>{kindNames[item.kind][zh ? 0 : 1]}</Badge>
</button>)}{filtered.length === 0 && <p className="content-help">{zh ? '没有匹配的内容。选择下面的类型或模板开始创建。' : 'No matching content. Pick a type or template below to create one.'}</p>}</div>
<Field label={zh ? '新建类型' : 'New content type'}><Select disabled={!editable} value={candidateArchetype ? `${candidateArchetype.kind}:${candidateArchetype.id}` : ''} onChange={(event) => setNewArchetype(event.target.value)}>
{candidateArchetypes.map((item) => <option key={`${item.kind}:${item.id}`} value={`${item.kind}:${item.id}`}>{kindNames[item.kind][zh ? 0 : 1]} · {localized(item, zh)}</option>)}
</Select></Field>
<Button variant="secondary" disabled={!editable || !candidateArchetype || draft.definitions.length >= 256} onClick={() => add()}><Plus size={16} />{zh ? '创建内容' : 'Create content'}</Button>
{(catalog.templates?.length ?? 0) > 0 && <><Field label={zh ? '官方玩法模板' : 'Gameplay templates'}><Select value={templateId} disabled={!editable} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">{zh ? '选择模板…' : 'Select a template…'}</option>{catalog.templates.map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
{templateId && <p className="content-help">{localized(catalog.templates.find((item) => item.id === templateId)!, zh, true)}</p>}
<Button variant="secondary" disabled={!editable || !templateId || draft.definitions.length >= 256} onClick={() => { const template = catalog.templates.find((item) => item.id === templateId); if (template) add(template.definition); }}>{zh ? '从模板创建' : 'Create from template'}</Button></>}
{draft.definitions.length >= 256 && <p className="content-help">{zh ? '已达到 256 个内容定义的上限。' : 'The 256-definition limit has been reached.'}</p>}
</aside>
<section className="content-editor" aria-label={zh ? '内容编辑器' : 'Content editor'}>
{selected ? <>
<Panel title={selected.name || (zh ? '未命名内容' : 'Untitled content')} action={<div className="button-row">
<Button variant="secondary" disabled={!editable || draft.definitions.length >= 256} onClick={() => add({ ...selected, name: `${selected.name.slice(0, 90)}${zh ? ' 副本' : ' copy'}` })}><CopyPlus size={16} />{zh ? '复制内容' : 'Duplicate'}</Button>
<Button variant="danger" disabled={!editable} onClick={() => setConfirmation({ type: 'delete' })}><Trash2 size={16} />{zh ? '删除内容' : 'Delete'}</Button></div>}>
<div className="content-fields"><Field label={zh ? '显示名称' : 'Display name'}><Input value={selected.name} maxLength={96} disabled={!editable} onChange={(event) => updateSelected({ ...selected, name: event.target.value })} /></Field>
<Field label={zh ? '注册 ID' : 'Registry ID'} hint={zh ? 'xfesmcontent:小写名称。已发布的 ID、类型与原型不可移除或更换;违规草稿会被拒绝发布,以保护世界存档。' : 'xfesmcontent:lowercase_name. Published IDs, kinds and archetypes cannot be removed or replaced; incompatible drafts are rejected at publication to protect saved worlds.'}><Input value={selected.id} maxLength={80} disabled={!editable} onChange={(event) => updateSelected({ ...selected, id: event.target.value })} /></Field>
<Field label={zh ? '内容原型' : 'Archetype'} hint={archetype ? localized(archetype, zh, true) : (zh ? '当前服务器不支持此原型;请勿丢弃已有数据。' : 'This archetype is unavailable on the server; existing data is preserved.')}>
<Select value={selected.archetype} disabled={!editable} onChange={(event) => { const next = catalog.archetypes.find((item) => item.kind === selected.kind && item.id === event.target.value); if (next) setConfirmation({ type: 'archetype', archetype: next }); }}>
{!archetype && <option value={selected.archetype}>{selected.archetype}</option>}{catalog.archetypes.filter((item) => item.kind === selected.kind).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}
</Select></Field>
<Field label={zh ? '说明' : 'Description'}><Input maxLength={2048} value={selected.description} disabled={!editable} onChange={(event) => updateSelected({ ...selected, description: event.target.value })} /></Field>
</div>
</Panel>
{archetype && <Panel title={zh ? '类型属性' : 'Type properties'} action={<Badge>{zh ? '服务端能力目录' : 'Server capability catalog'}</Badge>}>
<p className="content-help">{zh ? '下面仅列出当前平台已经实现的属性。值会在服务端再次校验;标记“重启生效”的字段不会立即修改在线世界。' : 'Only properties implemented on this platform are shown. Values are validated again on the server; restart-marked fields do not change the live world immediately.'}</p>
<div className="content-fields">{archetype.properties.map((field) => <PropertyField key={field.key} field={field} value={selected.properties[field.key] ?? field.defaultValue}
onChange={(value) => updateSelected({ ...selected, properties: { ...selected.properties, [field.key]: value } })} zh={zh} disabled={!editable} references={references} idPrefix="content-property" />)}</div>
{Object.keys(selected.properties).some((key) => !archetype.properties.some((field) => field.key === key)) && <details className="content-advanced"><summary>{zh ? '目录未识别的属性(保留,不丢弃)' : 'Unrecognized properties (preserved)'}</summary>
<pre>{JSON.stringify(Object.fromEntries(Object.entries(selected.properties).filter(([key]) => !archetype.properties.some((field) => field.key === key))), null, 2)}</pre></details>}
</Panel>}
<Panel title={zh ? '事件与自定义行为' : 'Events and custom behaviors'} action={<Button variant="secondary" disabled={!editable || selected.behaviors.length >= 32 || !catalog.events.some((event) => eventSupports(event, selected) && catalog.actions.some((action) => actionSupports(action, selected, event.id)))} onClick={addBehavior}><Plus size={16} />{zh ? '添加行为' : 'Add behavior'}</Button>}>
<p className="content-help">{zh ? '从事件 → 动作组成行为,可绑定已有菜单或触发器。复杂条件、变量与多步骤流程在触发器中编辑;这里负责连接内容事件。冷却单位为 tick(20 tick ≈ 1 秒)。' : 'Bind events to actions, existing menus or triggers. Author conditions and multi-step logic in triggers, then connect them here. Cooldowns use ticks (20 ticks ≈ 1 second).'}</p>
{selected.behaviors.length === 0 && <p className="content-empty-behavior">{zh ? '尚未绑定行为。可以添加右键开菜单、坐下、播放声音或调用触发器等已支持的动作。' : 'No behaviors yet. Add supported actions such as opening a menu, sitting, playing a sound or invoking a trigger.'}</p>}
<div className="content-behaviors">{selected.behaviors.map((behavior, index) => {
const eventDescriptor = catalog.events.find((item) => item.id === behavior.event);
const actionDescriptor = catalog.actions.find((item) => item.id === behavior.action);
return <section className="content-behavior" key={behavior.id} aria-label={`${zh ? '行为' : 'Behavior'} ${index + 1}`}>
<header><Badge>{index + 1}</Badge><strong>{eventDescriptor ? localized(eventDescriptor, zh) : behavior.event} → {actionDescriptor ? localized(actionDescriptor, zh) : behavior.action}</strong>
<Button variant="ghost" aria-label={`${zh ? '删除行为' : 'Remove behavior'} ${index + 1}`} disabled={!editable} onClick={() => updateSelected({ ...selected, behaviors: selected.behaviors.filter((_, current) => current !== index) })}><Trash2 size={16} /></Button></header>
<div className="content-fields"><Field label={zh ? '触发事件' : 'Event'} hint={eventDescriptor && localized(eventDescriptor, zh, true)}><Select disabled={!editable} value={behavior.event} onChange={(event) => {
const id = event.target.value; const supported = catalog.actions.filter((item) => actionSupports(item, selected, id));
const nextAction = supported.find((item) => item.id === behavior.action) ?? supported[0];
const canCancel = catalog.events.find((item) => item.id === id)?.cancellable === true;
updateBehavior(index, { ...behavior, event: id, cancelVanilla: canCancel && behavior.cancelVanilla,
action: nextAction?.id ?? '', parameters: nextAction?.id === behavior.action ? behavior.parameters : defaults(nextAction?.parameters ?? []) });
}}>{!catalog.events.some((item) => item.id === behavior.event && eventSupports(item, selected)) && <option value={behavior.event}>{behavior.event} · {zh ? '当前原型不支持' : 'Unavailable for this archetype'}</option>}
{catalog.events.filter((item) => eventSupports(item, selected)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
<Field label={zh ? '执行动作' : 'Action'} hint={actionDescriptor && localized(actionDescriptor, zh, true)}><Select disabled={!editable} value={behavior.action} onChange={(event) => {
const next = catalog.actions.find((item) => item.id === event.target.value);
if (next) updateBehavior(index, { ...behavior, action: next.id, parameters: defaults(next.parameters) });
}}>{!catalog.actions.some((item) => item.id === behavior.action && actionSupports(item, selected, behavior.event)) && <option value={behavior.action}>{behavior.action} · {zh ? '当前类型不支持' : 'Unavailable for this type'}</option>}
{catalog.actions.filter((item) => actionSupports(item, selected, behavior.event)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
<Field label={zh ? '冷却时间(tick)' : 'Cooldown (ticks)'} hint={`${Number.isFinite(behavior.cooldownTicks) ? behavior.cooldownTicks / 20 : '—'} ${zh ? '秒(20 TPS 时)' : 'seconds at 20 TPS'}`}><Input type="number" min={0} max={1728000} step={1} value={behavior.cooldownTicks} disabled={!editable} onChange={(event) => updateBehavior(index, { ...behavior, cooldownTicks: Number(event.target.value) })} /></Field>
<Field label={zh ? '阻止原版交互' : 'Cancel vanilla interaction'} hint={eventDescriptor?.cancellable ? (zh ? '动作触发时阻止对应原版交互,避免同时打开容器或使用物品。' : 'Prevent the corresponding vanilla interaction when the action fires.') : (zh ? '此事件不能取消。' : 'This event cannot be cancelled.')}><Select value={String(behavior.cancelVanilla)} disabled={!editable || !eventDescriptor?.cancellable} onChange={(event) => updateBehavior(index, { ...behavior, cancelVanilla: event.target.value === 'true' })}><option value="false">{zh ? '否' : 'No'}</option><option value="true">{zh ? '是' : 'Yes'}</option></Select></Field>
{actionDescriptor?.parameters.map((field) => <PropertyField key={field.key} field={field} value={behavior.parameters[field.key] ?? field.defaultValue} zh={zh} disabled={!editable} references={references} idPrefix={`behavior-${index}`}
onChange={(value) => updateBehavior(index, { ...behavior, parameters: { ...behavior.parameters, [field.key]: value } })} />)}
</div>
</section>;
})}</div>
</Panel>
</> : <Panel><EmptyState icon={<Blocks />} title={zh ? '创建你的第一个内容' : 'Create your first content'} detail={zh ? '在左侧选择物品、工具装备、生物原型,或从座椅、播放器模板开始。' : 'Choose an item, equipment, tool, entity archetype or a gameplay template on the left.'} /></Panel>}
<Panel title={zh ? '资源素材' : 'Assets'} action={<label className={`button button--secondary content-upload${editable ? '' : ' is-disabled'}`}><Upload size={16} />{zh ? '上传 PNG / OGG' : 'Upload PNG / OGG'}
<input aria-label={zh ? '上传素材文件' : 'Upload asset file'} type="file" accept="image/png,audio/ogg,.ogg" disabled={!editable} onChange={(event) => { upload(event.target.files?.[0]); event.target.value = ''; }} /></label>}>
<p className="content-help">{zh ? '每个文件最多 8 MiB,PNG 最大 2048 × 2048,音频使用 OGG Vorbis;素材库合计最多 64 MiB / 256 个。上传后在贴图或声音字段选择资源引用;实体渲染能力以目录支持为准。上传不等于客户端已同步。' : 'Up to 8 MiB per file, PNG up to 2048 × 2048, OGG Vorbis audio; the library holds up to 64 MiB / 256 assets. Select uploaded references in texture or sound fields. Entity rendering depends on catalog support. Uploading does not sync clients.'}</p>
{related.data?.unavailable && <p className="content-feedback content-feedback--error">{zh ? '部分菜单、触发器或素材目录读取失败;已有绑定仍被保留。' : 'Some menu, trigger or asset lists could not be loaded; existing bindings are preserved.'}</p>}
<div className="content-assets">{assets.map((asset) => <div key={asset.id}>{asset.mimeType.startsWith('audio/') ? <Music2 size={18} /> : <FileBox size={18} />}<span><strong>{asset.name}</strong><code>{asset.resourceLocation}</code></span><small>{formatBytes(asset.bytes)}</small></div>)}</div>
</Panel>
<Panel title={zh ? '工作台与版本恢复' : 'Workspace and revision recovery'}>
<div className="content-fields"><Field label={zh ? '启用完整内容模式' : 'Enable full content mode'} hint={zh ? '此开关属于草稿,需要保存并发布。一旦发布启用的内容包,服务端会禁止发布关闭状态或移除既有 ID;修改前仍建议备份世界。' : 'This draft setting requires saving and publishing. Once an enabled pack is published, disabling it or removing registered IDs is rejected. Back up your world before changes.'}><Select value={String(draft.enabled)} disabled={!editable} onChange={(event) => edit({ ...draft, enabled: event.target.value === 'true' })}><option value="false">{zh ? '未启用' : 'Disabled'}</option><option value="true">{zh ? '已启用' : 'Enabled'}</option></Select></Field>
<Field label={zh ? '要恢复的历史修订号' : 'Revision to restore'} hint={zh ? '恢复会创建新修订,不会直接修改在线世界。请使用实际存在的修订号。' : 'Restoration creates a new revision; it does not directly change the live world. Use an existing revision number.'}><Input type="number" min={0} max={Math.max(0, draft.revision - 1)} step={1} disabled={!editable} value={rollbackRevision} onChange={(event) => setRollbackRevision(event.target.value)} /></Field></div>
<div className="button-row"><Button variant="secondary" disabled={!editable || dirty || rollbackRevision === '' || !Number.isInteger(Number(rollbackRevision)) || Number(rollbackRevision) < 0 || Number(rollbackRevision) >= draft.revision} onClick={() => setConfirmation({ type: 'rollback' })}><RotateCcw size={16} />{zh ? '恢复历史修订' : 'Restore revision'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => setConfirmation({ type: 'reload' })}>{zh ? '重新读取工作台' : 'Reload workspace'}</Button></div>
</Panel>
</section>
</div>
</>}
</ResourceState>
{notice && <Toast message={notice} onClose={() => setNotice('')} />}
{confirmation && <ModalPortal><div className="modal-backdrop"><section ref={dialogRef} className="modal content-confirm" role="dialog" aria-modal="true" aria-labelledby="content-confirm-title">
<header><h2 id="content-confirm-title">{zh ? '确认内容变更' : 'Confirm content change'}</h2><Button variant="ghost" aria-label={zh ? '关闭确认' : 'Close confirmation'} onClick={() => setConfirmation(undefined)}><X size={18} /></Button></header>
<p>{confirmation.type === 'delete' ? (zh ? `删除“${selected?.name}”的草稿定义?此操作不清理游戏对象。为了保护旧世界存档,已发布过的 ID 必须保留:如果删除的是已发布内容,后续发布会被服务器拒绝,需恢复该定义。` : `Delete the draft definition “${selected?.name}”? This does not remove game objects. To protect saved worlds, published IDs must be retained: deleting a published definition causes the server to reject publication until it is restored.`)
: confirmation.type === 'archetype' ? (zh ? `切换到“${localized(confirmation.archetype, zh)}”?保留有效共有属性,超出新范围的属性恢复默认值,移除新原型不支持的属性。切换后请重新校验事件和动作绑定。` : `Switch to “${localized(confirmation.archetype, zh)}”? Valid shared properties are retained, out-of-range values use new defaults and unsupported properties are removed. Validate event and action bindings again afterwards.`)
: confirmation.type === 'reload' ? (zh ? '重新读取服务器草稿?本页面所有未保存修改将被放弃。' : 'Reload the server draft? All unsaved edits on this page will be discarded.')
: confirmation.type === 'rollback' ? (zh ? `将当前内容恢复为历史修订 r${rollbackRevision}?这会新增修订;发布和客户端同步状态请在完成后核对。` : `Restore revision r${rollbackRevision}? This creates a new revision. Check publication and client sync status afterwards.`)
: (zh ? `发布修订 r${draft?.revision}(${draft?.definitions.length} 个内容,${draft?.enabled ? '已启用' : '未启用'})?服务器会校验近期双因素认证并审计。内容定义、行为和素材将公开供客户端在登录前下载,请勿包含秘密。${view?.update?.managed ? '发布后 30 秒将保存世界、数据库并自动重启服务器。' : '尚未接管自动重启;发布后服务器会保持运行,需通过现有启动方式重启。'}客户端获取的是服务器重启后实际生效的版本。` : `Publish revision r${draft?.revision} (${draft?.definitions.length} definitions, ${draft?.enabled ? 'enabled' : 'disabled'})? Recent two-factor authentication and auditing are required. Definitions, behaviors and assets will be public before login; do not include secrets. ${view?.update?.managed ? 'The server will save and restart automatically after 30 seconds.' : 'Restart is not managed; the server stays running until restarted through its existing launcher.'} Clients fetch the active server revision.`)}</p>
{confirmation.type === 'publish' && !draft?.enabled && <p className="content-feedback content-feedback--error" role="alert">{zh ? '仅发布禁用草稿,不注册内容。即使已经创建物品、方块或生物,它们也不会在游戏中出现;要启用,请取消并先开启完整内容模式、保存后再发布。' : 'This only publishes a disabled draft; no content is registered. Created items, blocks and entities will not appear in game. Cancel, enable full content mode and save before publishing to activate them.'}</p>}
<div className="button-row"><Button variant="secondary" onClick={() => setConfirmation(undefined)}>{zh ? '取消' : 'Cancel'}</Button><Button variant={confirmation.type === 'delete' ? 'danger' : 'primary'} disabled={busy || (!writable && confirmation.type !== 'reload')} onClick={confirm}>{zh ? '确认继续' : 'Confirm'}</Button></div>
</section></div></ModalPortal>}
</div>;
}
import { Blocks, CheckCheck, CopyPlus, Download, FileBox, Music2, Plus, RotateCcw, Save, Search, Trash2, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { Badge, Button, EmptyState, Field, Input, PageHeader, Panel, ResourceState, Select, Toast, formatBytes } from '../components/ui';
import { useApiResource, useServer } from '../context/server-context';
import { useI18n } from '../lib/i18n';
import { hasCapability } from '../lib/permissions';
import type { ContentArchetype, ContentAsset, ContentBehavior, ContentCatalog, ContentDefinition, ContentInput, ContentKind, ContentLocalized, ContentProperty, ContentValidation, ContentWorkspace, ContentWorkspaceView } from '../types';
const kindNames: Record<ContentKind, [string, string]> = { ITEM: ['物品', 'Items'], BLOCK: ['方块', 'Blocks'], ENTITY: ['生物', 'Entities'] };
const optionNames: Record<string, string> = {
common: '普通', uncommon: '优秀', rare: '稀有', epic: '史诗',
wood: '木质', wooden: '木质', stone: '石质', iron: '铁质', gold: '金质', golden: '金质', diamond: '钻石', netherite: '下界合金',
leather: '皮革', chainmail: '锁链', turtle: '海龟',
never: '从不', always: '总是', true: '是', false: '否',
};
const message = (cause: unknown) => cause instanceof Error ? cause.message : String(cause);
const localized = (entry: ContentLocalized, zh: boolean, description = false) => description
? (zh ? entry.descriptionZh : entry.descriptionEn) : (zh ? entry.nameZh : entry.nameEn);
const defaults = (properties: ContentProperty[]) => Object.fromEntries(properties.map((field) => [field.key, field.defaultValue]));
const toInput = (workspace: ContentWorkspace): ContentInput => ({
expectedRevision: workspace.revision, enabled: workspace.enabled, definitions: workspace.definitions,
});
const eventSupports = (event: ContentCatalog['events'][number], definition: ContentDefinition) => event.kinds.includes(definition.kind)
&& (event.archetypes.length === 0 || event.archetypes.includes(definition.archetype));
const actionSupports = (action: ContentCatalog['actions'][number], definition: ContentDefinition, event: string) =>
action.kinds.includes(definition.kind) && action.events.includes(event);
function compatibleValue(field: ContentProperty, value: string | undefined): string {
if (value === undefined) return field.defaultValue;
if (field.options.length && !field.options.includes(value)) return field.defaultValue;
if (['int', 'integer', 'number'].includes(field.type)
&& (value.trim() === '' || !Number.isFinite(Number(value)) || (field.type !== 'number' && !Number.isInteger(Number(value)))
|| (field.min !== null && Number(value) < field.min) || (field.max !== null && Number(value) > field.max))) return field.defaultValue;
return value;
}
function uniqueId(existing: ContentDefinition[], stem: string): string {
const base = stem.replace(/^.*:/, '').replace(/[^a-z0-9_]/g, '_').slice(0, 48) || 'content';
let number = 1;
while (existing.some((item) => item.id === `xfesmcontent:${base}_${number}`)) number += 1;
return `xfesmcontent:${base}_${number}`;
}
interface References {
menus: Array<{ id: string; name: string }>;
triggers: Array<{ id: string; name: string }>;
assets: ContentAsset[];
}
function PropertyField({ field, value, onChange, zh, disabled, references, idPrefix }: {
field: ContentProperty; value: string; onChange: (value: string) => void;
zh: boolean; disabled: boolean; references: References; idPrefix: string;
}) {
const name = localized(field, zh) || field.key;
const source = field.type === 'menu_ref' ? references.menus : field.type === 'trigger_ref' ? references.triggers : undefined;
const optionList = field.options ?? [];
const listId = `${idPrefix}-${field.key}`;
let control;
if (source) {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
<option value="">{zh ? '请选择已有对象' : 'Select an existing object'}</option>
{value && !source.some((item) => item.id === value) && <option value={value}>{zh ? '未找到 / 无权读取:' : 'Missing / unavailable: '}{value}</option>}
{source.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.id.slice(0, 8)}</option>)}
</Select>;
} else if (field.type === 'boolean') {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
<option value="true">{zh ? '是' : 'Yes'}</option><option value="false">{zh ? '否' : 'No'}</option>
</Select>;
} else if (optionList.length > 0 || field.type === 'select') {
control = <Select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{!optionList.includes(value) && <option value={value}>{value || (zh ? '请选择' : 'Select')}</option>}
{optionList.map((option) => <option key={option} value={option}>{zh ? optionNames[option] ?? option : option}</option>)}
</Select>;
} else {
const numeric = ['int', 'integer', 'number'].includes(field.type);
control = <Input type={numeric ? 'number' : 'text'} value={value} disabled={disabled} maxLength={1024}
min={numeric ? field.min ?? undefined : undefined} max={numeric ? field.max ?? undefined : undefined}
step={numeric ? field.type === 'number' ? 'any' : '1' : undefined}
list={field.type === 'resource' ? listId : undefined}
onChange={(event) => onChange(event.target.value)} />;
}
return <div className="content-property"><Field label={<>{name}{field.requiresRestart && <small>{zh ? '重启生效' : 'Restart'}</small>}</>}
hint={localized(field, zh, true)}>{control}</Field>
{source?.length === 0 && <p className="content-help">{zh ? '请先在 UI 菜单或触发器页面创建对象,并确认读取权限。' : 'Create the menu or trigger first, and check read permissions.'}</p>}
{field.type === 'resource' && <datalist id={listId}>{references.assets
.filter((asset) => /sound|music/.test(field.key) ? asset.mimeType.startsWith('audio/') : asset.mimeType === 'image/png')
.map((asset) => <option key={asset.id} value={asset.resourceLocation}>{asset.name}</option>)}</datalist>}
</div>;
}
type Confirmation = { type: 'delete' | 'publish' | 'rollback' | 'reload' } | { type: 'archetype'; archetype: ContentArchetype };
export function ContentPage() {
const { api, session } = useServer();
const { locale } = useI18n();
const zh = locale === 'zh-CN';
const writable = session?.actor?.roles.includes('owner') === true;
const resource = useApiResource(() => Promise.all([api.contentCatalog(), api.contentWorkspace()]), [api]);
const related = useApiResource(async () => {
const [menus, triggers, assets] = await Promise.allSettled([
hasCapability(session?.actor, 'menu_read') ? api.menuWorkspace().then((value) => value.menus) : Promise.resolve([]),
api.triggerWorkspace().then((value) => value.groups.flatMap((group) => group.triggers)), api.contentAssets(),
]);
return {
menus: menus.status === 'fulfilled' ? menus.value : [], triggers: triggers.status === 'fulfilled' ? triggers.value : [],
assets: assets.status === 'fulfilled' ? assets.value : [],
unavailable: [menus, triggers, assets].some((result) => result.status === 'rejected'),
};
}, [api, session?.actor?.id]);
const catalog = resource.data?.[0];
const [view, setView] = useState<ContentWorkspaceView>();
const [draft, setDraft] = useState<ContentWorkspace>();
const [dirty, setDirty] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [kind, setKind] = useState<ContentKind | 'ALL'>('ALL');
const [query, setQuery] = useState('');
const [newArchetype, setNewArchetype] = useState('');
const [templateId, setTemplateId] = useState('');
const [validation, setValidation] = useState<ContentValidation>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [confirmation, setConfirmation] = useState<Confirmation>();
const dialogRef = useRef<HTMLElement>(null);
const [rollbackRevision, setRollbackRevision] = useState('');
const [assets, setAssets] = useState<ContentAsset[]>([]);
const references: References = { menus: related.data?.menus ?? [], triggers: related.data?.triggers ?? [], assets };
const selected = draft?.definitions[selectedIndex];
const archetype = catalog?.archetypes.find((item) => item.kind === selected?.kind && item.id === selected.archetype);
const editable = writable && !busy;
const candidateArchetypes = catalog?.archetypes.filter((item) => kind === 'ALL' || item.kind === kind) ?? [];
const candidateArchetype = candidateArchetypes.find((item) => `${item.kind}:${item.id}` === newArchetype) ?? candidateArchetypes[0];
const filtered = useMemo(() => draft?.definitions.map((item, index) => ({ item, index }))
.filter(({ item }) => (kind === 'ALL' || item.kind === kind)
&& `${item.name} ${item.id} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [], [draft, kind, query]);
useEffect(() => {
if (!resource.data) return;
setView((current) => current ?? resource.data![1]);
setDraft((current) => current ?? resource.data![1].workspace);
}, [resource.data]);
useEffect(() => {
if (!view?.update || !['SCHEDULED', 'ARMING', 'SAVING', 'RESTARTING'].includes(view.update.state)) return;
let cancelled = false;
let inFlight = false;
const timer = window.setInterval(() => {
if (inFlight) return;
inFlight = true;
void api.contentWorkspace().then((next) => {
if (!cancelled) setView(next); // never replace the user's unsaved draft during a countdown
}).catch(() => { /* a safe restart temporarily disconnects the control plane */ }).finally(() => { inFlight = false; });
}, 3000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [api, view?.update?.state]);
useEffect(() => {
if (related.data) setAssets((current) => Array.from(new Map([...related.data!.assets, ...current].map((asset) => [asset.id, asset])).values()));
}, [related.data]);
useEffect(() => {
if (!dirty) return;
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ''; };
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, [dirty]);
useEffect(() => {
if (!confirmation) return;
const previous = document.activeElement;
const dialog = dialogRef.current;
const focusable = () => Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]') ?? []);
focusable()[0]?.focus();
const handle = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.preventDefault(); setConfirmation(undefined); }
if (event.key === 'Tab') {
const elements = focusable(); const first = elements[0]; const last = elements.at(-1);
if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) { event.preventDefault(); first?.focus(); }
}
};
document.addEventListener('keydown', handle);
return () => { document.removeEventListener('keydown', handle); if (previous instanceof HTMLElement) previous.focus(); };
}, [confirmation]);
const edit = (next: ContentWorkspace) => { setDraft(next); setDirty(true); setValidation(undefined); setNotice(''); };
const updateSelected = (next: ContentDefinition) => {
if (draft) edit({ ...draft, definitions: draft.definitions.map((item, index) => index === selectedIndex ? next : item) });
};
const accept = (next: ContentWorkspaceView) => { setView(next); setDraft(next.workspace); setDirty(false); setValidation(undefined); };
const perform = async (task: () => Promise<void>) => {
if (busy) return;
setBusy(true); setError(''); setNotice('');
try { await task(); } catch (cause) { setError(message(cause)); } finally { setBusy(false); }
};
const save = useCallback(async () => {
if (!draft || !writable || busy) return;
setBusy(true); setError(''); setNotice('');
try {
const result = await api.validateContent(toInput(draft));
setValidation(result);
if (!result.valid) return;
const saved = await api.saveContent(toInput(draft));
setView(saved); setDraft(saved.workspace); setDirty(false);
setNotice(zh ? '草稿已保存。发布、同步内容包并重启后才会在游戏中生效。' : 'Draft saved. Publish, sync the pack and restart to apply it in game.');
} catch (cause) { setError(message(cause)); } finally { setBusy(false); }
}, [api, draft, writable, busy, zh]);
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); if (!confirmation) void save(); }
};
document.addEventListener('keydown', handle);
return () => document.removeEventListener('keydown', handle);
}, [save, confirmation]);
const add = (definition?: ContentDefinition) => {
if (!draft || !editable || draft.definitions.length >= 256 || (!definition && !candidateArchetype)) return;
const target = candidateArchetype;
const created: ContentDefinition = definition ? structuredClone(definition) : {
id: '', kind: target.kind, archetype: target.id, name: localized(target, zh), description: '',
properties: defaults(target.properties), behaviors: [],
};
created.id = uniqueId(draft.definitions, definition?.id || created.kind.toLowerCase());
created.behaviors = created.behaviors.map((behavior, index) => ({ ...behavior, id: `behavior_${index + 1}` }));
edit({ ...draft, definitions: [...draft.definitions, created] });
setSelectedIndex(draft.definitions.length); setKind('ALL'); setQuery('');
};
const updateBehavior = (index: number, value: ContentBehavior) => {
if (selected) updateSelected({ ...selected, behaviors: selected.behaviors.map((item, current) => current === index ? value : item) });
};
const addBehavior = () => {
if (!selected || !catalog || selected.behaviors.length >= 32) return;
const event = catalog.events.find((item) => eventSupports(item, selected)
&& catalog.actions.some((action) => actionSupports(action, selected, item.id)));
const action = catalog.actions.find((item) => event && actionSupports(item, selected, event.id));
if (!event || !action) return;
let id = 1; while (selected.behaviors.some((item) => item.id === `behavior_${id}`)) id += 1;
updateSelected({ ...selected, behaviors: [...selected.behaviors, { id: `behavior_${id}`, event: event.id,
action: action.id, parameters: defaults(action.parameters), cooldownTicks: 0, cancelVanilla: false }] });
};
const confirm = () => {
if (!confirmation || !draft || busy || (!writable && confirmation.type !== 'reload')) return;
const current = confirmation; setConfirmation(undefined);
if (current.type === 'delete') {
edit({ ...draft, definitions: draft.definitions.filter((_, index) => index !== selectedIndex) });
setSelectedIndex(Math.max(0, selectedIndex - 1));
} else if (current.type === 'archetype' && selected) {
const next = current.archetype;
updateSelected({ ...selected, archetype: next.id,
properties: Object.fromEntries(next.properties.map((field) => [field.key, compatibleValue(field, selected.properties[field.key])])) });
} else if (current.type === 'reload') {
void perform(async () => { accept(await api.contentWorkspace()); setSelectedIndex(0); });
} else if (current.type === 'publish') {
void perform(async () => {
const result = await api.validateContent(toInput(draft)); setValidation(result);
if (!result.valid) return;
const published = await api.publishContent(draft.revision);
accept(published);
setNotice(!draft.enabled
? (zh ? '已发布禁用草稿,不会注册内容。要启用新内容,请先开启完整内容模式,保存并重新发布。' : 'Published a disabled draft; no content will be registered. Enable full content mode, save and publish again to activate new content.')
: published.update?.state === 'SCHEDULED'
? (zh ? '版本已发布,服务器将在 30 秒后保存并自动重启。客户端再次连接时会检查并下载当前生效版本。' : 'Published. The managed server will save and restart in 30 seconds. Clients check and download the active version when connecting.')
: (zh ? '版本已发布。客户端会自动检查和下载;服务端尚未接管自动重启,请查看下方检测结果。' : 'Published. Clients check and download automatically; inspect restart detection below for server activation.'));
});
} else if (current.type === 'rollback') {
void perform(async () => {
accept(await api.rollbackContent(draft.revision, Number(rollbackRevision))); setSelectedIndex(0);
setNotice(zh ? '已恢复历史修订。请核对发布状态;已发布的内容仍需重新同步并重启。' : 'Revision restored. Check publication status; published content still requires pack sync and restart.');
});
}
};
const download = () => perform(async () => {
const exported = await api.exportContent();
const bytes = Uint8Array.from(atob(exported.dataBase64), (character) => character.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: exported.mimeType }));
const link = document.createElement('a'); link.href = url; link.download = exported.fileName; link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice(`${zh ? '已导出修订' : 'Exported revision'} ${exported.revision} · SHA-256: ${exported.sha256}`);
});
const upload = (file?: File) => {
if (!file || !editable) return;
const mimeType = /\.ogg$/i.test(file.name) ? 'audio/ogg' : file.type;
if (!['image/png', 'audio/ogg'].includes(mimeType) || file.size > 8 * 1024 * 1024) {
setError(zh ? '仅支持不超过 8 MiB 的 PNG 贴图或 OGG Vorbis 音频。' : 'Only PNG textures or OGG Vorbis audio up to 8 MiB are supported.'); return;
}
void perform(async () => {
const dataBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]);
reader.onerror = () => reject(reader.error); reader.readAsDataURL(file);
});
const asset = await api.uploadContentAsset(file.name, mimeType, dataBase64);
setAssets((current) => [...current.filter((entry) => entry.id !== asset.id), asset]);
setNotice(zh ? '素材已上传,可在贴图或声音字段选择。保存、发布并重启生效后,客户端连接时会获取新资源。' : 'Asset uploaded. Select it, save and publish; clients fetch the new resources after the server activates them on restart.');
});
};
return <div className="page content-page">
<PageHeader eyebrow="CONTENT STUDIO" title={zh ? '内容创作工作台' : 'Content studio'}
description={zh ? '创建独立注册的物品、方块和生物;使用属性与事件行为组合玩法。' : 'Create registered items, blocks and entities; compose gameplay with properties and event behaviors.'}
actions={<><Badge tone={writable ? 'info' : 'neutral'}>{writable ? 'owner' : zh ? '只读' : 'Read only'}</Badge>
<Button variant="secondary" disabled={busy || !draft} onClick={() => void perform(async () => { if (draft) setValidation(await api.validateContent(toInput(draft))); })}><CheckCheck size={16} />{zh ? '校验' : 'Validate'}</Button>
<Button disabled={!editable || !draft || !dirty} onClick={() => void save()}><Save size={16} />{zh ? '保存草稿' : 'Save draft'} <small>Ctrl+S</small></Button></>} />
<ResourceState loading={resource.loading && !draft} error={resource.error} onRetry={resource.reload}>
{catalog && draft && view && <>
<section className="content-release-banner" aria-label={zh ? '发布状态' : 'Publication status'}>
<FileBox size={25} /><div><strong>{!draft.enabled ? (zh ? '当前草稿未启用内容模式' : 'Content mode is disabled in this draft') : view.restartRequired ? (zh ? '待同步内容包并重启' : 'Pack sync and restart required') : (zh ? '完整内容模式需要配套客户端' : 'Full content requires the companion client')}</strong>
<p>{zh ? '客户端连接前会从游戏端口检查并下载服务器当前生效的内容,无需管理员逐个分发 ZIP。首次需要玩家确认信任;注册项在下次启动时应用。受管进程支持自动重启,未接管时不会强制退出。' : 'Clients check and download active content through the game port before login, with first-use trust confirmation. Registries apply on next startup. Managed processes can restart automatically; unmanaged processes are never forced to exit.'}</p>
{view.update && <p role="status"><strong>{zh ? '自动更新检测:' : 'Update detection: '}{view.update.managed ? (zh ? '支持受管重启' : 'Managed restart ready') : (zh ? '尚未接管重启' : 'Restart not managed')}</strong>{' · '}{view.update.reason}
{view.update.state === 'SCHEDULED' && ` ${view.update.countdownSeconds} ${zh ? '秒' : 'seconds'}`}</p>}
{!draft.enabled && <p>{zh ? '当前仅能发布禁用草稿,不注册内容。请先在下方“工作台与版本恢复”中开启完整内容模式,再保存和发布。' : 'Publishing this draft will not register content. Enable full content mode under “Workspace and revision recovery” below, then save and publish.'}</p>}
<div className="content-revisions"><Badge>{zh ? '草稿' : 'Draft'} r{draft.revision}{dirty ? (zh ? ' · 未保存' : ' · Unsaved') : ''}</Badge>
<Badge>{zh ? '已发布' : 'Published'} {view.publishedRevision == null ? '—' : `r${view.publishedRevision}`}</Badge>
<Badge>{zh ? '本次启动已加载' : 'Loaded at startup'} {view.loadedRevision == null ? '—' : `r${view.loadedRevision}`}</Badge></div>
</div><div className="content-release-actions"><Button variant="secondary" disabled={!editable || dirty} onClick={() => setConfirmation({ type: 'publish' })}>{zh ? '发布已保存版本' : 'Publish saved revision'}</Button>
<Button variant="secondary" disabled={busy || view.publishedRevision == null} onClick={() => void download()}><Download size={16} />{zh ? '导出已发布安装包' : 'Export published pack'}</Button></div>
</section>
{!writable && <p className="content-help">{zh ? '仅 owner 可以修改、上传、发布或回滚内容;高风险操作由服务器要求近期双因素认证并写入审计。' : 'Only owners can edit, upload, publish or roll back content. The server requires recent two-factor authentication and audit logging.'}</p>}
{error && <div className="content-feedback content-feedback--error" role="alert">{error}<p>{zh ? '你的未保存修改仍保留在页面中。修订冲突时请先保留修改,再重新读取工作台。' : 'Your unsaved edits remain here. On a revision conflict, preserve them before reloading the workspace.'}</p></div>}
{validation && <section className={`content-feedback ${validation.valid ? '' : 'content-feedback--error'}`} aria-label={zh ? '校验结果' : 'Validation results'}>
<strong>{validation.valid ? (zh ? '校验通过' : 'Validation passed') : (zh ? '校验未通过' : 'Validation failed')}</strong>
{validation.errors.length > 0 && <ul>{validation.errors.map((item, index) => <li key={index}>{item}</li>)}</ul>}
{validation.warnings.length > 0 && <ul>{validation.warnings.map((item, index) => <li key={index}>{item}</li>)}</ul>}
</section>}
<div className="content-workspace">
<aside className="content-library panel">
<div className="content-section-heading"><h2>{zh ? '内容库' : 'Library'}</h2><Badge>{draft.definitions.length}</Badge></div>
<label className="content-search"><Search size={16} /><Input type="search" aria-label={zh ? '搜索内容' : 'Search content'} placeholder={zh ? '名称或注册 ID…' : 'Name or registry ID…'} value={query} onChange={(event) => setQuery(event.target.value)} /></label>
<div className="content-kind-filter" role="group" aria-label={zh ? '内容分类' : 'Content categories'}>
{(['ALL', 'ITEM', 'BLOCK', 'ENTITY'] as const).map((value) => <button key={value} type="button" aria-pressed={kind === value} onClick={() => setKind(value)}>{value === 'ALL' ? zh ? '全部' : 'All' : kindNames[value][zh ? 0 : 1]}</button>)}
</div>
<div className="content-list">{filtered.map(({ item, index }) => <button key={`${index}:${item.id}`} type="button" aria-pressed={selectedIndex === index} onClick={() => setSelectedIndex(index)}>
<Blocks size={18} /><span><strong>{item.name || (zh ? '未命名' : 'Untitled')}</strong><small>{item.id}</small></span><Badge>{kindNames[item.kind][zh ? 0 : 1]}</Badge>
</button>)}{filtered.length === 0 && <p className="content-help">{zh ? '没有匹配的内容。选择下面的类型或模板开始创建。' : 'No matching content. Pick a type or template below to create one.'}</p>}</div>
<Field label={zh ? '新建类型' : 'New content type'}><Select disabled={!editable} value={candidateArchetype ? `${candidateArchetype.kind}:${candidateArchetype.id}` : ''} onChange={(event) => setNewArchetype(event.target.value)}>
{candidateArchetypes.map((item) => <option key={`${item.kind}:${item.id}`} value={`${item.kind}:${item.id}`}>{kindNames[item.kind][zh ? 0 : 1]} · {localized(item, zh)}</option>)}
</Select></Field>
<Button variant="secondary" disabled={!editable || !candidateArchetype || draft.definitions.length >= 256} onClick={() => add()}><Plus size={16} />{zh ? '创建内容' : 'Create content'}</Button>
{(catalog.templates?.length ?? 0) > 0 && <><Field label={zh ? '官方玩法模板' : 'Gameplay templates'}><Select value={templateId} disabled={!editable} onChange={(event) => setTemplateId(event.target.value)}>
<option value="">{zh ? '选择模板…' : 'Select a template…'}</option>{catalog.templates.map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
{templateId && <p className="content-help">{localized(catalog.templates.find((item) => item.id === templateId)!, zh, true)}</p>}
<Button variant="secondary" disabled={!editable || !templateId || draft.definitions.length >= 256} onClick={() => { const template = catalog.templates.find((item) => item.id === templateId); if (template) add(template.definition); }}>{zh ? '从模板创建' : 'Create from template'}</Button></>}
{draft.definitions.length >= 256 && <p className="content-help">{zh ? '已达到 256 个内容定义的上限。' : 'The 256-definition limit has been reached.'}</p>}
</aside>
<section className="content-editor" aria-label={zh ? '内容编辑器' : 'Content editor'}>
{selected ? <>
<Panel title={selected.name || (zh ? '未命名内容' : 'Untitled content')} action={<div className="button-row">
<Button variant="secondary" disabled={!editable || draft.definitions.length >= 256} onClick={() => add({ ...selected, name: `${selected.name.slice(0, 90)}${zh ? ' 副本' : ' copy'}` })}><CopyPlus size={16} />{zh ? '复制内容' : 'Duplicate'}</Button>
<Button variant="danger" disabled={!editable} onClick={() => setConfirmation({ type: 'delete' })}><Trash2 size={16} />{zh ? '删除内容' : 'Delete'}</Button></div>}>
<div className="content-fields"><Field label={zh ? '显示名称' : 'Display name'}><Input value={selected.name} maxLength={96} disabled={!editable} onChange={(event) => updateSelected({ ...selected, name: event.target.value })} /></Field>
<Field label={zh ? '注册 ID' : 'Registry ID'} hint={zh ? 'xfesmcontent:小写名称。已发布的 ID、类型与原型不可移除或更换;违规草稿会被拒绝发布,以保护世界存档。' : 'xfesmcontent:lowercase_name. Published IDs, kinds and archetypes cannot be removed or replaced; incompatible drafts are rejected at publication to protect saved worlds.'}><Input value={selected.id} maxLength={80} disabled={!editable} onChange={(event) => updateSelected({ ...selected, id: event.target.value })} /></Field>
<Field label={zh ? '内容原型' : 'Archetype'} hint={archetype ? localized(archetype, zh, true) : (zh ? '当前服务器不支持此原型;请勿丢弃已有数据。' : 'This archetype is unavailable on the server; existing data is preserved.')}>
<Select value={selected.archetype} disabled={!editable} onChange={(event) => { const next = catalog.archetypes.find((item) => item.kind === selected.kind && item.id === event.target.value); if (next) setConfirmation({ type: 'archetype', archetype: next }); }}>
{!archetype && <option value={selected.archetype}>{selected.archetype}</option>}{catalog.archetypes.filter((item) => item.kind === selected.kind).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}
</Select></Field>
<Field label={zh ? '说明' : 'Description'}><Input maxLength={2048} value={selected.description} disabled={!editable} onChange={(event) => updateSelected({ ...selected, description: event.target.value })} /></Field>
</div>
</Panel>
{archetype && <Panel title={zh ? '类型属性' : 'Type properties'} action={<Badge>{zh ? '服务端能力目录' : 'Server capability catalog'}</Badge>}>
<p className="content-help">{zh ? '下面仅列出当前平台已经实现的属性。值会在服务端再次校验;标记“重启生效”的字段不会立即修改在线世界。' : 'Only properties implemented on this platform are shown. Values are validated again on the server; restart-marked fields do not change the live world immediately.'}</p>
<div className="content-fields">{archetype.properties.map((field) => <PropertyField key={field.key} field={field} value={selected.properties[field.key] ?? field.defaultValue}
onChange={(value) => updateSelected({ ...selected, properties: { ...selected.properties, [field.key]: value } })} zh={zh} disabled={!editable} references={references} idPrefix="content-property" />)}</div>
{Object.keys(selected.properties).some((key) => !archetype.properties.some((field) => field.key === key)) && <details className="content-advanced"><summary>{zh ? '目录未识别的属性(保留,不丢弃)' : 'Unrecognized properties (preserved)'}</summary>
<pre>{JSON.stringify(Object.fromEntries(Object.entries(selected.properties).filter(([key]) => !archetype.properties.some((field) => field.key === key))), null, 2)}</pre></details>}
</Panel>}
<Panel title={zh ? '事件与自定义行为' : 'Events and custom behaviors'} action={<Button variant="secondary" disabled={!editable || selected.behaviors.length >= 32 || !catalog.events.some((event) => eventSupports(event, selected) && catalog.actions.some((action) => actionSupports(action, selected, event.id)))} onClick={addBehavior}><Plus size={16} />{zh ? '添加行为' : 'Add behavior'}</Button>}>
<p className="content-help">{zh ? '从事件 → 动作组成行为,可绑定已有菜单或触发器。复杂条件、变量与多步骤流程在触发器中编辑;这里负责连接内容事件。冷却单位为 tick(20 tick ≈ 1 秒)。' : 'Bind events to actions, existing menus or triggers. Author conditions and multi-step logic in triggers, then connect them here. Cooldowns use ticks (20 ticks ≈ 1 second).'}</p>
{selected.behaviors.length === 0 && <p className="content-empty-behavior">{zh ? '尚未绑定行为。可以添加右键开菜单、坐下、播放声音或调用触发器等已支持的动作。' : 'No behaviors yet. Add supported actions such as opening a menu, sitting, playing a sound or invoking a trigger.'}</p>}
<div className="content-behaviors">{selected.behaviors.map((behavior, index) => {
const eventDescriptor = catalog.events.find((item) => item.id === behavior.event);
const actionDescriptor = catalog.actions.find((item) => item.id === behavior.action);
return <section className="content-behavior" key={behavior.id} aria-label={`${zh ? '行为' : 'Behavior'} ${index + 1}`}>
<header><Badge>{index + 1}</Badge><strong>{eventDescriptor ? localized(eventDescriptor, zh) : behavior.event} → {actionDescriptor ? localized(actionDescriptor, zh) : behavior.action}</strong>
<Button variant="ghost" aria-label={`${zh ? '删除行为' : 'Remove behavior'} ${index + 1}`} disabled={!editable} onClick={() => updateSelected({ ...selected, behaviors: selected.behaviors.filter((_, current) => current !== index) })}><Trash2 size={16} /></Button></header>
<div className="content-fields"><Field label={zh ? '触发事件' : 'Event'} hint={eventDescriptor && localized(eventDescriptor, zh, true)}><Select disabled={!editable} value={behavior.event} onChange={(event) => {
const id = event.target.value; const supported = catalog.actions.filter((item) => actionSupports(item, selected, id));
const nextAction = supported.find((item) => item.id === behavior.action) ?? supported[0];
const canCancel = catalog.events.find((item) => item.id === id)?.cancellable === true;
updateBehavior(index, { ...behavior, event: id, cancelVanilla: canCancel && behavior.cancelVanilla,
action: nextAction?.id ?? '', parameters: nextAction?.id === behavior.action ? behavior.parameters : defaults(nextAction?.parameters ?? []) });
}}>{!catalog.events.some((item) => item.id === behavior.event && eventSupports(item, selected)) && <option value={behavior.event}>{behavior.event} · {zh ? '当前原型不支持' : 'Unavailable for this archetype'}</option>}
{catalog.events.filter((item) => eventSupports(item, selected)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
<Field label={zh ? '执行动作' : 'Action'} hint={actionDescriptor && localized(actionDescriptor, zh, true)}><Select disabled={!editable} value={behavior.action} onChange={(event) => {
const next = catalog.actions.find((item) => item.id === event.target.value);
if (next) updateBehavior(index, { ...behavior, action: next.id, parameters: defaults(next.parameters) });
}}>{!catalog.actions.some((item) => item.id === behavior.action && actionSupports(item, selected, behavior.event)) && <option value={behavior.action}>{behavior.action} · {zh ? '当前类型不支持' : 'Unavailable for this type'}</option>}
{catalog.actions.filter((item) => actionSupports(item, selected, behavior.event)).map((item) => <option key={item.id} value={item.id}>{localized(item, zh)}</option>)}</Select></Field>
<Field label={zh ? '冷却时间(tick)' : 'Cooldown (ticks)'} hint={`${Number.isFinite(behavior.cooldownTicks) ? behavior.cooldownTicks / 20 : '—'} ${zh ? '秒(20 TPS 时)' : 'seconds at 20 TPS'}`}><Input type="number" min={0} max={1728000} step={1} value={behavior.cooldownTicks} disabled={!editable} onChange={(event) => updateBehavior(index, { ...behavior, cooldownTicks: Number(event.target.value) })} /></Field>
<Field label={zh ? '阻止原版交互' : 'Cancel vanilla interaction'} hint={eventDescriptor?.cancellable ? (zh ? '动作触发时阻止对应原版交互,避免同时打开容器或使用物品。' : 'Prevent the corresponding vanilla interaction when the action fires.') : (zh ? '此事件不能取消。' : 'This event cannot be cancelled.')}><Select value={String(behavior.cancelVanilla)} disabled={!editable || !eventDescriptor?.cancellable} onChange={(event) => updateBehavior(index, { ...behavior, cancelVanilla: event.target.value === 'true' })}><option value="false">{zh ? '否' : 'No'}</option><option value="true">{zh ? '是' : 'Yes'}</option></Select></Field>
{actionDescriptor?.parameters.map((field) => <PropertyField key={field.key} field={field} value={behavior.parameters[field.key] ?? field.defaultValue} zh={zh} disabled={!editable} references={references} idPrefix={`behavior-${index}`}
onChange={(value) => updateBehavior(index, { ...behavior, parameters: { ...behavior.parameters, [field.key]: value } })} />)}
</div>
</section>;
})}</div>
</Panel>
</> : <Panel><EmptyState icon={<Blocks />} title={zh ? '创建你的第一个内容' : 'Create your first content'} detail={zh ? '在左侧选择物品、工具装备、生物原型,或从座椅、播放器模板开始。' : 'Choose an item, equipment, tool, entity archetype or a gameplay template on the left.'} /></Panel>}
<Panel title={zh ? '资源素材' : 'Assets'} action={<label className={`button button--secondary content-upload${editable ? '' : ' is-disabled'}`}><Upload size={16} />{zh ? '上传 PNG / OGG' : 'Upload PNG / OGG'}
<input aria-label={zh ? '上传素材文件' : 'Upload asset file'} type="file" accept="image/png,audio/ogg,.ogg" disabled={!editable} onChange={(event) => { upload(event.target.files?.[0]); event.target.value = ''; }} /></label>}>
<p className="content-help">{zh ? '每个文件最多 8 MiB,PNG 最大 2048 × 2048,音频使用 OGG Vorbis;素材库合计最多 64 MiB / 256 个。上传后在贴图或声音字段选择资源引用;实体渲染能力以目录支持为准。上传不等于客户端已同步。' : 'Up to 8 MiB per file, PNG up to 2048 × 2048, OGG Vorbis audio; the library holds up to 64 MiB / 256 assets. Select uploaded references in texture or sound fields. Entity rendering depends on catalog support. Uploading does not sync clients.'}</p>
{related.data?.unavailable && <p className="content-feedback content-feedback--error">{zh ? '部分菜单、触发器或素材目录读取失败;已有绑定仍被保留。' : 'Some menu, trigger or asset lists could not be loaded; existing bindings are preserved.'}</p>}
<div className="content-assets">{assets.map((asset) => <div key={asset.id}>{asset.mimeType.startsWith('audio/') ? <Music2 size={18} /> : <FileBox size={18} />}<span><strong>{asset.name}</strong><code>{asset.resourceLocation}</code></span><small>{formatBytes(asset.bytes)}</small></div>)}</div>
</Panel>
<Panel title={zh ? '工作台与版本恢复' : 'Workspace and revision recovery'}>
<div className="content-fields"><Field label={zh ? '启用完整内容模式' : 'Enable full content mode'} hint={zh ? '此开关属于草稿,需要保存并发布。一旦发布启用的内容包,服务端会禁止发布关闭状态或移除既有 ID;修改前仍建议备份世界。' : 'This draft setting requires saving and publishing. Once an enabled pack is published, disabling it or removing registered IDs is rejected. Back up your world before changes.'}><Select value={String(draft.enabled)} disabled={!editable} onChange={(event) => edit({ ...draft, enabled: event.target.value === 'true' })}><option value="false">{zh ? '未启用' : 'Disabled'}</option><option value="true">{zh ? '已启用' : 'Enabled'}</option></Select></Field>
<Field label={zh ? '要恢复的历史修订号' : 'Revision to restore'} hint={zh ? '恢复会创建新修订,不会直接修改在线世界。请使用实际存在的修订号。' : 'Restoration creates a new revision; it does not directly change the live world. Use an existing revision number.'}><Input type="number" min={0} max={Math.max(0, draft.revision - 1)} step={1} disabled={!editable} value={rollbackRevision} onChange={(event) => setRollbackRevision(event.target.value)} /></Field></div>
<div className="button-row"><Button variant="secondary" disabled={!editable || dirty || rollbackRevision === '' || !Number.isInteger(Number(rollbackRevision)) || Number(rollbackRevision) < 0 || Number(rollbackRevision) >= draft.revision} onClick={() => setConfirmation({ type: 'rollback' })}><RotateCcw size={16} />{zh ? '恢复历史修订' : 'Restore revision'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => setConfirmation({ type: 'reload' })}>{zh ? '重新读取工作台' : 'Reload workspace'}</Button></div>
</Panel>
</section>
</div>
</>}
</ResourceState>
{notice && <Toast message={notice} onClose={() => setNotice('')} />}
{confirmation && <ModalPortal><div className="modal-backdrop"><section ref={dialogRef} className="modal content-confirm" role="dialog" aria-modal="true" aria-labelledby="content-confirm-title">
<header><h2 id="content-confirm-title">{zh ? '确认内容变更' : 'Confirm content change'}</h2><Button variant="ghost" aria-label={zh ? '关闭确认' : 'Close confirmation'} onClick={() => setConfirmation(undefined)}><X size={18} /></Button></header>
<p>{confirmation.type === 'delete' ? (zh ? `删除“${selected?.name}”的草稿定义?此操作不清理游戏对象。为了保护旧世界存档,已发布过的 ID 必须保留:如果删除的是已发布内容,后续发布会被服务器拒绝,需恢复该定义。` : `Delete the draft definition “${selected?.name}”? This does not remove game objects. To protect saved worlds, published IDs must be retained: deleting a published definition causes the server to reject publication until it is restored.`)
: confirmation.type === 'archetype' ? (zh ? `切换到“${localized(confirmation.archetype, zh)}”?保留有效共有属性,超出新范围的属性恢复默认值,移除新原型不支持的属性。切换后请重新校验事件和动作绑定。` : `Switch to “${localized(confirmation.archetype, zh)}”? Valid shared properties are retained, out-of-range values use new defaults and unsupported properties are removed. Validate event and action bindings again afterwards.`)
: confirmation.type === 'reload' ? (zh ? '重新读取服务器草稿?本页面所有未保存修改将被放弃。' : 'Reload the server draft? All unsaved edits on this page will be discarded.')
: confirmation.type === 'rollback' ? (zh ? `将当前内容恢复为历史修订 r${rollbackRevision}?这会新增修订;发布和客户端同步状态请在完成后核对。` : `Restore revision r${rollbackRevision}? This creates a new revision. Check publication and client sync status afterwards.`)
: (zh ? `发布修订 r${draft?.revision}(${draft?.definitions.length} 个内容,${draft?.enabled ? '已启用' : '未启用'})?服务器会校验近期双因素认证并审计。内容定义、行为和素材将公开供客户端在登录前下载,请勿包含秘密。${view?.update?.managed ? '发布后 30 秒将保存世界、数据库并自动重启服务器。' : '尚未接管自动重启;发布后服务器会保持运行,需通过现有启动方式重启。'}客户端获取的是服务器重启后实际生效的版本。` : `Publish revision r${draft?.revision} (${draft?.definitions.length} definitions, ${draft?.enabled ? 'enabled' : 'disabled'})? Recent two-factor authentication and auditing are required. Definitions, behaviors and assets will be public before login; do not include secrets. ${view?.update?.managed ? 'The server will save and restart automatically after 30 seconds.' : 'Restart is not managed; the server stays running until restarted through its existing launcher.'} Clients fetch the active server revision.`)}</p>
{confirmation.type === 'publish' && !draft?.enabled && <p className="content-feedback content-feedback--error" role="alert">{zh ? '仅发布禁用草稿,不注册内容。即使已经创建物品、方块或生物,它们也不会在游戏中出现;要启用,请取消并先开启完整内容模式、保存后再发布。' : 'This only publishes a disabled draft; no content is registered. Created items, blocks and entities will not appear in game. Cancel, enable full content mode and save before publishing to activate them.'}</p>}
<div className="button-row"><Button variant="secondary" onClick={() => setConfirmation(undefined)}>{zh ? '取消' : 'Cancel'}</Button><Button variant={confirmation.type === 'delete' ? 'danger' : 'primary'} disabled={busy || (!writable && confirmation.type !== 'reload')} onClick={confirm}>{zh ? '确认继续' : 'Confirm'}</Button></div>
</section></div></ModalPortal>}
</div>;
}