diff --git a/public/mcdoc/blood-dialog.mcdoc b/public/mcdoc/blood-dialog.mcdoc index e9f8f8577..806146f87 100644 --- a/public/mcdoc/blood-dialog.mcdoc +++ b/public/mcdoc/blood-dialog.mcdoc @@ -1,15 +1,45 @@ -use ::java::util::text::Text - -// Source dialog scripts also support graph helpers outside JSON: -// @label_name, -> label_name, ? condition:value -> label_name, condition blocks -// ? condition:value { ... } { ... }, and ! end. Label names must match [A-Za-z0-9_.-]+. -// Source action shorthands: ! give_item , +struct LocalizedText { + /// Ключ локализации ресурспака. Создаётся при сохранении, если поле пустое. + key?: string, + en_us?: string, + ru_ru?: string, + /// Значения для плейсхолдеров %s в lang-строке. + arguments?: [LocalizedText], + /// Дополнительные части текста, добавленные после этого текста. + append?: [LocalizedText], + color?: string, + shadow_color?: string, + font?: string, + bold?: boolean, + italic?: boolean, + underlined?: boolean, + strikethrough?: boolean, + obfuscated?: boolean, + insertion?: string, + click_event?: ClickEvent, + hover_event?: HoverEvent, +} + +struct ClickEvent { + action: string, + value: string, +} + +struct HoverEvent { + action: string, + value: string, +} + +// Source-скрипты диалогов также поддерживают вспомогательный графовый синтаксис вне JSON: +// @label_name, -> label_name, ? condition:value -> label_name, блоки условий +// ? condition:value { ... } { ... } и ! end. Имена label должны совпадать с [A-Za-z0-9_.-]+. +// Короткие source-action команды: ! give_item , // ! give_currency , ! teleport , -// ! set_flag [true|false|string:value|int:value], and ! clear_flag . +// ! set_flag [true|false|string:value|int:value] и ! clear_flag . dispatch minecraft:resource[blood:blood-dialog] to struct Dialog { id: string, - /// True by default + /// По умолчанию true. condition?: Condition, actors: [Actor], root: [Node], @@ -17,7 +47,7 @@ dispatch minecraft:resource[blood:blood-dialog] to struct Dialog { struct Condition { condition: string, - /// Provider argument, for example a flag key, quest id, level, or ":" for quest_stage. + /// Аргумент провайдера: например ключ флага, id квеста, уровень или ":" для quest_stage. value?: string, and?: [Condition], or?: [Condition], @@ -25,7 +55,7 @@ struct Condition { } struct Actor { - /// Can be used as ref in TellNode + /// Можно использовать как ref в TellNode. id: string, entity: string, } @@ -33,13 +63,15 @@ struct Actor { // --- Nodes --- dispatch blood:nodes[blood:tell_node] to struct { - text: Text, + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + text?: LocalizedText, actor: string, pauseAfterMessage?: boolean, } dispatch blood:nodes[blood:say_node] to struct { - text: Text, + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + text?: LocalizedText, pauseAfterMessage?: boolean, } @@ -51,7 +83,7 @@ dispatch blood:nodes[blood:branch_node] to struct { struct ConditionBranch { condition: Condition, label: string, - /// Lower priorities are tested first. If omitted, JSON order is used after prioritized branches. + /// Меньший priority проверяется раньше. Если поле пустое, после priority-веток используется порядок JSON. priority?: int, } @@ -61,8 +93,9 @@ dispatch blood:nodes[blood:condition_branch_node] to struct { } struct Choice { - text: Text, - /// Jumps to said label + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + text?: LocalizedText, + /// Переход к указанному label. label: string, } @@ -115,7 +148,8 @@ dispatch blood:actions[blood:command_action] to struct { } dispatch blood:actions[blood:set_actor_name_action] to struct { - actorName: Text, + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + actorName?: LocalizedText, } dispatch blood:actions[blood:give_item_action] to struct { @@ -145,7 +179,7 @@ enum(string) PlayerFlagValueType { dispatch blood:actions[blood:set_player_flag_action] to struct { key: string, - /// Defaults to boolean true when omitted. + /// Если поле пустое, используется boolean true. value?: string, valueType?: PlayerFlagValueType, } @@ -169,7 +203,7 @@ dispatch minecraft:resource[blood:dialog_item_definition] to struct ItemDefiniti } dispatch blood:item_definition[blood:item_stack] to struct { - /// Bukkit ItemStack serialized data. + /// Сериализованные данные Bukkit ItemStack. yml: string, } diff --git a/public/mcdoc/blood-quest.mcdoc b/public/mcdoc/blood-quest.mcdoc index 8c1a5dea2..9dc278499 100644 --- a/public/mcdoc/blood-quest.mcdoc +++ b/public/mcdoc/blood-quest.mcdoc @@ -1,14 +1,20 @@ -use ::java::util::text::Text - dispatch minecraft:resource[blood:blood-quest] to struct Quest { id: string, type: QuestType, - name: Text, - description: Text, - /// True by default + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + name?: LocalizedText, + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + description?: LocalizedText, + /// По умолчанию true. condition?: Condition, activation?: Activation, - /// Changing this will reset players' progress if quest is active upon reload + /// ID актёра NPC, который выдаёт квест. + giverActor?: string, + /// ID актёра NPC, который выдаёт награду. + rewardActor?: string, + /// Строковый custom model data для GUI квестов. + icon?: string, + /// Изменение версии сбросит прогресс игроков, если квест активен при перезагрузке. version: string, rewards: [Reward], stages: [Stage], @@ -67,7 +73,7 @@ dispatch minecraft:resource[blood:reward] to struct ItemDefinition { } dispatch blood:item_definition[blood:item_stack] to struct { - /// Bukkit ItemStack serialized data + /// Сериализованные данные Bukkit ItemStack. yml: string, } @@ -90,13 +96,45 @@ dispatch blood:item_definition[blood:arc_potion] to struct { struct Condition { condition: string, - /// Provider argument, for example a flag key, quest id, level, or ":" for quest_stage. + /// Аргумент провайдера: например ключ флага, id квеста, уровень или ":" для quest_stage. value?: string, and?: [Condition], or?: [Condition], not?: Condition, } +struct LocalizedText { + /// Ключ локализации ресурспака. Создаётся при сохранении, если поле пустое. + key?: string, + en_us?: string, + ru_ru?: string, + /// Значения для плейсхолдеров %s в lang-строке. + arguments?: [LocalizedText], + /// Дополнительные части текста, добавленные после этого текста. + append?: [LocalizedText], + color?: string, + shadow_color?: string, + font?: string, + bold?: boolean, + italic?: boolean, + underlined?: boolean, + strikethrough?: boolean, + obfuscated?: boolean, + insertion?: string, + click_event?: ClickEvent, + hover_event?: HoverEvent, +} + +struct ClickEvent { + action: string, + value: string, +} + +struct HoverEvent { + action: string, + value: string, +} + dispatch minecraft:resource[blood:activation] to struct Activation { type: ActivationType, ...blood:activation[[type]], @@ -107,15 +145,27 @@ dispatch blood:activation[dialog] to struct { } dispatch minecraft:resource[blood:stage] to struct Stage { - name: Text, - coordinates: string, + /// Локализованный текст. Укажите key вручную или оставьте пустым, чтобы ключ создался при сохранении. + name?: LocalizedText, + coordinates: QuestLocation, goals: [Goal] } +type QuestLocation = (QuestPosition | string) + +struct QuestPosition { + world: string, + x: double, + y: double, + z: double, + yaw?: float, + pitch?: float, +} + struct Area { - /// Center coordinates in "world x y z" or F3+C format - location: string, - /// Radius in blocks + /// Координаты центра + location: QuestLocation, + /// Радиус в блоках radius: float, } @@ -132,22 +182,31 @@ enum(string) GoalType { ShootArea = "blood:shoot_area", Consume = "blood:consume", Dialog = "blood:dialog", - Die = "blood:die", // lmao + Die = "blood:die", KillPlayer = "blood:kill_player", Level = "blood:level", + Craft = "blood:craft", + Dungeon = "blood:dungeon", + InteractObject = "blood:interact_object", + NpcZone = "blood:npc_zone", + Condition = "blood:condition", + Scoreboard = "blood:scoreboard", + ScoreboardTag = "blood:scoreboard_tag", } dispatch minecraft:resource[blood:goal] to struct Goal { + /// Стабильный уникальный id; создаётся сервером при сохранении, если поле пустое. + id?: string, type: GoalType, ...blood:goal[[type]], } dispatch blood:goal[blood:kill] to struct { - /// Registry key. Use either entity_type or mythic_mob. + /// Ключ реестра. Укажите entity_type или mythic_mob. entity_type?: string, - /// MythicMobs internal name. Use either mythic_mob or entity_type. + /// Внутреннее имя MythicMobs. Укажите mythic_mob или entity_type. mythic_mob?: string, - /// Amount of entities to kill + /// Количество сущностей для убийства. amount: int@1.., } @@ -157,12 +216,12 @@ dispatch blood:goal[blood:gather] to struct { dispatch blood:goal[blood:deliver] to struct { item: ItemDefinition, - /// ActorId of NPC to whom to deliver + /// ID актёра NPC, которому нужно отнести предмет. actor: string, } dispatch blood:goal[blood:talk] to struct { - /// ActorId of NPC to whom to talk + /// ID актёра NPC, с которым нужно поговорить. actor: string, } @@ -171,13 +230,15 @@ dispatch blood:goal[blood:reach] to struct { } dispatch blood:goal[blood:interact_entity] to struct { - /// Entity registry key + /// Ключ реестра сущности. entity_type: string, } dispatch blood:goal[blood:interact_block] to struct { - /// Block position formatted as "world x y z" - block: string, + /// Позиция блока. + block?: QuestLocation, + /// Несколько позиций блоков; каждую позицию нужно использовать один раз. + blocks?: [QuestLocation], } dispatch blood:goal[blood:shoot_area] to struct { @@ -197,10 +258,59 @@ dispatch blood:goal[blood:die] to struct { } dispatch blood:goal[blood:kill_player] to struct { - // TODO: Player Predicate? + // Позже: предикат игрока. amount: int@1.., } dispatch blood:goal[blood:level] to struct { level: int@1.., } + +dispatch blood:goal[blood:craft] to struct { + item: ItemDefinition, + amount: int@1.., +} + +enum(string) DungeonMode { + Solo = "SOLO", + Party = "PARTY", +} + +dispatch blood:goal[blood:dungeon] to struct { + dungeon: string, + mode: DungeonMode, + /// По умолчанию 3 для PARTY. + min_players?: int@1.., +} + +dispatch blood:goal[blood:interact_object] to struct { + objectId: string, + amount: int@1.., +} + +enum(string) ZoneDirection { + Enter = "ENTER", + Exit = "EXIT", +} + +dispatch blood:goal[blood:npc_zone] to struct { + actor: string, + radius: float@0.1.., + direction: ZoneDirection, +} + +dispatch blood:goal[blood:condition] to struct { + condition: Condition, +} + +dispatch blood:goal[blood:scoreboard] to struct { + /// Имя цели vanilla scoreboard. + objective: string, + /// Требуемое значение счёта. + min: int@1.., +} + +dispatch blood:goal[blood:scoreboard_tag] to struct { + /// Entity-тег vanilla из команды /tag add . + tag: string, +} diff --git a/public/spawnerbox/README.md b/public/spawnerbox/README.md new file mode 100644 index 000000000..67948bb0e --- /dev/null +++ b/public/spawnerbox/README.md @@ -0,0 +1,37 @@ +# Spawner Box Generator — standalone tool + +A self-contained web tool that turns coloured concrete marker blocks into MythicMobs +*Spawners* YAML. It mirrors the in-game `/spawnerbox` command and needs **no build step and +no running Minecraft server**. + +## Run it (pick one) + +### 1. Double-click (easiest) +- **Windows:** double-click `start.bat` +- **macOS / Linux:** `./start.sh` + +This starts a tiny built-in Node server (no `npm install`) on + and opens your browser. Stop it with `Ctrl+C`. + +### 2. One command +From this folder: +```bash +node serve.mjs # http://localhost:4599/ +node serve.mjs 8080 # custom port +``` +Or with any static server, e.g. `py -m http.server 4599` (then open +). + +### 3. Open the file directly +Just open `index.html` in a browser (`file://`). Everything works offline; the only +caveat is that some browsers block the clipboard on `file://`, so "Copy YAML" falls back +to selecting the text for a manual `Ctrl+C`. + +## Usage +1. Paste markers as `x y z colour` per line (or a JSON array of `{x,y,z,color}`), or click + **Load sample**. +2. Adjust settings (folder, zone/area/difficulty, `mergeRadius`, radius formula, ...). +3. **Generate spots** → review/edit Radius, RadiusY, MaxMobs, Level and MobName per spot. +4. **Copy YAML** or **Export .zip** (files land under `folder/NAME.yml`). + +The tool is also served by the full frontend (Vite) at `/spawnerbox/`. diff --git a/public/spawnerbox/index.html b/public/spawnerbox/index.html new file mode 100644 index 000000000..bc0d015f2 --- /dev/null +++ b/public/spawnerbox/index.html @@ -0,0 +1,196 @@ + + + + + + Spawner Box Generator - Arc-Blood + + + + + +
+ + +
+ Arc-Blood · MythicMobs Works +

Spawner Box Generator

+

Feed the machine coloured concrete markers — it forges grouped spawner YAML.

+
+
+ +
+
+ ? Guide & reference +
+

In-game commands

+
    +
  • /spawnerbox pos1 — set corner 1 (stand there)
  • +
  • /spawnerbox pos2 — set the opposite corner
  • +
  • /spawnerbox dump [name] — scan the box → write a markers .json to plugins/arc-blood/spawnerbox/ (upload it here)
  • +
  • /spawnerbox scan <folder> <zone> <area> <difficulty> — generate YAML in-game instead
  • +
  • /spawnerbox preview [n] — list spots / show one YAML
  • +
  • /spawnerbox export [overwrite] — write files to MythicMobs/spawners
  • +
  • /spawnerbox clear — reset selection
  • +
  • Permission: arcblood.command.spawnerbox (OP by default)
  • +
+ +

Setup checklist

+
+ + + + + + + + +
+ +

Zone levels

+
+ +

Mob categories

+
+ +

Marker blocks

+
+ +

Naming

+

Spawner name {ZONE}_{AREA}_{DIFFICULTY}_{NNN}, group {ZONE}_{AREA}_{DIFFICULTY}, + file under spawners/{folder}/. Example: bst/BST_FLD_E_001.yml.

+
+
+ +
+

Zones

+

Spawner groups tracked in this browser. Tick the box when a zone is done; Open reloads its + spawners so you can re-edit and re-export them.

+
+
+ +
+

I Markers

+

+ Paste marker blocks, one per line as x y z colour (colour = green/yellow/orange/red/pink), + or a JSON array of {x,y,z,color}. This is the same data /spawnerbox scan reads in-game. +

+ +
+ + + + +
+
+ ⬇ Drag & drop .yml / .json files here — or click to browse + Release to load +
+

+ Load .yml opens spawner files you already exported + (e.g. run/plugins/MythicMobs/spawners/bst/*.yml) so you can tweak and re-export them. +

+
+ +
+

II Settings

+
+ + + + + + + + + + + +
+
+ Difficulty by marker colour — auto per spot +
+ + + + + +
+
+
+ Popular onblock tags — click to toggle +
+
+ +
+ + + + + +
+

Fixme — rename existing folders

+

+ Drop a folder (or its .yml files) of already-exported spawners. This rewrites the file + name and SpawnerGroup so Difficulty comes from the mob's colour (per the map above) + and re-numbers each ZONE_AREA_DIFF group. Set the target Zone/Area below. +

+
+ + + +
+
+ + + + + + +
+
+ ⬇ Drag & drop a folder or .yml files here to rename + Release to load +
+
+
+
+ +
Powered by steam, brass & MythicMobs Spawners.
+ + + + + + diff --git a/public/spawnerbox/serve.mjs b/public/spawnerbox/serve.mjs new file mode 100644 index 000000000..60956ea22 --- /dev/null +++ b/public/spawnerbox/serve.mjs @@ -0,0 +1,57 @@ +// Zero-dependency static server for the Spawner Box Generator tool. +// Run with: node serve.mjs [port] +// No `npm install` required — uses only Node's built-in modules. +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { extname, join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' +import { dirname } from 'node:path' +import { spawn } from 'node:child_process' + +const root = dirname(fileURLToPath(import.meta.url)) +const port = Number(process.argv[2]) || 4599 + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +} + +const server = createServer(async (req, res) => { + try { + let path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname) + if (path === '/' || path.endsWith('/')) path += 'index.html' + // Keep requests inside the tool folder. + const filePath = normalize(join(root, path)) + if (!filePath.startsWith(root)) { + res.writeHead(403).end('Forbidden') + return + } + const data = await readFile(filePath) + res.writeHead(200, { 'content-type': MIME[extname(filePath)] || 'application/octet-stream' }) + res.end(data) + } catch { + res.writeHead(404).end('Not found') + } +}) + +server.listen(port, () => { + const url = `http://localhost:${port}/` + console.log(`Spawner Box Generator running at ${url}`) + console.log('Press Ctrl+C to stop.') + openBrowser(url) +}) + +function openBrowser(url) { + const platform = process.platform + const cmd = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open' + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url] + try { + spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() + } catch { /* opening a browser is best-effort */ } +} diff --git a/public/spawnerbox/spawnerbox.css b/public/spawnerbox/spawnerbox.css new file mode 100644 index 000000000..8db6ee3d4 --- /dev/null +++ b/public/spawnerbox/spawnerbox.css @@ -0,0 +1,632 @@ +/* ============================================================================ + Spawner Box Generator — steampunk workshop theme + ============================================================================ */ +:root { + --ink: #2a1d10; + --bg-0: #191009; + --bg-1: #241812; + --panel: #2c2016; + --panel-hi: #37281b; + --recess: #170f09; + + --brass: #c79a5b; + --brass-hi: #f2dca6; + --brass-lo: #7c5a2e; + --copper: #c07846; + --amber: #ffab40; + --amber-glow: rgba(255, 171, 64, 0.45); + --parchment: #e9d8b0; + + --text: #ecdcbb; + --muted: #a58f6c; + --line: #4a3823; + + --green: #8fce5b; + --yellow: #e7c94f; + --orange: #e0842f; + --red: #d1504a; + --pink: #d986b6; + --danger: #e5675f; +} + +* { box-sizing: border-box; } + +html { scrollbar-color: var(--brass-lo) var(--bg-0); } + +body { + margin: 0; + color: var(--text); + font: 15px/1.55 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; + background: + radial-gradient(1200px 700px at 50% -10%, #3a2716 0%, transparent 60%), + radial-gradient(900px 900px at 100% 100%, #2a1a0f 0%, transparent 55%), + linear-gradient(160deg, var(--bg-1), var(--bg-0)); + min-height: 100vh; +} + +/* faint drifting cog texture behind everything */ +.cogfield { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.05; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='none' stroke='%23f2dca6' stroke-width='2'%3E%3Ccircle cx='30' cy='30' r='18'/%3E%3Ccircle cx='30' cy='30' r='7'/%3E%3Ccircle cx='90' cy='85' r='26'/%3E%3Ccircle cx='90' cy='85' r='10'/%3E%3C/g%3E%3C/svg%3E"); +} + +main, .top, .foot { position: relative; z-index: 1; } + +/* ------------------------------------------------------------------ header */ +.top { + position: relative; + overflow: hidden; + padding: 34px 24px 30px; + text-align: center; + border-bottom: 3px solid var(--brass-lo); + box-shadow: 0 3px 0 #120b05, 0 8px 24px rgba(0,0,0,0.5); + background: + linear-gradient(180deg, rgba(255,220,160,0.06), transparent 40%), + linear-gradient(180deg, #2f2114, #221610); +} + +.plaque { + position: relative; + display: inline-block; + padding: 14px 34px; + border-radius: 10px; + background: linear-gradient(180deg, var(--panel-hi), var(--panel)); + border: 2px solid var(--brass-lo); + box-shadow: + inset 0 1px 0 rgba(242,220,166,0.25), + inset 0 0 24px rgba(0,0,0,0.5), + 0 6px 18px rgba(0,0,0,0.45); +} +/* corner rivets on the plaque */ +.plaque::before, .plaque::after { + content: ""; + position: absolute; + top: 8px; bottom: 8px; width: 0; + border-left: 2px dotted rgba(242,220,166,0.18); +} +.plaque::before { left: 12px; } +.plaque::after { right: 12px; } + +.eyebrow { + display: block; + font-size: 11px; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--copper); + margin-bottom: 2px; +} +.top h1 { + margin: 0; + font-size: clamp(26px, 5vw, 40px); + letter-spacing: 2px; + text-transform: uppercase; + background: linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 45%, var(--brass-lo) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: 0 1px 0 rgba(0,0,0,0.35); +} +.top .sub { margin: 6px 0 0; color: var(--muted); font-style: italic; } + +.gear { + position: absolute; + width: 150px; height: 150px; + color: var(--brass); + opacity: 0.14; + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.5)); +} +.gear-a { top: -34px; left: -26px; animation: spin 26s linear infinite; } +.gear-b { bottom: -52px; right: -30px; width: 190px; height: 190px; animation: spin 34s linear infinite reverse; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .gear { animation: none; } } + +/* -------------------------------------------------------------------- main */ +main { + max-width: 1080px; + margin: 0 auto; + padding: 26px 16px 40px; + display: flex; + flex-direction: column; + gap: 22px; +} + +/* ------------------------------------------------------------------ panels */ +.panel { + position: relative; + padding: 20px 22px 22px; + border-radius: 12px; + background: + linear-gradient(180deg, rgba(255,220,160,0.04), transparent 30%), + linear-gradient(180deg, var(--panel-hi), var(--panel)); + border: 2px solid var(--brass-lo); + box-shadow: + inset 0 1px 0 rgba(242,220,166,0.18), + inset 0 0 40px rgba(0,0,0,0.35), + 0 8px 20px rgba(0,0,0,0.4); +} +/* riveted corner studs */ +.panel::before, .panel::after { + content: ""; + position: absolute; + width: 7px; height: 7px; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, var(--brass-hi), var(--brass-lo) 70%, #3a2913); + box-shadow: 0 1px 1px rgba(0,0,0,0.6); +} +.panel::before { top: 9px; left: 9px; box-shadow: 900px 0 0 -0px rgba(0,0,0,0), 0 1px 1px rgba(0,0,0,0.6); } +.panel::after { bottom: 9px; right: 9px; } + +h2 { + margin: 0 0 12px; + font-size: 19px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--brass-hi); + display: flex; + align-items: center; + gap: 10px; +} +.cog { + display: inline-grid; + place-items: center; + width: 30px; height: 30px; + font-size: 12px; + font-weight: 700; + color: #21160c; + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, var(--brass-hi), var(--brass) 55%, var(--brass-lo)); + box-shadow: inset 0 0 0 3px #21160c, 0 0 0 2px var(--brass-lo), 0 2px 4px rgba(0,0,0,0.5); +} + +.hint { color: var(--muted); margin: 0 0 12px; font-size: 13.5px; } +code { + background: var(--recess); + padding: 1px 6px; + border-radius: 4px; + border: 1px solid var(--line); + color: var(--amber); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; +} + +/* --------------------------------------------------------------- controls */ +textarea, input, pre { + background: var(--recess); + color: var(--text); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: inset 0 2px 6px rgba(0,0,0,0.55); +} + +textarea { + width: 100%; + min-height: 150px; + resize: vertical; + padding: 12px; + font: 13px/1.6 ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 12px 16px; + margin-bottom: 18px; +} +label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--copper); +} +input { + padding: 8px 10px; + font-size: 14px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +select { + background: var(--recess); + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.55); +} +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--amber); + box-shadow: inset 0 2px 6px rgba(0,0,0,0.55), 0 0 0 2px var(--amber-glow); +} +.lblhint { color: var(--muted); text-transform: none; letter-spacing: 0; font-style: italic; } + +.diffmap { margin: 4px 0 16px; } +.diffrow { display: flex; flex-wrap: wrap; gap: 10px 14px; } +.dmi { + flex-direction: row; + align-items: center; + gap: 6px; + text-transform: none; + letter-spacing: 0; + color: var(--text); + font-size: 13px; +} +.dmi input { width: 52px; text-align: center; text-transform: uppercase; } + +.fixpreview { margin-top: 12px; display: flex; flex-direction: column; gap: 3px; max-height: 340px; overflow-y: auto; } +.fixsummary { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + color: var(--brass-hi); + padding: 6px 0; + border-bottom: 1px solid var(--line); + margin-bottom: 4px; +} +.fixrow { + display: flex; + align-items: center; + gap: 8px; + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; +} +.fixrow .fold { color: var(--muted); } +.fixrow .farr { color: var(--copper); } +.fixrow .fnew { color: var(--brass-hi); } + +.chipwrap { margin: 4px 0 16px; } +.chip-title { + display: block; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--copper); + margin-bottom: 8px; +} +.chips { display: flex; flex-wrap: wrap; gap: 7px; } +.chip { + padding: 5px 11px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + font-family: ui-monospace, Consolas, monospace; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + background: var(--recess); + box-shadow: none; +} +.chip:hover { color: var(--brass-hi); border-color: var(--brass); filter: none; } +.chip.on { + color: #241505; + border-color: var(--brass-lo); + background: linear-gradient(180deg, var(--brass-hi), var(--brass)); + box-shadow: 0 0 10px var(--amber-glow); +} +.chip.on::before { content: "\2713 "; } + +.dropzone { + margin-top: 12px; + padding: 16px; + text-align: center; + border: 2px dashed var(--brass-lo); + border-radius: 10px; + background: var(--recess); + color: var(--muted); + font-size: 13.5px; + cursor: pointer; + transition: border-color .12s ease, background .12s ease, color .12s ease; +} +.dropzone b { color: var(--brass-hi); } +.dropzone:hover { border-color: var(--brass); color: var(--text); } +.dropzone .dz-hot { display: none; color: var(--amber); font-weight: 700; } +.dropzone.drag { + border-color: var(--amber); + border-style: solid; + background: rgba(255, 171, 64, 0.12); + color: var(--amber); + box-shadow: inset 0 0 20px var(--amber-glow); +} +.dropzone.drag .dz-idle { display: none; } +.dropzone.drag .dz-hot { display: inline; } + +.row { display: flex; align-items: center; gap: 14px; margin-top: 12px; flex-wrap: wrap; } +.muted { color: var(--muted); font-size: 12.5px; font-style: italic; } + +/* --------------------------------------------------------------- buttons */ +button { + cursor: pointer; + border-radius: 9px; + padding: 9px 18px; + font-size: 13.5px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--brass-hi); + border: 1px solid var(--brass-lo); + background: linear-gradient(180deg, var(--panel-hi), #241a11); + box-shadow: inset 0 1px 0 rgba(242,220,166,0.2), 0 3px 6px rgba(0,0,0,0.4); + transition: transform .06s ease, filter .12s ease, box-shadow .12s ease; +} +button:hover { filter: brightness(1.12); } +button:active { transform: translateY(1px); box-shadow: inset 0 2px 5px rgba(0,0,0,0.5); } + +button.primary { + color: #241505; + border-color: #6d4a1e; + background: linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 48%, var(--brass-lo) 100%); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.4), + inset 0 -2px 4px rgba(0,0,0,0.3), + 0 0 14px var(--amber-glow), + 0 3px 8px rgba(0,0,0,0.45); +} +button.ghost { background: transparent; color: var(--muted); } +button.ghost:hover { color: var(--brass-hi); border-color: var(--brass); } + +/* ----------------------------------------------------------------- table */ +.tablewrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(0,0,0,0.18); +} +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { + text-align: left; + padding: 9px 11px; + border-bottom: 1px solid var(--line); + white-space: nowrap; +} +thead th { + color: var(--brass-hi); + font-variant: small-caps; + letter-spacing: 0.6px; + text-transform: lowercase; + background: linear-gradient(180deg, var(--panel-hi), var(--panel)); + border-bottom: 2px solid var(--brass-lo); + position: sticky; + top: 0; +} +tbody tr:nth-child(even) { background: rgba(255,220,160,0.03); } +tbody tr:hover { background: rgba(255,171,64,0.07); } +td input { width: 100%; min-width: 72px; padding: 6px 8px; } +td.mob input { min-width: 220px; } + +/* color dots */ +.dot { + display: inline-block; + width: 11px; height: 11px; + border-radius: 50%; + margin-right: 7px; + vertical-align: middle; + box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 0 6px currentColor; +} +.c-green { background: var(--green); color: var(--green); } +.c-yellow { background: var(--yellow); color: var(--yellow); } +.c-orange { background: var(--orange); color: var(--orange); } +.c-red { background: var(--red); color: var(--red); } +.c-pink { background: var(--pink); color: var(--pink); } + +/* -------------------------------------------------------- guide & reference */ +details.guide { padding: 0; } +details.guide > summary { + list-style: none; + cursor: pointer; + padding: 16px 20px; + font-size: 19px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--brass-hi); + display: flex; + align-items: center; + gap: 10px; +} +details.guide > summary::-webkit-details-marker { display: none; } +details.guide > summary::after { + content: "▸"; + margin-left: auto; + color: var(--brass); + transition: transform .15s ease; +} +details.guide[open] > summary::after { transform: rotate(90deg); } +.guide-body { padding: 0 20px 20px; } +.guide-body h3 { + margin: 18px 0 8px; + font-size: 13px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--copper); +} +.cmds { margin: 0; padding-left: 18px; color: var(--text); } +.cmds li { margin: 3px 0; font-size: 13.5px; } +.checklist { display: flex; flex-direction: column; gap: 7px; } +.checklist label { + flex-direction: row; + align-items: flex-start; + gap: 9px; + font-size: 13.5px; + letter-spacing: 0; + text-transform: none; + color: var(--text); +} +.checklist input[type="checkbox"] { accent-color: var(--amber); margin-top: 2px; } +td.mono, .mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; white-space: normal; } + +/* -------------------------------------------------------------- zone tracker */ +.zonelist { display: flex; flex-direction: column; gap: 8px; } +.zone-row { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 12px; + border: 1px solid var(--line); + border-left: 4px solid var(--brass-lo); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} +.zone-row.ready { border-left-color: var(--green); background: rgba(143, 206, 91, 0.06); } +.zone-row input[type="checkbox"] { accent-color: var(--green); width: 16px; height: 16px; } +.zone-row .zname { + font-family: ui-monospace, Consolas, monospace; + font-weight: 700; + color: var(--brass-hi); + font-size: 14px; +} +.zone-row.ready .zname { color: var(--green); } +.zone-row .zmeta { color: var(--muted); font-size: 12px; margin-left: auto; } +.zone-row button { padding: 5px 12px; font-size: 12px; } +.zone-row .zdel { color: var(--danger); border-color: transparent; padding: 5px 9px; } + +/* ------------------------------------------------------------- spot cards */ +.cards { display: flex; flex-direction: column; gap: 16px; } + +.card { + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); + padding: 14px 16px; + box-shadow: inset 0 1px 0 rgba(242, 220, 166, 0.08); +} +.card-head { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid var(--line); +} +.card-head b { color: var(--brass-hi); font-size: 15px; letter-spacing: 0.5px; } +.card-head .coords { color: var(--amber); font-family: ui-monospace, Consolas, monospace; font-size: 12px; } +.card-head .meta { color: var(--muted); font-size: 12px; margin-left: auto; } + +.sliders { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px 20px; + margin-bottom: 14px; +} +.slider-row { display: flex; flex-direction: column; gap: 6px; } +.slabel { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--copper); +} +.slabel .sval { + color: var(--brass-hi); + font-family: ui-monospace, Consolas, monospace; + font-size: 14px; + font-weight: 700; +} +.scontrols { display: flex; align-items: center; gap: 10px; } +.scontrols input[type="range"] { + flex: 1; + accent-color: var(--amber); + height: 4px; + background: transparent; + cursor: pointer; +} +.scontrols input[type="number"] { width: 68px; padding: 5px 7px; text-align: center; } + +.mobs { + border-top: 1px solid var(--line); + padding-top: 12px; + margin-bottom: 12px; +} +.mob-title { + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--copper); + margin-bottom: 8px; +} +.mob-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 6px 10px; + margin-bottom: 10px; +} +.mob-item { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + cursor: pointer; + text-transform: none; + letter-spacing: 0; +} +.mob-item:hover { border-color: var(--brass); } +.mob-item.on { border-color: var(--amber); background: rgba(255, 171, 64, 0.1); } +.mob-item input[type="checkbox"] { accent-color: var(--amber); } +.mob-item .mid { + flex: 1; + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mob-item .wt { width: 48px; padding: 3px 5px; font-size: 12px; text-align: center; } +.mob-add { display: flex; gap: 8px; } +.mob-add input { flex: 1; max-width: 260px; } +.mobname { font-size: 12.5px; color: var(--muted); margin-bottom: 10px; } +.mobname code { + color: var(--amber); + background: var(--recess); + border: 1px solid var(--line); + padding: 1px 6px; + border-radius: 4px; +} +.card-yaml { + margin: 6px 0 0; + max-height: 260px; + padding: 10px 12px; + font-size: 11.5px; +} + +/* ------------------------------------------------------------------ yaml */ +pre { + padding: 14px 16px; + overflow-x: auto; + font: 12.5px/1.55 ui-monospace, "SF Mono", Menlo, Consolas, monospace; + max-height: 480px; + color: var(--parchment); +} + +/* ------------------------------------------------------------- warnings */ +.warnbox { margin-bottom: 12px; display: flex; flex-direction: column; gap: 4px; } +.warnbox div { + padding: 7px 11px; + border-radius: 7px; + border-left: 3px solid; + background: rgba(0,0,0,0.25); + font-size: 13px; +} +.warn { color: var(--amber); border-color: var(--amber); } +.fatal { color: var(--danger); border-color: var(--danger); } + +/* ------------------------------------------------------------------ foot */ +.foot { + text-align: center; + padding: 20px; + color: var(--muted); + font-style: italic; + font-size: 12.5px; + border-top: 1px solid var(--line); +} diff --git a/public/spawnerbox/spawnerbox.js b/public/spawnerbox/spawnerbox.js new file mode 100644 index 000000000..f460c063d --- /dev/null +++ b/public/spawnerbox/spawnerbox.js @@ -0,0 +1,952 @@ +'use strict' + +// --------------------------------------------------------------------------------------------- +// Data: mob pools and zone levels (kept in sync with the Kotlin backend). +// --------------------------------------------------------------------------------------------- +const CATEGORIES = ['green', 'yellow', 'orange', 'red', 'pink'] + +const MOB_POOLS = { + green: ['Snail', 'wild_boar_brown', 'plague_rat_black', 'Plant_Monster', 'skr_barebone', 'Stone_Minion', 'Wraith', 'slim', 'Stone_Golem', 'skr_ranger'], + yellow: ['plague_rat_grey', 'skr_skirmisher', 'skr_stray', 'wolf', 'beaver', 'small_spider', 'crazy_cat', 'Lava_Salamander', 'fog_lizard_brown', 'magma_slime', 'snake', 'Salamander', 'Stone_Minion_Fire', 'Stone_Fire_Golem'], + orange: ['skr_arbalist', 'skr_berserker', 'Stone_Ice_Golem', 'Stone_Minion_Ice', 'Wraith', 'poison_frog', 'giant_ant', 'lizard', 'plague_rat_brown', 'wild_boar_grey', 'fog_lizard_green'], + red: ['zombie_improve', 'bear', 'skr_witherbone', 'Salamander_Blood', 'Salamander_Ice', 'fog_lizard_dark', 'big_spider', 'blood_slime', 'plague_rat_red', 'plague_rat_white'], + pink: ['zombie_elite'], +} + +const COUNT_MULTIPLIER = { plague_rat_red: 2.0, plague_rat_white: 2.0, fog_lizard_dark: 1.5 } + +// Popular ground blocks/tags for onblock{m=...}. '#' entries are real Java Edition block tags +// (verified against minecraft.wiki); the rest are single Bukkit materials. +const POPULAR_ONBLOCK = [ + '#dirt', 'GRASS_BLOCK', '#base_stone_overworld', '#base_stone_nether', + '#sand', 'GRAVEL', '#nylium', 'SOUL_SAND', + '#logs', '#leaves', '#planks', '#wool', '#terracotta', '#stone_bricks', + '#snow', '#ice', '#coral_blocks', '#animals_spawnable_on', +] + +// minLevel, maxLevel, leashRange, cooldownSeconds, direction +const ZONE_LEVELS = { + green: { min: 1, max: 10, leash: 10, cooldown: 20, dir: 'CENTER_HIGH' }, + yellow: { min: 10, max: 50, leash: 20, cooldown: 60, dir: 'CENTER_HIGH' }, + orange: { min: 50, max: 100, leash: 30, cooldown: 120, dir: 'EDGE_HIGH' }, + red: { min: 100, max: 150, leash: 30, cooldown: 300, dir: 'EDGE_HIGH' }, + pink: { min: 150, max: 200, leash: 30, cooldown: 1200, dir: 'CENTER_HIGH' }, +} + +// --------------------------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------------------------- +function parseMarkers(raw) { + const text = raw.trim() + if (!text) return [] + const markers = [] + if (text[0] === '[') { + const arr = JSON.parse(text) + for (const m of arr) { + const color = String(m.color || m.category || '').toLowerCase() + if (!CATEGORIES.includes(color)) continue + markers.push({ color, x: Math.floor(m.x), y: Math.floor(m.y), z: Math.floor(m.z) }) + } + return markers + } + for (const line of text.split(/\r?\n/)) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const parts = t.split(/[\s,]+/) + if (parts.length < 4) continue + const color = parts[3].toLowerCase() + if (!CATEGORIES.includes(color)) continue + markers.push({ color, x: parseInt(parts[0], 10), y: parseInt(parts[1], 10), z: parseInt(parts[2], 10) }) + } + return markers.filter(m => Number.isFinite(m.x) && Number.isFinite(m.y) && Number.isFinite(m.z)) +} + +// --------------------------------------------------------------------------------------------- +// Pipeline (mirrors SpawnerBoxSystem) +// --------------------------------------------------------------------------------------------- +function detectColumns(markers) { + const map = new Map() + for (const m of markers) { + const key = `${m.color}|${m.x}|${m.z}` + let c = map.get(key) + if (!c) { c = { color: m.color, x: m.x, z: m.z, baseY: m.y, height: 0 }; map.set(key, c) } + c.height++ + c.baseY = Math.min(c.baseY, m.y) + } + return [...map.values()] +} + +function detectGroups(columns, mergeRadius) { + const groups = [] + for (const color of CATEGORIES) { + const cols = columns.filter(c => c.color === color) + const n = cols.length + if (n === 0) continue + const parent = Array.from({ length: n }, (_, i) => i) + const find = a => { while (parent[a] !== a) { parent[a] = parent[parent[a]]; a = parent[a] } return a } + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const dx = cols[i].x - cols[j].x, dz = cols[i].z - cols[j].z + if (Math.hypot(dx, dz) <= mergeRadius) parent[find(i)] = find(j) + } + } + const buckets = new Map() + for (let i = 0; i < n; i++) { + const r = find(i) + if (!buckets.has(r)) buckets.set(r, []) + buckets.get(r).push(cols[i]) + } + for (const members of buckets.values()) { + groups.push(makeGroup(color, members)) + } + } + groups.sort((a, b) => CATEGORIES.indexOf(a.color) - CATEGORIES.indexOf(b.color) || a.centerX - b.centerX || a.centerZ - b.centerZ) + return groups +} + +function makeGroup(color, columns) { + const avg = arr => Math.round(arr.reduce((s, v) => s + v, 0) / arr.length) + return { + color, + columns, + centerX: avg(columns.map(c => c.x)), + centerZ: avg(columns.map(c => c.z)), + baseY: Math.min(...columns.map(c => c.baseY)), + maxColumnHeight: Math.max(...columns.map(c => c.height)), + mobCount: columns.reduce((s, c) => s + c.height, 0), + } +} + +function pureMobIds(mobName) { + return mobName.split(',').map(s => s.trim()).filter(Boolean) + .map(s => s.replace(/^\s*\d+(?:\.\d+)?%\s*/, '').trim()).filter(Boolean) +} + +// A MobName may hold several mobs: "MobA,MobB" (equal chance) or weighted "50%MobA,50%MobB". +function parseMobs(mobName) { + return mobName.split(',').map(s => s.trim()).filter(Boolean).map(part => { + const m = part.match(/^(\d+(?:\.\d+)?)%\s*(.+)$/) + return m ? { id: m[2].trim(), weight: parseFloat(m[1]) } : { id: part, weight: null } + }) +} +function buildMobName(mobs) { + const list = mobs.filter(m => m.id) + if (!list.length) return '' + const allWeighted = list.every(m => Number.isFinite(m.weight) && m.weight > 0) + return list.map(m => (allWeighted ? `${trimNum(m.weight)}%${m.id}` : m.id)).join(',') +} +function trimNum(v) { return Number.isInteger(v) ? String(v) : String(v) } + +function computeRadius(h, s) { + const raw = s.minSpawnRadius + (h - 1) * s.radiusPerHeightBlock + return Math.min(s.maxSpawnRadius, Math.max(s.minSpawnRadius, raw)) +} +function computeRadiusY(h) { return Math.max(3, Math.min(8, h + 2)) } + +function computeMaxMobs(mobCount, mobName, s) { + const ids = pureMobIds(mobName) + const mult = ids.reduce((m, id) => Math.max(m, COUNT_MULTIPLIER[id] || 1), 1) + return Math.max(s.mobsPerSpawn, Math.max(1, Math.ceil(mobCount / mult))) +} + +function toDrafts(groups, s) { + const counters = {} + return groups.map((g, index) => { + const pool = MOB_POOLS[g.color] || [] + const mobName = pool.length ? pool[index % pool.length] : '' + const zone = ZONE_LEVELS[g.color] + const difficulty = s.difficultyByColor[g.color] || 'E' + const spawnerGroup = `${s.zone}_${s.area}_${difficulty}` + counters[spawnerGroup] = (counters[spawnerGroup] || 0) + 1 + const name = `${spawnerGroup}_${String(counters[spawnerGroup]).padStart(3, '0')}` + return { + name, spawnerGroup, folder: s.folder, world: s.world, + zoneName: s.zone, areaName: s.area, difficulty, batch: `${s.zone}_${s.area}`, + x: g.centerX, y: g.baseY, z: g.centerZ, + color: g.color, maxColumnHeight: g.maxColumnHeight, mobCount: g.mobCount, + mobName, mobs: parseMobs(mobName), zone, mobLevel: zone.max, + maxMobs: computeMaxMobs(g.mobCount, mobName, s), + mobsPerSpawn: s.mobsPerSpawn, + radius: computeRadius(g.maxColumnHeight, s), radiusY: computeRadiusY(g.maxColumnHeight), + cooldown: zone.cooldown, warmup: 0, leashRange: zone.leash, + activationRange: 120, scalingRange: 25, + onBlockFilter: s.onBlockFilter, condRadius: s.condRadius, + } + }) +} + +const MOB_TO_CATEGORY = (() => { + const m = {} + for (const cat of CATEGORIES) for (const id of MOB_POOLS[cat]) if (!(id in m)) m[id] = cat + return m +})() + +function fmt1(v) { return Number(v).toFixed(1) } + +// --------------------------------------------------------------------------------------------- +// Import existing MythicMobs spawner YAML back into editable drafts. +// --------------------------------------------------------------------------------------------- +function importSpawnerYaml(text, folder, settings) { + const drafts = [] + const lines = text.split(/\r?\n/) + let cur = null + let inConds = false + const flush = () => { if (cur) { drafts.push(finishImported(cur, folder, settings)); cur = null } } + + for (const raw of lines) { + if (!raw.trim()) continue + const head = raw.match(/^([A-Za-z0-9_]+):\s*$/) + if (head) { flush(); cur = { name: head[1], fields: {}, conditions: [] }; inConds = false; continue } + if (!cur) continue + if (/^\s*SpawnConditions:\s*$/.test(raw)) { inConds = true; continue } + const item = raw.match(/^\s*-\s*(.+)$/) + if (item && inConds) { cur.conditions.push(item[1].trim()); continue } + const kv = raw.match(/^\s*([A-Za-z]+):\s*(.*)$/) + if (kv) { inConds = false; cur.fields[kv[1]] = kv[2].trim().replace(/^'(.*)'$/, '$1') } + } + flush() + return drafts +} + +function finishImported(block, folder, s) { + const f = block.fields + const num = (k, d) => { const v = parseFloat(f[k]); return Number.isFinite(v) ? v : d } + const mobName = f.MobName || '' + const color = MOB_TO_CATEGORY[pureMobIds(mobName)[0]] || 'green' + const radius = num('Radius', 4) + const maxMobs = Math.round(num('MaxMobs', 1)) + // Reverse the radius formula and the count multiplier to reconstruct display-only values. + const height = Math.max(1, Math.round((radius - s.minSpawnRadius) / s.radiusPerHeightBlock) + 1) + const mult = pureMobIds(mobName).reduce((m, id) => Math.max(m, COUNT_MULTIPLIER[id] || 1), 1) + const onblock = (block.conditions.find(c => c.includes('onblock')) || '').match(/m=([^}\s]+)/) + const cond = (block.conditions.find(c => c.includes('mobsInRadius')) || '').match(/radius=(\d+)/) + const group = f.SpawnerGroup || block.name.replace(/_\d+$/, '') + const parts = group.split('_') + const difficulty = parts.length >= 3 ? parts[parts.length - 1] : '?' + const areaName = parts.length >= 3 ? parts[parts.length - 2] : '?' + const zoneName = parts.length >= 3 ? parts.slice(0, parts.length - 2).join('_') : group + return { + name: block.name, + spawnerGroup: group, + folder, + zoneName, areaName, difficulty, batch: `${zoneName}_${areaName}`, + world: f.World || 'world', + x: Math.round(num('X', 0)), y: Math.round(num('Y', 0)), z: Math.round(num('Z', 0)), + color, maxColumnHeight: height, mobCount: Math.round(maxMobs * mult), + mobName, mobs: parseMobs(mobName), zone: ZONE_LEVELS[color], mobLevel: Math.round(num('MobLevel', 1)), + maxMobs, mobsPerSpawn: Math.round(num('MobsPerSpawn', 1)), + radius, radiusY: num('RadiusY', 3), + cooldown: Math.round(num('Cooldown', 0)), warmup: Math.round(num('Warmup', 0)), + leashRange: num('LeashRange', 10), + activationRange: num('ActivationRange', 120), scalingRange: num('ScalingRange', 25), + onBlockFilter: onblock ? onblock[1] : s.onBlockFilter, + condRadius: cond ? parseInt(cond[1], 10) : s.condRadius, + } +} + +function zoneFromFileName(fileName) { + return fileName.replace(/\.[^.]+$/, '').trim().toUpperCase().replace(/[^A-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '') +} + +async function loadFiles(fileList) { + const files = [...fileList] + if (!files.length) return + const s = readSettings() + const folder = s.folder + let imported = [] + let markerText = null + let markerFileName = null + for (const file of files) { + const text = await file.text() + const trimmed = text.trimStart() + if (file.name.endsWith('.json') || trimmed[0] === '[' || trimmed[0] === '{') { + markerText = text // JSON marker dump -> run the normal pipeline + markerFileName = file.name + } else { + imported = imported.concat(importSpawnerYaml(text, folder, s)) + } + } + if (markerText !== null) { + // Zone token always comes from the loaded JSON's name. + const zone = zoneFromFileName(markerFileName || '') + if (zone) { $('zone').value = zone; $('folder').value = zone.toLowerCase() } + $('markers').value = markerText + $('markers').dispatchEvent(new Event('input')) + generate() + return + } + if (!imported.length) return + drafts = imported + $('summary').textContent = `— loaded ${drafts.length} spawner(s) from file` + renderWarnings([]) + renderCards() + $('resultsPanel').hidden = false + $('yamlPanel').hidden = false + $('resultsPanel').scrollIntoView({ behavior: 'smooth' }) +} + +function renderYaml(d) { + const types = pureMobIds(d.mobName).join(',') + const lines = [ + `${d.name}:`, + ` MobName: ${d.mobName}`, + ` World: ${d.world}`, + ` SpawnerGroup: ${d.spawnerGroup}`, + ` X: ${d.x}`, ` Y: ${d.y}`, ` Z: ${d.z}`, + ` Yaw: 0.0`, ` Pitch: 0.0`, + ` Radius: ${fmt1(d.radius)}`, ` RadiusY: ${fmt1(d.radiusY)}`, + ` UseTimer: true`, + ` MaxMobs: '${d.maxMobs}'`, ` MobLevel: '${d.mobLevel}'`, + ` MobsPerSpawn: ${d.mobsPerSpawn}`, + ` Cooldown: ${d.cooldown}`, ` Warmup: ${d.warmup}`, + ` ActivationRange: ${fmt1(d.activationRange)}`, ` ScalingRange: ${fmt1(d.scalingRange)}`, + ` LeashRange: ${fmt1(d.leashRange)}`, + ` HealOnLeash: false`, ` ResetThreatOnLeash: false`, ` Breakable: false`, + ` CheckForPlayers: true`, ` ShowFlames: false`, ` Conditions: []`, + ` SpawnConditions:`, + ` - onblock{m=${d.onBlockFilter}} true`, + ` - mobsInRadius{types=${types};amount=0;radius=${d.condRadius}}`, + ` CooldownTimer: 0`, ` WarmupTimer: 0`, ` ActiveMobs: 0`, + ] + return lines.join('\n') +} + +// --------------------------------------------------------------------------------------------- +// UI +// --------------------------------------------------------------------------------------------- +const $ = id => document.getElementById(id) +let drafts = [] + +function difficultyByColor() { + const g = id => ($(id).value.trim().toUpperCase() || '?') + return { + green: g('diff-green'), yellow: g('diff-yellow'), orange: g('diff-orange'), + red: g('diff-red'), pink: g('diff-pink'), + } +} + +function readSettings() { + const num = (id, d) => { const v = parseFloat($(id).value); return Number.isFinite(v) ? v : d } + return { + folder: $('folder').value.trim(), + zone: $('zone').value.trim().toUpperCase(), + area: $('area').value.trim().toUpperCase(), + difficultyByColor: difficultyByColor(), + world: $('world').value.trim() || 'world', + mergeRadius: num('mergeRadius', 10), + minSpawnRadius: num('minSpawnRadius', 4), + radiusPerHeightBlock: num('radiusPerHeightBlock', 2), + maxSpawnRadius: num('maxSpawnRadius', 24), + mobsPerSpawn: Math.max(1, Math.round(num('mobsPerSpawn', 1))), + onBlockFilter: $('onBlockFilter').value.trim() || '#dirt', + condRadius: Math.round(num('condRadius', 4)), + } +} + +function generate() { + const markers = parseMarkers($('markers').value) + const s = readSettings() + const columns = detectColumns(markers) + const groups = detectGroups(columns, s.mergeRadius) + drafts = toDrafts(groups, s) + + const warnings = [] + if (markers.length === 0) warnings.push({ fatal: true, msg: 'No marker blocks parsed.' }) + for (const g of groups) if (!(MOB_POOLS[g.color] || []).length) warnings.push({ fatal: true, msg: `Category ${g.color} has no mob pool.` }) + if (drafts.length > 200) warnings.push({ fatal: false, msg: `Grouping produced ${drafts.length} spawners (>200); check mergeRadius.` }) + + $('summary').textContent = `— ${markers.length} markers, ${columns.length} columns, ${drafts.length} spots` + renderWarnings(warnings) + renderCards() + $('resultsPanel').hidden = false + $('yamlPanel').hidden = drafts.length === 0 +} + +function renderWarnings(warnings) { + const box = $('warnings') + box.innerHTML = '' + if (!warnings.length) return + const wrap = document.createElement('div') + wrap.className = 'warnbox' + for (const w of warnings) { + const d = document.createElement('div') + d.className = w.fatal ? 'fatal' : 'warn' + d.textContent = `${w.fatal ? '[fatal] ' : '[warn] '}${w.msg}` + wrap.appendChild(d) + } + box.appendChild(wrap) +} + +function el(tag, cls, html) { + const e = document.createElement(tag) + if (cls) e.className = cls + if (html != null) e.innerHTML = html + return e +} + +// A labelled slider bound to a range + a synced number box. onChange gets the new value. +function slider(label, min, max, step, value, onChange) { + const wrap = el('div', 'slider-row') + wrap.appendChild(el('label', 'slabel', `${label} ${trimVal(value)}`)) + const controls = el('div', 'scontrols') + const range = document.createElement('input') + range.type = 'range'; range.min = min; range.max = max; range.step = step; range.value = value + const box = document.createElement('input') + box.type = 'number'; box.min = min; box.max = max; box.step = step; box.value = value + const valEl = wrap.querySelector('.sval') + const apply = (v, from) => { + let n = parseFloat(v) + if (!Number.isFinite(n)) return + n = Math.min(max, Math.max(min, n)) + if (from !== 'range') range.value = n + if (from !== 'box') box.value = n + valEl.textContent = trimVal(n) + onChange(n) + } + range.addEventListener('input', () => apply(range.value, 'range')) + box.addEventListener('input', () => apply(box.value, 'box')) + controls.append(range, box) + wrap.appendChild(controls) + return wrap +} +function trimVal(v) { return Number.isInteger(v) ? String(v) : Number(v).toFixed(1) } + +function renderCards() { + const host = $('spots') + host.innerHTML = '' + drafts.forEach((d, i) => host.appendChild(buildCard(d, i))) + renderYamlAll() +} + +function buildCard(d, i) { + const card = el('div', 'card') + const yamlPre = el('pre', 'card-yaml') + const refresh = () => { yamlPre.textContent = renderYaml(d); renderYamlAll() } + + const head = el('div', 'card-head') + head.innerHTML = + `` + + `${d.name}` + + `X ${d.x} · Y ${d.y} · Z ${d.z}` + + `${d.color} · h=${d.maxColumnHeight} · markers=${d.mobCount}` + card.appendChild(head) + + const zone = d.zone || ZONE_LEVELS[d.color] + const s = readSettings() + const sliders = el('div', 'sliders') + sliders.appendChild(slider('Radius', s.minSpawnRadius, Math.max(s.maxSpawnRadius, d.radius), 0.5, d.radius, v => { d.radius = v; refresh() })) + sliders.appendChild(slider('RadiusY', 1, 16, 0.5, d.radiusY, v => { d.radiusY = v; refresh() })) + sliders.appendChild(slider('MobLevel', zone.min, zone.max, 1, clampInt(d.mobLevel, zone.min, zone.max), v => { d.mobLevel = Math.round(v); refresh() })) + sliders.appendChild(slider('MaxMobs', 1, Math.max(20, d.mobCount * 2, d.maxMobs), 1, d.maxMobs, v => { d.maxMobs = Math.round(v); refresh() })) + card.appendChild(sliders) + + card.appendChild(buildMobPicker(d, refresh)) + card.appendChild(yamlPre) + yamlPre.textContent = renderYaml(d) + return card +} + +function clampInt(v, lo, hi) { return Math.min(hi, Math.max(lo, Math.round(v || lo))) } + +function buildMobPicker(d, refresh) { + const box = el('div', 'mobs') + box.appendChild(el('div', 'mob-title', 'Mobs — pick one or several (weighted list)')) + const list = el('div', 'mob-list') + box.appendChild(list) + + const nameLine = el('div', 'mobname') + const syncName = () => { + d.mobName = buildMobName(d.mobs) + nameLine.innerHTML = `MobName: ${d.mobName || '(none)'}` + } + + const draw = () => { + list.innerHTML = '' + const pool = MOB_POOLS[d.color] || [] + const extras = d.mobs.map(m => m.id).filter(id => !pool.includes(id)) + for (const id of [...pool, ...extras]) { + const sel = d.mobs.find(m => m.id === id) + const row = el('label', 'mob-item' + (sel ? ' on' : '')) + const cb = document.createElement('input') + cb.type = 'checkbox'; cb.checked = !!sel + const wt = document.createElement('input') + wt.type = 'number'; wt.className = 'wt'; wt.min = 0; wt.step = 1; wt.placeholder = '%' + wt.value = sel && sel.weight != null ? sel.weight : '' + wt.disabled = !sel + cb.addEventListener('change', () => { + if (cb.checked) { if (!d.mobs.some(m => m.id === id)) d.mobs.push({ id, weight: null }) } + else d.mobs = d.mobs.filter(m => m.id !== id) + syncName(); refresh(); draw() + }) + wt.addEventListener('input', () => { + const m = d.mobs.find(x => x.id === id) + if (m) { const n = parseFloat(wt.value); m.weight = Number.isFinite(n) ? n : null; syncName(); refresh() } + }) + row.append(cb, el('span', 'mid', id), wt) + list.appendChild(row) + } + } + + const add = el('div', 'mob-add') + const custom = document.createElement('input') + custom.type = 'text'; custom.placeholder = 'custom mob id…' + const addBtn = el('button', 'ghost', 'Add') + const doAdd = () => { + const id = custom.value.trim() + if (id && !d.mobs.some(m => m.id === id)) { d.mobs.push({ id, weight: null }); custom.value = ''; syncName(); refresh(); draw() } + } + addBtn.addEventListener('click', doAdd) + custom.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); doAdd() } }) + add.append(custom, addBtn) + + box.append(nameLine, add) + syncName() + draw() + return box +} + +function renderYamlAll() { + $('yaml').textContent = drafts.map(renderYaml).join('\n') + scheduleSave() +} + +function download(name, blob) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.click() + setTimeout(() => URL.revokeObjectURL(url), 2000) +} + +// --------------------------------------------------------------------------------------------- +// Minimal STORE (no compression) ZIP writer +// --------------------------------------------------------------------------------------------- +const CRC_TABLE = (() => { + const t = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + t[n] = c >>> 0 + } + return t +})() +function crc32(bytes) { + let c = 0xffffffff + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} +function makeZip(files) { + const enc = new TextEncoder() + const chunks = [] + const central = [] + let offset = 0 + const u16 = v => [v & 0xff, (v >>> 8) & 0xff] + const u32 = v => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff] + for (const f of files) { + const nameBytes = enc.encode(f.name) + const data = enc.encode(f.content) + const crc = crc32(data) + const local = [ + ...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0), ...u16(0), ...u16(0), + ...u32(crc), ...u32(data.length), ...u32(data.length), + ...u16(nameBytes.length), ...u16(0), + ] + chunks.push(new Uint8Array(local), nameBytes, data) + central.push({ nameBytes, crc, size: data.length, offset }) + offset += local.length + nameBytes.length + data.length + } + const centralChunks = [] + let centralSize = 0 + for (const c of central) { + const rec = [ + ...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0), ...u16(0), ...u16(0), + ...u32(c.crc), ...u32(c.size), ...u32(c.size), + ...u16(c.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), + ...u32(c.offset), + ] + const recBytes = new Uint8Array(rec) + centralChunks.push(recBytes, c.nameBytes) + centralSize += recBytes.length + c.nameBytes.length + } + const end = new Uint8Array([ + ...u32(0x06054b50), ...u16(0), ...u16(0), + ...u16(central.length), ...u16(central.length), + ...u32(centralSize), ...u32(offset), ...u16(0), + ]) + return new Blob([...chunks, ...centralChunks, end], { type: 'application/zip' }) +} + +function exportZip() { + if (!drafts.length) return + const files = drafts.map(d => ({ + name: d.folder ? `${d.folder}/${d.name}.yml` : `${d.name}.yml`, + content: renderYaml(d) + '\n', + })) + download('spawners.zip', makeZip(files)) +} + +// --------------------------------------------------------------------------------------------- +// Wiring +// --------------------------------------------------------------------------------------------- +// ------------------------------------------------------------------ onblock tag chips +function onblockTokens() { + return $('onBlockFilter').value.split(',').map(s => s.trim()).filter(Boolean) +} +function applyOnblock(tokens) { + const value = tokens.join(',') + $('onBlockFilter').value = value + if (drafts.length) { // live-update already generated spots + const filter = value || '#dirt' + drafts.forEach(d => { d.onBlockFilter = filter }) + renderCards() + } + renderOnblockChips() +} +function renderOnblockChips() { + const host = $('onblockChips') + if (!host) return + host.innerHTML = '' + const active = new Set(onblockTokens()) + for (const tag of POPULAR_ONBLOCK) { + const chip = el('button', 'chip' + (active.has(tag) ? ' on' : ''), tag) + chip.type = 'button' + chip.addEventListener('click', () => { + const tokens = onblockTokens() + const idx = tokens.indexOf(tag) + if (idx >= 0) tokens.splice(idx, 1) + else tokens.push(tag) + applyOnblock(tokens) + }) + host.appendChild(chip) + } +} + +function downloadCombined() { + if (!drafts.length) return + const content = drafts.map(renderYaml).join('\n\n') + '\n' + const base = drafts[0].spawnerGroup || 'spawners' + download(`${base}.yml`, new Blob([content], { type: 'text/yaml' })) +} + +// ============================================================================================ +// Fixme — re-name existing spawner folders so Difficulty follows the mob colour. +// ============================================================================================ +let fixResults = [] + +function scanYamlBlocks(lines) { + const blocks = [] + let cur = null + lines.forEach((raw, i) => { + const head = raw.match(/^([A-Za-z0-9_]+):\s*$/) + if (head) { cur = { name: head[1], keyLine: i, sgLine: -1, mob: null }; blocks.push(cur); return } + if (!cur) return + const mob = raw.match(/^\s+MobName:\s*(.+?)\s*$/) + if (mob && cur.mob === null) cur.mob = mob[1].trim().replace(/^'(.*)'$/, '$1') + if (/^\s+SpawnerGroup:/.test(raw)) cur.sgLine = i + }) + return blocks +} + +function fixOneFile(text, zone, area, diffMap, counters) { + const lines = text.split(/\r?\n/) + const blocks = scanYamlBlocks(lines) + const rows = [] + let outName = null + for (const b of blocks) { + const color = MOB_TO_CATEGORY[pureMobIds(b.mob || '')[0]] || null + const diff = color ? (diffMap[color] || '?') : '?' + const group = `${zone}_${area}_${diff}` + counters[group] = (counters[group] || 0) + 1 + const newName = `${group}_${String(counters[group]).padStart(3, '0')}` + lines[b.keyLine] = `${newName}:` + if (b.sgLine >= 0) lines[b.sgLine] = ` SpawnerGroup: ${group}` + rows.push({ old: b.name, new: newName, color: color || '?' }) + if (!outName) outName = newName + } + return { outName: outName || 'unnamed', content: lines.join('\n'), rows } +} + +async function processFix(fileList) { + const files = [...fileList].filter(f => /\.ya?ml$/i.test(f.name)) + if (!files.length) return + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) + const zone = $('fixZone').value.trim().toUpperCase() || 'ZONE' + const area = $('fixArea').value.trim().toUpperCase() || 'FLD' + const diffMap = difficultyByColor() + const counters = {} + fixResults = [] + for (const file of files) { + const text = await file.text() + fixResults.push(fixOneFile(text, zone, area, diffMap, counters)) + } + renderFixPreview() + $('fixDownload').disabled = fixResults.length === 0 + $('fixCount').textContent = `${fixResults.length} file(s) → ${Object.keys(counters).length} group(s)` +} + +function renderFixPreview() { + const host = $('fixPreview') + host.innerHTML = '' + const flat = fixResults.flatMap(r => r.rows) + if (!flat.length) { host.innerHTML = '

Nothing loaded.

'; return } + const byGroup = {} + for (const r of flat) { const g = r.new.replace(/_\d+$/, ''); byGroup[g] = (byGroup[g] || 0) + 1 } + const summary = el('div', 'fixsummary', Object.entries(byGroup).map(([g, n]) => `${g}: ${n}`).join(' ')) + host.appendChild(summary) + for (const r of flat.slice(0, 300)) { + const row = el('div', 'fixrow') + row.innerHTML = `${r.old}` + + `${r.new}` + host.appendChild(row) + } + if (flat.length > 300) host.appendChild(el('p', 'muted', `…and ${flat.length - 300} more`)) +} + +function downloadFix() { + if (!fixResults.length) return + const folder = $('fixFolder').value.trim() + const files = fixResults.map(r => ({ + name: folder ? `${folder}/${r.outName}.yml` : `${r.outName}.yml`, + content: r.content.endsWith('\n') ? r.content : r.content + '\n', + })) + download('spawners-fixed.zip', makeZip(files)) +} + +// Recursively collect files from a drag-drop that may include folders. +function walkEntry(entry, out) { + return new Promise(resolve => { + if (entry.isFile) { + entry.file(f => { out.push(f); resolve() }, () => resolve()) + } else if (entry.isDirectory) { + const reader = entry.createReader() + const readBatch = () => reader.readEntries(async ents => { + if (!ents.length) { resolve(); return } + for (const en of ents) await walkEntry(en, out) + readBatch() + }, () => resolve()) + readBatch() + } else { + resolve() + } + }) +} +async function filesFromDataTransfer(dt) { + const items = dt.items ? [...dt.items] : [] + const entries = items.map(it => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null)).filter(Boolean) + if (entries.length) { + const out = [] + for (const e of entries) await walkEntry(e, out) + return out + } + return [...dt.files] +} + +// ============================================================================================ +// Guide / reference tables, setup checklist, and the persistent Zones tracker. +// ============================================================================================ +const MARKER_BLOCKS = [ + ['minecraft:lime_concrete', 'green'], + ['minecraft:yellow_concrete', 'yellow'], + ['minecraft:orange_concrete', 'orange'], + ['minecraft:red_concrete', 'red'], + ['minecraft:pink_concrete', 'pink'], +] +const DIRECTION_TEXT = { CENTER_HIGH: 'edge → low, center → high', EDGE_HIGH: 'edge → high, center → low' } + +function renderReference() { + const zt = $('zoneTable') + if (zt) zt.innerHTML = + 'ZoneLevel rangeLeashRangeCooldownLevel direction' + + CATEGORIES.map(c => { + const z = ZONE_LEVELS[c] + return `${c} zone${z.min}–${z.max}` + + `${z.leash}${z.cooldown}s${DIRECTION_TEXT[z.dir]}` + }).join('') + const mt = $('mobTable') + if (mt) mt.innerHTML = + 'CategoryMob ids' + + CATEGORIES.map(c => `${c}${MOB_POOLS[c].join(', ')}`).join('') + const mk = $('markerTable') + if (mk) mk.innerHTML = + 'BlockCategory' + + MARKER_BLOCKS.map(([b, c]) => `${b}${c}`).join('') +} + +const CK_KEY = 'spawnerbox.checklist' +function initChecklist() { + let saved = {} + try { saved = JSON.parse(localStorage.getItem(CK_KEY) || '{}') } catch (_) { saved = {} } + const boxes = document.querySelectorAll('#checklist input[type=checkbox]') + boxes.forEach(cb => { + cb.checked = !!saved[cb.dataset.k] + cb.addEventListener('change', () => { + const s = {} + boxes.forEach(x => { s[x.dataset.k] = x.checked }) + try { localStorage.setItem(CK_KEY, JSON.stringify(s)) } catch (_) { /* ignore */ } + }) + }) +} + +const ZONE_KEY = 'spawnerbox.zones' +function loadZones() { try { return JSON.parse(localStorage.getItem(ZONE_KEY) || '{}') } catch (_) { return {} } } +function saveZones(z) { try { localStorage.setItem(ZONE_KEY, JSON.stringify(z)) } catch (_) { /* ignore */ } } + +function persistCurrent() { + if (!drafts.length) return + const group = drafts[0].batch || drafts[0].spawnerGroup || 'spawners' + const z = loadZones() + z[group] = { + group, + folder: drafts[0].folder, + ready: z[group] ? z[group].ready : false, + updated: Date.now(), + drafts: JSON.parse(JSON.stringify(drafts)), + } + saveZones(z) + renderZones() +} +let saveTimer = null +function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(persistCurrent, 500) } + +function renderZones() { + const host = $('zoneList') + if (!host) return + const groups = Object.values(loadZones()).sort((a, b) => b.updated - a.updated) + $('zoneCount').textContent = groups.length ? `— ${groups.length} tracked` : '' + if (!groups.length) { + host.innerHTML = '

No zones yet — generate or load spawners and they show up here.

' + return + } + host.innerHTML = '' + for (const g of groups) { + const row = el('div', 'zone-row' + (g.ready ? ' ready' : '')) + const cb = document.createElement('input') + cb.type = 'checkbox'; cb.checked = !!g.ready; cb.title = 'Mark this zone ready' + cb.addEventListener('change', () => { + const z = loadZones() + if (z[g.group]) { z[g.group].ready = cb.checked; saveZones(z); renderZones() } + }) + const name = el('span', 'zname', g.group) + const meta = el('span', 'zmeta', `${g.drafts.length} spawners · ${new Date(g.updated).toLocaleString()}`) + const open = el('button', 'ghost', 'Open') + open.addEventListener('click', () => openZone(g.group)) + const del = el('button', 'ghost zdel', '✕') + del.title = 'Remove from history' + del.addEventListener('click', () => { const z = loadZones(); delete z[g.group]; saveZones(z); renderZones() }) + row.append(cb, name, meta, open, del) + host.appendChild(row) + } +} + +function openZone(group) { + const g = loadZones()[group] + if (!g) return + drafts = JSON.parse(JSON.stringify(g.drafts)) + if (g.folder != null) $('folder').value = g.folder + $('summary').textContent = `— ${group}: ${drafts.length} spawner(s)` + renderWarnings([]) + renderCards() + $('resultsPanel').hidden = false + $('yamlPanel').hidden = drafts.length === 0 + $('resultsPanel').scrollIntoView({ behavior: 'smooth' }) +} + +renderReference() +initChecklist() +renderZones() + +$('generate').addEventListener('click', generate) + +async function copyText(text) { + // navigator.clipboard needs a secure context; falls back to execCommand for file:// / http. + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + return true + } + } catch (_) { /* fall through */ } + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + let ok = false + try { ok = document.execCommand('copy') } catch (_) { ok = false } + document.body.removeChild(ta) + return ok +} + +$('copy').addEventListener('click', async () => { + const ok = await copyText($('yaml').textContent) + const el = $('copied'); el.textContent = ok ? 'copied!' : 'press Ctrl+C'; setTimeout(() => (el.textContent = ''), 1800) +}) +$('zip').addEventListener('click', exportZip) +$('combined').addEventListener('click', downloadCombined) +$('onBlockFilter').addEventListener('input', renderOnblockChips) +renderOnblockChips() +$('loadBtn').addEventListener('click', () => $('loadFile').click()) +$('loadFile').addEventListener('change', e => { loadFiles(e.target.files); e.target.value = '' }) + +// Fixme wiring +$('fixPick').addEventListener('click', () => $('fixFiles').click()) +$('fixPickDir').addEventListener('click', () => $('fixDir').click()) +$('fixFiles').addEventListener('change', e => { processFix(e.target.files); e.target.value = '' }) +$('fixDir').addEventListener('change', e => { processFix(e.target.files); e.target.value = '' }) +$('fixDownload').addEventListener('click', downloadFix) +{ + const fdz = $('fixDrop') + fdz.addEventListener('click', () => $('fixDir').click()) + ;['dragenter', 'dragover'].forEach(ev => fdz.addEventListener(ev, e => { + if (!hasFiles(e)) return + e.preventDefault(); e.stopPropagation(); fdz.classList.add('drag') + })) + ;['dragleave', 'dragend'].forEach(ev => fdz.addEventListener(ev, () => fdz.classList.remove('drag'))) + fdz.addEventListener('drop', async e => { + e.preventDefault(); e.stopPropagation() + fdz.classList.remove('drag') + const files = await filesFromDataTransfer(e.dataTransfer) + if (files.length) processFix(files) + }) +} + +// ---- drag & drop: drop files anywhere on the page (and highlight the dropzone) ---- +const dropzone = $('dropzone') +dropzone.addEventListener('click', () => $('loadFile').click()) + +function hasFiles(e) { + return e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files') +} +let dragDepth = 0 +window.addEventListener('dragenter', e => { + if (!hasFiles(e)) return + e.preventDefault() + dragDepth++ + dropzone.classList.add('drag') +}) +window.addEventListener('dragover', e => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }) +window.addEventListener('dragleave', e => { + if (!hasFiles(e)) return + dragDepth = Math.max(0, dragDepth - 1) + if (dragDepth === 0) dropzone.classList.remove('drag') +}) +window.addEventListener('drop', e => { + const files = e.dataTransfer && e.dataTransfer.files + dragDepth = 0 + dropzone.classList.remove('drag') + if (files && files.length) { e.preventDefault(); loadFiles(files) } +}) +$('markers').addEventListener('input', () => { + const n = parseMarkers($('markers').value).length + $('markerCount').textContent = n ? `${n} markers parsed` : '' +}) +$('sample').addEventListener('click', () => { + $('markers').value = [ + '214 74 -273 green', '214 75 -273 green', '215 74 -273 green', '216 75 -273 green', + '218 74 -272 green', '220 74 -273 green', + '260 70 -240 red', '260 71 -240 red', '261 70 -240 red', + '300 80 -100 yellow', '301 80 -100 yellow', '302 81 -100 yellow', '303 80 -100 yellow', + ].join('\n') + $('markers').dispatchEvent(new Event('input')) +}) diff --git a/public/spawnerbox/start.bat b/public/spawnerbox/start.bat new file mode 100644 index 000000000..ec11b283f --- /dev/null +++ b/public/spawnerbox/start.bat @@ -0,0 +1,5 @@ +@echo off +REM Double-click to launch the Spawner Box Generator locally (no main server needed). +cd /d "%~dp0" +node serve.mjs %1 +pause diff --git a/public/spawnerbox/start.sh b/public/spawnerbox/start.sh new file mode 100644 index 000000000..49dcb9777 --- /dev/null +++ b/public/spawnerbox/start.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Launch the Spawner Box Generator locally (no main server needed). +cd "$(dirname "$0")" || exit 1 +exec node serve.mjs "$@" diff --git a/src/app/services/integration/QuestApi.ts b/src/app/services/integration/QuestApi.ts index 70f3873c9..029b15a80 100644 --- a/src/app/services/integration/QuestApi.ts +++ b/src/app/services/integration/QuestApi.ts @@ -50,18 +50,132 @@ export class QuestApi { ...(this.configuration.headers ?? {}), } if (options.body !== undefined) { - headers['Content-Type'] = 'application/json' + headers['Content-Type'] = 'application/json; charset=utf-8' } - const response = await fetch(url.toString(), { - method: options.method, - headers, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }) + let response: Response + try { + response = await fetch(url.toString(), { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }) + } catch (error) { + throw new Error(formatQuestNetworkError(error)) + } if (!response.ok) { const text = await response.text() - throw new Error(text || `Quest API returned ${response.status}`) + throw new Error(formatQuestApiError(response.status, text)) } return await response.json() } } + +function formatQuestApiError(status: number, body: string): string { + const message = extractErrorMessage(body) || `Quest API returned HTTP ${status}` + const hint = errorHint(status, message) + return hint ? `${message}\n\n${hint}` : message +} + +function extractErrorMessage(body: string): string | undefined { + const trimmed = body.trim() + if (!trimmed) { + return undefined + } + try { + const json = JSON.parse(trimmed) as { message?: unknown, error?: unknown } + const message = typeof json.message === 'string' ? json.message : json.error + if (typeof message === 'string' && message.trim()) { + return message.trim() + } + } catch { + // Fall through to HTML/plain text handling. + } + + const htmlMessage = trimmed.match(/

Message<\/b>\s*([^<]+)<\/p>/i)?.[1] + if (htmlMessage) { + return decodeHtml(htmlMessage).trim() + } + const title = trimmed.match(/([^<]+)<\/title>/i)?.[1] + if (title) { + return decodeHtml(title).trim() + } + return trimmed.length > 500 ? `${trimmed.slice(0, 500)}...` : trimmed +} + +function errorHint(status: number, message: string): string | undefined { + const normalized = message.toLowerCase() + if (status === 0 || normalized.includes('failed to fetch') || normalized.includes('networkerror')) { + return 'Проверь, что Minecraft-сервер с web API запущен, фронт смотрит на http://localhost:8080, а CORS разрешает адрес фронта.' + } + if (status === 401 || status === 403 || normalized.includes('authorization')) { + return 'Проверь quest API token: введи ровно web.dialog.token из config.yml, без Bearer и без пробелов.' + } + if (normalized.includes('token not configured')) { + return 'В config.yml должен быть задан web.dialog.token. После изменения конфига перезапусти сервер.' + } + if (normalized.includes('unknown quest root')) { + return 'Выбери существующий Root в окне Save to server. Обычно нужен Live plugin data (live).' + } + if (normalized.includes('invalid quest id') || normalized.includes('must contain a string id')) { + return 'Заполни Id простым путём квеста, например story/civ/quest0 или daily/rat_cleanup. Без .., обратных слешей и пустых сегментов.' + } + if (normalized.includes('does not match requested id')) { + return 'Id внутри JSON должен совпадать с именем/путём файла, который сохраняешь. Исправь поле Id или выбери правильный файл.' + } + if (normalized.includes('invalid quest json')) { + return 'JSON не прошёл разбор или проверку. Проверь красные поля в форме и правую панель JSON: типы goal, обязательные поля, числа amount/radius и пустые строки.' + } + if (normalized.includes('quest must contain at least one stage')) { + return 'Добавь хотя бы одну Stage через кнопку Stages +.' + } + if (normalized.includes('must contain at least one goal')) { + return 'В каждой Stage должна быть хотя бы одна цель. Открой Stages -> Goals и добавь goal.' + } + if (normalized.includes('goal ids must be unique')) { + return 'У целей повторяются id. Оставь id пустым для автогенерации или задай каждому goal уникальный id.' + } + if (normalized.includes('coordinates')) { + return 'Заполни координаты объектом: world, x, y, z. Например { "world": "world", "x": 20, "y": 100, "z": 0 }.' + } + if (normalized.includes('requires block or blocks')) { + return 'Для interact_block укажи block для одной точки или blocks для списка точек.' + } + if (normalized.includes('entity_type') || normalized.includes('mythic_mob')) { + return 'Для kill/interact_entity укажи entity_type или mythic_mob, в зависимости от типа цели.' + } + if (normalized.includes('radius must be positive') || normalized.includes('radius must be greater than zero')) { + return 'Radius должен быть числом больше 0.' + } + if (normalized.includes('must be greater than zero')) { + return 'Количество/значение должно быть числом больше 0.' + } + if (normalized.includes('unknown arcblood item')) { + return 'Item id не найден в реестре ArcBlood items. Проверь id предмета или сначала зарегистрируй предмет.' + } + if (normalized.includes('unknown arcblood potion')) { + return 'Potion id не найден в реестре ArcBlood potions. Проверь id зелья или сначала зарегистрируй зелье.' + } + if (normalized.includes('resource pack lang files were not found')) { + return 'Сервер не нашёл lang-файлы ресурспака. Проверь, что папка resourcepack есть в репозитории, и сервер запущен из правильного проекта.' + } + if (normalized.includes('quest file was saved, but live reload failed')) { + return 'Файл сохранился, но live reload квестов упал. Посмотри лог сервера: чаще всего причина в невалидной ссылке на goal/dialog/item.' + } + if (normalized.includes('already defines a field named type')) { + return 'На сервере старая сборка с ошибкой сериализации goal.type. Пересобери plugin и перезапусти сервер.' + } + return undefined +} + +function formatQuestNetworkError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + const hint = errorHint(0, message) + return hint ? `Quest API недоступен: ${message}\n\n${hint}` : `Quest API недоступен: ${message}` +} + +function decodeHtml(value: string): string { + const textarea = document.createElement('textarea') + textarea.innerHTML = value + return textarea.value +}